feat(engine): 14-day daily-challenge calendar in the Profile modal
The daily challenge already updated streak counters, but past completions were invisible — the player had no in-game surface to see streak length or the actual day-by-day record. Adds a 14-dot horizontal calendar above the Profile modal's achievements section with a "Current streak: N · Longest: M" caption. Each dot represents a day in the trailing 14-day window ending today. Today's dot gets a 2-px Balatro-yellow ring; completed days fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 × 12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the modal's 360 px min_width even on the 800 px window minimum. PlayerProgress gains two #[serde(default)] fields: - daily_challenge_history: Vec<NaiveDate> capped at 365 entries (one year of history; older entries pushed off when the cap is hit). Sorted ascending, deduped on insert so same-day re-runs don't bloat the list. - daily_challenge_longest_streak: u32, updated whenever streak exceeds the previous max. Legacy progress.json files load to empty/0 via #[serde(default)]. solitaire_sync::merge unions histories from local + remote (sorted, capped) and takes max(longest_streak), with a clamp to ensure longest is never below the merged current streak — guards against legacy payloads where longest=0 but current is mid-streak. 13 new tests across solitaire_sync (record_daily history append, chronological order, dedupe, cap, longest update, merge union, merge cap, max longest, clamp), solitaire_data (history append, longest update, legacy deserialise), and solitaire_engine (modal renders 14 dots, today marker on rightmost only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -298,4 +298,70 @@ mod tests {
|
|||||||
assert!(!recorded_again, "same-day completion must report no-op");
|
assert!(!recorded_again, "same-day completion must report no-op");
|
||||||
assert_eq!(p.daily_challenge_streak, 1);
|
assert_eq!(p.daily_challenge_streak, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge history & longest streak ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_appends_to_history() {
|
||||||
|
// Recording a completion adds the date to history, preserving the
|
||||||
|
// pre-call length + 1, and the new entry is the chronological tail.
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
let prev_len = p.daily_challenge_history.len();
|
||||||
|
let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
|
||||||
|
let recorded = p.record_daily_completion(today);
|
||||||
|
assert!(recorded);
|
||||||
|
assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
|
||||||
|
assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_updates_longest_streak() {
|
||||||
|
// A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
|
||||||
|
// (we seed the previous best at 2 and watch it get overtaken).
|
||||||
|
let mut p = PlayerProgress {
|
||||||
|
daily_challenge_longest_streak: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
|
||||||
|
p.record_daily_completion(d);
|
||||||
|
p.record_daily_completion(d + Duration::days(1));
|
||||||
|
p.record_daily_completion(d + Duration::days(2));
|
||||||
|
// 3rd consecutive day equals the previous best; longest should match.
|
||||||
|
assert_eq!(p.daily_challenge_streak, 3);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||||
|
// 4th consecutive day overtakes the previous best.
|
||||||
|
p.record_daily_completion(d + Duration::days(3));
|
||||||
|
assert_eq!(p.daily_challenge_streak, 4);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_progress_without_history_deserializes_to_empty() {
|
||||||
|
// A progress.json file produced before the history fields existed
|
||||||
|
// must still round-trip through serde::from_slice without error,
|
||||||
|
// with the new fields landing on their `#[serde(default)]` values.
|
||||||
|
let path = tmp_path("legacy_no_history");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let legacy_json = br#"{
|
||||||
|
"total_xp": 1500,
|
||||||
|
"level": 3,
|
||||||
|
"daily_challenge_last_completed": null,
|
||||||
|
"daily_challenge_streak": 0,
|
||||||
|
"weekly_goal_progress": {},
|
||||||
|
"unlocked_card_backs": [0],
|
||||||
|
"unlocked_backgrounds": [0],
|
||||||
|
"last_modified": "2026-04-29T12:00:00Z"
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, legacy_json).expect("write");
|
||||||
|
let p = load_progress_from(&path);
|
||||||
|
assert_eq!(p.total_xp, 1500);
|
||||||
|
assert!(
|
||||||
|
p.daily_challenge_history.is_empty(),
|
||||||
|
"legacy file lacking daily_challenge_history must default to empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_longest_streak, 0,
|
||||||
|
"legacy file lacking daily_challenge_longest_streak must default to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use chrono::{Duration, Local, NaiveDate};
|
||||||
use solitaire_core::achievement::achievement_by_id;
|
use solitaire_core::achievement::achievement_by_id;
|
||||||
use solitaire_data::SyncBackend;
|
use solitaire_data::SyncBackend;
|
||||||
|
|
||||||
@@ -20,14 +21,38 @@ use crate::ui_modal::{
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||||
TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Number of days surfaced in the daily-challenge calendar row.
|
||||||
|
///
|
||||||
|
/// 14 = trailing two weeks ending today. At ~12 px per dot with a 6 px gap
|
||||||
|
/// the row is ~246 px wide — well inside the 360 px minimum modal width on
|
||||||
|
/// the smallest supported window (800 px).
|
||||||
|
const CALENDAR_DAYS: usize = 14;
|
||||||
|
|
||||||
|
/// Diameter of each calendar dot, in pixels.
|
||||||
|
const CALENDAR_DOT_SIZE_PX: f32 = 12.0;
|
||||||
|
|
||||||
/// Marker component on the profile overlay root node.
|
/// Marker component on the profile overlay root node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ProfileScreen;
|
pub struct ProfileScreen;
|
||||||
|
|
||||||
|
/// Marker on each daily-challenge calendar dot inside the Profile modal.
|
||||||
|
///
|
||||||
|
/// One entity per day in the trailing 14-day window — tests can query
|
||||||
|
/// for this component to assert the row was rendered.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct DailyCalendarDot {
|
||||||
|
/// The calendar date this dot represents.
|
||||||
|
pub date: NaiveDate,
|
||||||
|
/// Whether the player completed the daily challenge on `date`.
|
||||||
|
pub completed: bool,
|
||||||
|
/// `true` if `date == today` (the rightmost dot).
|
||||||
|
pub is_today: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Registers the `P` key toggle for the profile overlay.
|
/// Registers the `P` key toggle for the profile overlay.
|
||||||
pub struct ProfilePlugin;
|
pub struct ProfilePlugin;
|
||||||
|
|
||||||
@@ -195,6 +220,16 @@ fn spawn_profile_screen(
|
|||||||
font_row.clone(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// 14-day daily-challenge calendar row.
|
||||||
|
spawn_daily_calendar(
|
||||||
|
card,
|
||||||
|
&prog.daily_challenge_history,
|
||||||
|
prog.daily_challenge_streak,
|
||||||
|
prog.daily_challenge_longest_streak,
|
||||||
|
Local::now().date_naive(),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Achievements section ────────────────────────────────────
|
// ── Achievements section ────────────────────────────────────
|
||||||
@@ -300,6 +335,98 @@ fn spawn_spacer(parent: &mut ChildSpawnerCommands, height: Val) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn the daily-challenge calendar row: a caption + 14 dots.
|
||||||
|
///
|
||||||
|
/// `history` is the player's full chronological completion history.
|
||||||
|
/// `current_streak` and `longest_streak` are surfaced in the caption.
|
||||||
|
/// `today` is passed in (rather than read directly) so the function is
|
||||||
|
/// trivially testable with a fixed reference date.
|
||||||
|
///
|
||||||
|
/// Layout: caption row → row of 14 dots (~12 px each, 6 px gap). The
|
||||||
|
/// rightmost dot represents today; past dots fill from oldest (left) to
|
||||||
|
/// most recent (right). Each dot carries a [`DailyCalendarDot`] marker.
|
||||||
|
fn spawn_daily_calendar(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
history: &[NaiveDate],
|
||||||
|
current_streak: u32,
|
||||||
|
longest_streak: u32,
|
||||||
|
today: NaiveDate,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let history_set: HashSet<NaiveDate> = history.iter().copied().collect();
|
||||||
|
|
||||||
|
let font_caption = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
parent.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"Current streak: {current_streak} \u{00B7} Longest: {longest_streak}"
|
||||||
|
)),
|
||||||
|
font_caption,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect {
|
||||||
|
top: VAL_SPACE_1,
|
||||||
|
bottom: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(SPACE_1 + 2.0), // 6 px between dots
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
// Iterate from oldest (today − 13) to today (rightmost).
|
||||||
|
for offset in (0..CALENDAR_DAYS as i64).rev() {
|
||||||
|
let date = today - Duration::days(offset);
|
||||||
|
let is_today = offset == 0;
|
||||||
|
let completed = history_set.contains(&date);
|
||||||
|
// Today's dot keeps the outlined-ring look (Balatro-yellow
|
||||||
|
// accent border) regardless of completion; past days use a
|
||||||
|
// subtle border so the row reads as a row of pills, not a
|
||||||
|
// strip of bare squares.
|
||||||
|
let border_color = if is_today { ACCENT_PRIMARY } else { BORDER_STRONG };
|
||||||
|
let border_width = if is_today { 2.0 } else { 0.0 };
|
||||||
|
row.spawn((
|
||||||
|
DailyCalendarDot {
|
||||||
|
date,
|
||||||
|
completed,
|
||||||
|
is_today,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Px(CALENDAR_DOT_SIZE_PX),
|
||||||
|
height: Val::Px(CALENDAR_DOT_SIZE_PX),
|
||||||
|
border: UiRect::all(Val::Px(border_width)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(CALENDAR_DOT_SIZE_PX / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(calendar_dot_color(completed)),
|
||||||
|
BorderColor::all(border_color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background colour for a calendar dot. `STATE_SUCCESS` for completed
|
||||||
|
/// days, `BG_ELEVATED` for missed/pending days.
|
||||||
|
fn calendar_dot_color(completed: bool) -> Color {
|
||||||
|
if completed {
|
||||||
|
STATE_SUCCESS
|
||||||
|
} else {
|
||||||
|
BG_ELEVATED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `(backend_name, username_display)` for the given sync backend.
|
/// Return `(backend_name, username_display)` for the given sync backend.
|
||||||
fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
|
fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
|
||||||
match backend {
|
match backend {
|
||||||
@@ -417,4 +544,43 @@ mod tests {
|
|||||||
// Level 10 is the first post-table level (span = 1000, starts at 5000).
|
// Level 10 is the first post-table level (span = 1000, starts at 5000).
|
||||||
assert_eq!(xp_progress(5_000, 10), (1_000, 0));
|
assert_eq!(xp_progress(5_000, 10), (1_000, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_modal_renders_14_calendar_dots() {
|
||||||
|
// Open the Profile modal and assert the 14-day calendar row was
|
||||||
|
// populated with one DailyCalendarDot entity per day.
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyP);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let dot_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&DailyCalendarDot>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
dot_count, CALENDAR_DAYS,
|
||||||
|
"Profile modal must render exactly {CALENDAR_DAYS} calendar dots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_dot_today_marker_is_set_on_rightmost_dot_only() {
|
||||||
|
// Exactly one of the 14 dots is the "today" dot (the rightmost).
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyP);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let today_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&DailyCalendarDot>()
|
||||||
|
.iter(app.world())
|
||||||
|
.filter(|d| d.is_today)
|
||||||
|
.count();
|
||||||
|
assert_eq!(today_count, 1, "exactly one dot must be marked is_today");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-2
@@ -3,10 +3,10 @@
|
|||||||
//! All functions are free of I/O and side effects — safe to call from any
|
//! All functions are free of I/O and side effects — safe to call from any
|
||||||
//! context including unit tests and the Bevy main thread.
|
//! context including unit tests and the Bevy main thread.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::{NaiveDate, Utc};
|
||||||
|
|
||||||
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
use crate::progress::level_for_xp;
|
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||||
|
|
||||||
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||||
///
|
///
|
||||||
@@ -240,6 +240,22 @@ fn merge_progress(
|
|||||||
// Challenge index: take the higher (further ahead in challenge progression).
|
// Challenge index: take the higher (further ahead in challenge progression).
|
||||||
let challenge_index = local.challenge_index.max(remote.challenge_index);
|
let challenge_index = local.challenge_index.max(remote.challenge_index);
|
||||||
|
|
||||||
|
// Daily-challenge history: union the two ordered lists into a sorted,
|
||||||
|
// deduplicated, capped Vec so completions made on either device survive.
|
||||||
|
let daily_challenge_history = union_naive_dates(
|
||||||
|
&local.daily_challenge_history,
|
||||||
|
&remote.daily_challenge_history,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Longest streak ever: simple max — never regresses.
|
||||||
|
let daily_challenge_longest_streak = local
|
||||||
|
.daily_challenge_longest_streak
|
||||||
|
.max(remote.daily_challenge_longest_streak)
|
||||||
|
// Also defend against an old payload whose `longest_streak` was
|
||||||
|
// never written but whose current `daily_challenge_streak` exceeds
|
||||||
|
// the recorded longest — keep them coherent post-merge.
|
||||||
|
.max(daily_challenge_streak);
|
||||||
|
|
||||||
PlayerProgress {
|
PlayerProgress {
|
||||||
total_xp,
|
total_xp,
|
||||||
level: level_for_xp(total_xp),
|
level: level_for_xp(total_xp),
|
||||||
@@ -250,6 +266,8 @@ fn merge_progress(
|
|||||||
unlocked_card_backs,
|
unlocked_card_backs,
|
||||||
unlocked_backgrounds,
|
unlocked_backgrounds,
|
||||||
challenge_index,
|
challenge_index,
|
||||||
|
daily_challenge_history,
|
||||||
|
daily_challenge_longest_streak,
|
||||||
last_modified: Utc::now(),
|
last_modified: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +279,20 @@ fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
|
|||||||
set.into_iter().collect()
|
set.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the sorted union of two `NaiveDate` slices with duplicates
|
||||||
|
/// removed and the result capped at [`DAILY_CHALLENGE_HISTORY_CAP`]
|
||||||
|
/// entries (oldest dates trimmed first).
|
||||||
|
fn union_naive_dates(a: &[NaiveDate], b: &[NaiveDate]) -> Vec<NaiveDate> {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
let set: BTreeSet<NaiveDate> = a.iter().chain(b.iter()).copied().collect();
|
||||||
|
let mut v: Vec<NaiveDate> = set.into_iter().collect();
|
||||||
|
if v.len() > DAILY_CHALLENGE_HISTORY_CAP {
|
||||||
|
let excess = v.len() - DAILY_CHALLENGE_HISTORY_CAP;
|
||||||
|
v.drain(0..excess);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -753,4 +785,110 @@ mod tests {
|
|||||||
let (merged, _) = merge(&local, &remote);
|
let (merged, _) = merge(&local, &remote);
|
||||||
assert_eq!(merged.stats.fastest_win_seconds, 300);
|
assert_eq!(merged.stats.fastest_win_seconds, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Daily-challenge history + longest-streak merge
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn nd(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_unions_daily_challenge_history() {
|
||||||
|
// Local and remote have disjoint completion dates; the merged
|
||||||
|
// history must contain all of them, sorted ascending, with no
|
||||||
|
// duplicates and within the cap.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_history =
|
||||||
|
vec![nd(2026, 4, 20), nd(2026, 4, 22), nd(2026, 4, 24)];
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.daily_challenge_history =
|
||||||
|
vec![nd(2026, 4, 21), nd(2026, 4, 22), nd(2026, 4, 25)];
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_history,
|
||||||
|
vec![
|
||||||
|
nd(2026, 4, 20),
|
||||||
|
nd(2026, 4, 21),
|
||||||
|
nd(2026, 4, 22),
|
||||||
|
nd(2026, 4, 24),
|
||||||
|
nd(2026, 4, 25),
|
||||||
|
],
|
||||||
|
"history union must be sorted, deduplicated, and contain every date from either side"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
merged.progress.daily_challenge_history.len() <= DAILY_CHALLENGE_HISTORY_CAP,
|
||||||
|
"merged history must respect the 365-entry cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_caps_daily_challenge_history_at_max() {
|
||||||
|
// Construct a local history that already has CAP entries and a
|
||||||
|
// remote history that adds 50 fresher entries — the merge must
|
||||||
|
// drop the oldest 50 so the cap is preserved.
|
||||||
|
let start = nd(2024, 1, 1);
|
||||||
|
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
|
||||||
|
.map(|i| start + chrono::Duration::days(i))
|
||||||
|
.collect();
|
||||||
|
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
|
||||||
|
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
|
||||||
|
.map(|i| start + chrono::Duration::days(i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_history = local_dates.clone();
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.daily_challenge_history = remote_dates.clone();
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_history.len(),
|
||||||
|
DAILY_CHALLENGE_HISTORY_CAP,
|
||||||
|
"merged history must be capped at DAILY_CHALLENGE_HISTORY_CAP"
|
||||||
|
);
|
||||||
|
// The oldest 50 entries should have been evicted; oldest retained
|
||||||
|
// is therefore start + 50 days.
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_history.first().copied(),
|
||||||
|
Some(start + chrono::Duration::days(50))
|
||||||
|
);
|
||||||
|
// Most recent retained is the last remote date.
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_history.last().copied(),
|
||||||
|
remote_dates.last().copied()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_takes_max_longest_streak() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_longest_streak = 4;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.daily_challenge_longest_streak = 9;
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_longest_streak, 9,
|
||||||
|
"longest streak must be the max across both sides"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_longest_streak_never_below_current_streak() {
|
||||||
|
// If a payload's `daily_challenge_longest_streak` was never written
|
||||||
|
// (legacy file) but its `daily_challenge_streak` is non-zero, the
|
||||||
|
// merged longest must reflect at least the current streak so the
|
||||||
|
// two values stay coherent.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_streak = 7;
|
||||||
|
local.progress.daily_challenge_longest_streak = 0; // legacy
|
||||||
|
let remote = default_payload();
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert!(
|
||||||
|
merged.progress.daily_challenge_longest_streak >= 7,
|
||||||
|
"longest streak must be at least as large as the merged current streak"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ pub fn level_for_xp(xp: u64) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum number of dates retained in [`PlayerProgress::daily_challenge_history`].
|
||||||
|
///
|
||||||
|
/// Bounds the per-player file size across years of play. ~365 entries is
|
||||||
|
/// roughly a year of daily completions, far more than the 14-day window the
|
||||||
|
/// in-game calendar surfaces.
|
||||||
|
pub const DAILY_CHALLENGE_HISTORY_CAP: usize = 365;
|
||||||
|
|
||||||
/// Persisted player progression state.
|
/// Persisted player progression state.
|
||||||
///
|
///
|
||||||
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
|
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
|
||||||
@@ -45,6 +52,14 @@ pub struct PlayerProgress {
|
|||||||
/// Index of the next Challenge-mode seed to serve to this player.
|
/// Index of the next Challenge-mode seed to serve to this player.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub challenge_index: u32,
|
pub challenge_index: u32,
|
||||||
|
/// All dates the player has completed the daily challenge, in
|
||||||
|
/// chronological ascending order. Bounded to the most recent 365
|
||||||
|
/// entries so file size stays bounded across years of play.
|
||||||
|
#[serde(default)]
|
||||||
|
pub daily_challenge_history: Vec<NaiveDate>,
|
||||||
|
/// Longest daily-challenge streak ever achieved on this profile.
|
||||||
|
#[serde(default)]
|
||||||
|
pub daily_challenge_longest_streak: u32,
|
||||||
/// Wall-clock time of the last modification (used for conflict detection).
|
/// Wall-clock time of the last modification (used for conflict detection).
|
||||||
pub last_modified: DateTime<Utc>,
|
pub last_modified: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -61,6 +76,8 @@ impl Default for PlayerProgress {
|
|||||||
unlocked_card_backs: vec![0],
|
unlocked_card_backs: vec![0],
|
||||||
unlocked_backgrounds: vec![0],
|
unlocked_backgrounds: vec![0],
|
||||||
challenge_index: 0,
|
challenge_index: 0,
|
||||||
|
daily_challenge_history: Vec::new(),
|
||||||
|
daily_challenge_longest_streak: 0,
|
||||||
last_modified: DateTime::UNIX_EPOCH,
|
last_modified: DateTime::UNIX_EPOCH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +131,12 @@ impl PlayerProgress {
|
|||||||
/// - Completion the day after the previous: streak increments.
|
/// - Completion the day after the previous: streak increments.
|
||||||
/// - Same day as the previous: no-op (idempotent).
|
/// - Same day as the previous: no-op (idempotent).
|
||||||
///
|
///
|
||||||
|
/// On every fresh completion, `date` is appended to
|
||||||
|
/// `daily_challenge_history` (kept sorted ascending and capped at
|
||||||
|
/// [`DAILY_CHALLENGE_HISTORY_CAP`] entries) and
|
||||||
|
/// `daily_challenge_longest_streak` is bumped if the current streak
|
||||||
|
/// exceeds it.
|
||||||
|
///
|
||||||
/// Returns `true` if this call recorded a fresh completion.
|
/// Returns `true` if this call recorded a fresh completion.
|
||||||
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
||||||
match self.daily_challenge_last_completed {
|
match self.daily_challenge_last_completed {
|
||||||
@@ -126,6 +149,19 @@ impl PlayerProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.daily_challenge_last_completed = Some(date);
|
self.daily_challenge_last_completed = Some(date);
|
||||||
|
// Append to history (defensive against duplicates and out-of-order
|
||||||
|
// dates so a hand-edited or merged file can't corrupt the order).
|
||||||
|
if !self.daily_challenge_history.contains(&date) {
|
||||||
|
self.daily_challenge_history.push(date);
|
||||||
|
self.daily_challenge_history.sort();
|
||||||
|
if self.daily_challenge_history.len() > DAILY_CHALLENGE_HISTORY_CAP {
|
||||||
|
let excess = self.daily_challenge_history.len() - DAILY_CHALLENGE_HISTORY_CAP;
|
||||||
|
self.daily_challenge_history.drain(0..excess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.daily_challenge_streak > self.daily_challenge_longest_streak {
|
||||||
|
self.daily_challenge_longest_streak = self.daily_challenge_streak;
|
||||||
|
}
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -320,4 +356,85 @@ mod tests {
|
|||||||
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
|
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
|
||||||
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak");
|
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// record_daily_completion — history + longest-streak side effects
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_appends_to_history_in_chronological_order() {
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
assert!(p.daily_challenge_history.is_empty());
|
||||||
|
p.record_daily_completion(date(2026, 4, 20));
|
||||||
|
p.record_daily_completion(date(2026, 4, 21));
|
||||||
|
p.record_daily_completion(date(2026, 4, 22));
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_history,
|
||||||
|
vec![
|
||||||
|
date(2026, 4, 20),
|
||||||
|
date(2026, 4, 21),
|
||||||
|
date(2026, 4, 22),
|
||||||
|
],
|
||||||
|
"history should hold all three completions in ascending order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_same_day_does_not_duplicate_history() {
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
p.record_daily_completion(date(2026, 4, 20));
|
||||||
|
p.record_daily_completion(date(2026, 4, 20));
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_history,
|
||||||
|
vec![date(2026, 4, 20)],
|
||||||
|
"same-day completion is a no-op and must not duplicate history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_updates_longest_streak() {
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
// Three-day streak: longest jumps from 0 → 3.
|
||||||
|
p.record_daily_completion(date(2026, 4, 20));
|
||||||
|
p.record_daily_completion(date(2026, 4, 21));
|
||||||
|
p.record_daily_completion(date(2026, 4, 22));
|
||||||
|
assert_eq!(p.daily_challenge_streak, 3);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||||
|
|
||||||
|
// Gap resets the current streak — longest must NOT regress.
|
||||||
|
p.record_daily_completion(date(2026, 4, 25));
|
||||||
|
assert_eq!(p.daily_challenge_streak, 1);
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_longest_streak, 3,
|
||||||
|
"longest_streak must never regress after a gap"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two-day streak — still below longest, so longest stays at 3.
|
||||||
|
p.record_daily_completion(date(2026, 4, 26));
|
||||||
|
assert_eq!(p.daily_challenge_streak, 2);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daily_challenge_history_is_capped_at_max() {
|
||||||
|
// Push DAILY_CHALLENGE_HISTORY_CAP + 5 consecutive days; the
|
||||||
|
// earliest five must be evicted and the most recent CAP retained.
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
let start = date(2024, 1, 1);
|
||||||
|
let total = DAILY_CHALLENGE_HISTORY_CAP + 5;
|
||||||
|
for offset in 0..total {
|
||||||
|
p.record_daily_completion(start + Duration::days(offset as i64));
|
||||||
|
}
|
||||||
|
assert_eq!(p.daily_challenge_history.len(), DAILY_CHALLENGE_HISTORY_CAP);
|
||||||
|
// Oldest retained is `start + 5` (we dropped the first 5).
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_history.first().copied(),
|
||||||
|
Some(start + Duration::days(5))
|
||||||
|
);
|
||||||
|
// Newest retained is the last date pushed.
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_history.last().copied(),
|
||||||
|
Some(start + Duration::days(total as i64 - 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user