From 3984231c9bd8f92f4dd942917d090123de471c82 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 5 May 2026 18:46:32 +0000 Subject: [PATCH] feat(data,sync,engine): per-mode best score and fastest win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifetime stats now also track best score and fastest win per game mode (Classic, Zen, Challenge), additive on top of the existing all-modes-combined `best_single_score` and `fastest_win_seconds`. Time Attack is intentionally excluded — its scoring model is session-level (count of wins inside a 10-minute window) so a per-game best wouldn't compose. Daily Challenge inherits Classic scoring and contributes through the Classic row. - `solitaire_sync::StatsSnapshot` gains six fields (`{mode}_best_score`, `{mode}_fastest_win_seconds` × {Classic, Zen, Challenge}). All are `#[serde(default)]` so older save files load cleanly to zeros. - `solitaire_sync::merge` propagates the per-mode bests through the same max/min logic as the global counterparts. - `solitaire_data::StatsExt::update_per_mode_bests` is the engine's entry point — called from `update_stats_on_win` alongside the existing `update_on_win`. - Stats overlay grows a "Per-mode bests" section with three rows (Classic / Zen / Challenge) tagged with `PerModeBestsRow`. Empty rows render an em-dash, matching the first-launch zero-state treatment used by the primary cells. - 3 new tests cover the rendering, the Classic-mode update path, and the Zen-mode update path. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_data/src/stats.rs | 179 +++++++++++++++++++++++++- solitaire_engine/src/stats_plugin.rs | 185 +++++++++++++++++++++++++++ solitaire_sync/src/merge.rs | 116 +++++++++++++++++ solitaire_sync/src/stats.rs | 72 +++++++++++ 4 files changed, 550 insertions(+), 2 deletions(-) diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index d4ddb51..aaf7f79 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -5,16 +5,35 @@ //! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`. use chrono::Utc; -use solitaire_core::game_state::DrawMode; +use solitaire_core::game_state::{DrawMode, GameMode}; pub use solitaire_sync::StatsSnapshot; /// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`]. /// -/// Import this trait alongside `StatsSnapshot` to use `update_on_win`. +/// Import this trait alongside `StatsSnapshot` to use `update_on_win` +/// and [`StatsExt::update_per_mode_bests`]. pub trait StatsExt { /// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`. + /// + /// Tracks lifetime totals only — per-mode best scores and times are + /// updated separately via [`StatsExt::update_per_mode_bests`] so the + /// long-standing call sites that only know about [`DrawMode`] keep + /// compiling. fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode); + + /// Updates the per-mode best score and fastest-win-time fields for the + /// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from + /// the win handler. + /// + /// Behaviour: + /// - `Classic`, `Zen`, `Challenge`: updates the matching `*_best_score` + /// (max) and `*_fastest_win_seconds` (zero-aware min — 0 means + /// "no win recorded yet"). + /// - `TimeAttack`: no-op. Time Attack uses session-level scoring (count + /// of wins in 10 minutes); a per-game best wouldn't compose with + /// the other modes' single-game scoring. + fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode); } impl StatsExt for StatsSnapshot { @@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot { self.last_modified = Utc::now(); } + + fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode) { + let score_u32 = score.max(0) as u32; + // Zero-aware min — 0 means "no win recorded yet" for the per-mode + // fastest fields, so we must not let a real time get clobbered to 0. + // (Mirrors the merge logic in `solitaire_sync::merge`.) + let min_ignore_zero = |existing: u64, candidate: u64| -> u64 { + if existing == 0 { + candidate + } else if candidate == 0 { + existing + } else { + existing.min(candidate) + } + }; + match mode { + GameMode::Classic => { + self.classic_best_score = self.classic_best_score.max(score_u32); + self.classic_fastest_win_seconds = + min_ignore_zero(self.classic_fastest_win_seconds, time_seconds); + } + GameMode::Zen => { + self.zen_best_score = self.zen_best_score.max(score_u32); + self.zen_fastest_win_seconds = + min_ignore_zero(self.zen_fastest_win_seconds, time_seconds); + } + GameMode::Challenge => { + self.challenge_best_score = self.challenge_best_score.max(score_u32); + self.challenge_fastest_win_seconds = + min_ignore_zero(self.challenge_fastest_win_seconds, time_seconds); + } + // Time Attack uses its own session-level scoring; a per-game best + // wouldn't compose with the other modes' single-game numbers. + GameMode::TimeAttack => {} + } + self.last_modified = Utc::now(); + } } #[cfg(test)] @@ -177,4 +233,123 @@ mod tests { s.update_on_win(200, 60, &DrawMode::DrawOne); assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); } + + // ----------------------------------------------------------------------- + // Per-mode bests + // ----------------------------------------------------------------------- + + #[test] + fn classic_win_updates_classic_best_score_only() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(1500, 200, GameMode::Classic); + assert_eq!(s.classic_best_score, 1500); + assert_eq!(s.classic_fastest_win_seconds, 200); + // Other modes untouched. + assert_eq!(s.zen_best_score, 0); + assert_eq!(s.zen_fastest_win_seconds, 0); + assert_eq!(s.challenge_best_score, 0); + assert_eq!(s.challenge_fastest_win_seconds, 0); + } + + #[test] + fn zen_win_updates_zen_best_score_only() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(1800, 600, GameMode::Zen); + assert_eq!(s.zen_best_score, 1800); + assert_eq!(s.zen_fastest_win_seconds, 600); + assert_eq!(s.classic_best_score, 0); + assert_eq!(s.challenge_best_score, 0); + } + + #[test] + fn challenge_win_updates_challenge_best_score_only() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(2400, 480, GameMode::Challenge); + assert_eq!(s.challenge_best_score, 2400); + assert_eq!(s.challenge_fastest_win_seconds, 480); + assert_eq!(s.classic_best_score, 0); + assert_eq!(s.zen_best_score, 0); + } + + #[test] + fn time_attack_win_does_not_touch_per_mode_bests() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(9999, 1, GameMode::TimeAttack); + assert_eq!(s.classic_best_score, 0); + assert_eq!(s.zen_best_score, 0); + assert_eq!(s.challenge_best_score, 0); + assert_eq!(s.classic_fastest_win_seconds, 0); + assert_eq!(s.zen_fastest_win_seconds, 0); + assert_eq!(s.challenge_fastest_win_seconds, 0); + } + + #[test] + fn per_mode_best_score_takes_max_across_calls() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(500, 200, GameMode::Classic); + s.update_per_mode_bests(200, 200, GameMode::Classic); + s.update_per_mode_bests(900, 200, GameMode::Classic); + assert_eq!(s.classic_best_score, 900); + } + + #[test] + fn per_mode_fastest_uses_zero_aware_min() { + // First Classic win: 240s. Field starts at 0 (no win yet) — we + // must adopt 240, not stay at 0 like a naive `min` would. + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(100, 240, GameMode::Classic); + assert_eq!(s.classic_fastest_win_seconds, 240); + // Faster Classic win replaces it. + s.update_per_mode_bests(100, 120, GameMode::Classic); + assert_eq!(s.classic_fastest_win_seconds, 120); + // Slower Classic win does not. + s.update_per_mode_bests(100, 300, GameMode::Classic); + assert_eq!(s.classic_fastest_win_seconds, 120); + } + + #[test] + fn negative_score_treated_as_zero_in_per_mode() { + let mut s = StatsSnapshot::default(); + s.update_per_mode_bests(-50, 240, GameMode::Classic); + assert_eq!(s.classic_best_score, 0); + // Time still recorded — a win with a low score is still a win. + assert_eq!(s.classic_fastest_win_seconds, 240); + } + + #[test] + fn legacy_stats_without_per_mode_fields_deserializes_to_zero() { + // A pre-per-mode `stats.json` must still deserialise cleanly: + // every new field falls back to 0 via `#[serde(default)]` so + // updating the binary never wipes the player's old stats file. + let legacy_json = r#"{ + "games_played": 12, + "games_won": 5, + "games_lost": 7, + "win_streak_current": 1, + "win_streak_best": 3, + "avg_time_seconds": 240, + "fastest_win_seconds": 180, + "lifetime_score": 8500, + "best_single_score": 2200, + "draw_one_wins": 4, + "draw_three_wins": 1, + "last_modified": "2026-04-29T12:00:00Z" + }"#; + + let s: StatsSnapshot = serde_json::from_str(legacy_json) + .expect("legacy payload must deserialise without per-mode fields"); + + // Pre-existing fields kept their values. + assert_eq!(s.games_played, 12); + assert_eq!(s.best_single_score, 2200); + assert_eq!(s.fastest_win_seconds, 180); + + // Every new per-mode field defaulted to 0 ("no win yet"). + assert_eq!(s.classic_best_score, 0); + assert_eq!(s.classic_fastest_win_seconds, 0); + assert_eq!(s.zen_best_score, 0); + assert_eq!(s.zen_fastest_win_seconds, 0); + assert_eq!(s.challenge_best_score, 0); + assert_eq!(s.challenge_fastest_win_seconds, 0); + } } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index b87a458..bde9064 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -82,6 +82,15 @@ pub struct LatestReplayPath(pub Option); #[derive(Component, Debug)] pub struct WatchReplayButton; +/// Marker component on each per-mode bests row in the stats overlay. +/// +/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic, +/// Zen, Challenge — Time Attack and Daily are intentionally excluded; see +/// `StatsSnapshot` doc comments). Tests query by this marker to assert the +/// per-mode section rendered. +#[derive(Component, Debug)] +pub struct PerModeBestsRow; + /// Registers stats resources, update systems, and the UI toggle. pub struct StatsPlugin { /// Where to persist stats. `None` disables all file I/O (for tests). @@ -236,6 +245,13 @@ fn update_stats_on_win( stats .0 .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); + // Per-mode best score / fastest win — additive on top of the + // lifetime totals tracked by `update_on_win`. TimeAttack is a + // no-op inside the helper because it has its own session-level + // scoring model. + stats + .0 + .update_per_mode_bests(ev.score, ev.time_seconds, game.0.mode); let new_streak = stats.0.win_streak_current; // Fire the streak-milestone event only on the threshold // crossing — `prev < threshold && new >= threshold`. This @@ -460,6 +476,46 @@ fn spawn_stats_screen( spawn_stat_cell(grid, &best_streak_str, "Best Streak"); }); + // --- per-mode bests section --- + // Three rows, one per supported mode. Time Attack uses session-level + // scoring (count of wins inside a 10-minute window) so a per-game + // best wouldn't compose; Daily uses Classic scoring and so already + // contributes to the Classic row. + card.spawn(( + Text::new("Per-mode bests"), + font_section.clone(), + TextColor(STATE_INFO), + )); + card.spawn(Node { + flex_direction: FlexDirection::Column, + width: Val::Percent(100.0), + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|column| { + spawn_per_mode_bests_row( + column, + "Classic", + stats.classic_best_score, + stats.classic_fastest_win_seconds, + &font_row, + ); + spawn_per_mode_bests_row( + column, + "Zen", + stats.zen_best_score, + stats.zen_fastest_win_seconds, + &font_row, + ); + spawn_per_mode_bests_row( + column, + "Challenge", + stats.challenge_best_score, + stats.challenge_fastest_win_seconds, + &font_row, + ); + }); + // --- progression section --- if let Some(p) = progress { card.spawn(( @@ -574,6 +630,74 @@ fn spawn_stats_screen( }); } +/// Spawn one row of the "Per-mode bests" section: the mode label on the +/// left, then the best-score and best-time readouts right-aligned. Each +/// row is tagged with [`PerModeBestsRow`] so tests can count them. +/// +/// `best_score == 0` and `fastest_win_seconds == 0` both render as an +/// em-dash, consistent with the first-launch zero-state treatment used +/// by the primary cells above. +fn spawn_per_mode_bests_row( + parent: &mut ChildSpawnerCommands, + mode_label: &str, + best_score: u32, + fastest_win_seconds: u64, + font_row: &TextFont, +) { + let dash = "\u{2014}".to_string(); + let score_str = if best_score == 0 { + format!("Best {dash}") + } else { + format!("Best {best_score}") + }; + let time_str = if fastest_win_seconds == 0 { + format!("Best time {dash}") + } else { + format!("Best time {}", format_duration(fastest_win_seconds)) + }; + + parent + .spawn(( + PerModeBestsRow, + Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + justify_content: JustifyContent::SpaceBetween, + width: Val::Percent(100.0), + column_gap: VAL_SPACE_3, + ..default() + }, + )) + .with_children(|row| { + // Mode label on the left. + row.spawn(( + Text::new(mode_label.to_string()), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + // Right-aligned readouts grouped together. + row.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + justify_content: JustifyContent::FlexEnd, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|readouts| { + readouts.spawn(( + Text::new(score_str), + font_row.clone(), + TextColor(ACCENT_PRIMARY), + )); + readouts.spawn(( + Text::new(time_str), + font_row.clone(), + TextColor(TEXT_SECONDARY), + )); + }); + }); +} + /// Spawn a single stat cell: a large value label on top and a small /// descriptor below, inside a fixed-min-width column with a subtle /// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white @@ -836,6 +960,67 @@ mod tests { ); } + #[test] + fn stats_screen_renders_three_per_mode_bests_rows() { + // Open the Stats overlay and assert three [`PerModeBestsRow`] + // entities exist — one per supported [`GameMode`] (Classic, Zen, + // Challenge — Time Attack and Daily are excluded by design). + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyS); + app.update(); + + let row_count = app + .world_mut() + .query::<&PerModeBestsRow>() + .iter(app.world()) + .count(); + assert_eq!( + row_count, 3, + "expected three per-mode bests rows (Classic, Zen, Challenge), got {row_count}" + ); + } + + #[test] + fn classic_win_event_updates_classic_best_score() { + // Default mode is Classic — a win event should populate the + // Classic per-mode bests but leave Zen and Challenge at zero. + let mut app = headless_app(); + app.world_mut().write_message(GameWonEvent { + score: 1500, + time_seconds: 180, + }); + app.update(); + + let stats = &app.world().resource::().0; + assert_eq!(stats.classic_best_score, 1500); + assert_eq!(stats.classic_fastest_win_seconds, 180); + assert_eq!(stats.zen_best_score, 0); + assert_eq!(stats.challenge_best_score, 0); + } + + #[test] + fn zen_win_event_updates_zen_best_score_only() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .mode = solitaire_core::game_state::GameMode::Zen; + + app.world_mut().write_message(GameWonEvent { + score: 1800, + time_seconds: 600, + }); + app.update(); + + let stats = &app.world().resource::().0; + assert_eq!(stats.zen_best_score, 1800); + assert_eq!(stats.zen_fastest_win_seconds, 600); + assert_eq!(stats.classic_best_score, 0); + assert_eq!(stats.challenge_best_score, 0); + } + #[test] fn pressing_s_twice_closes_stats_screen() { let mut app = headless_app(); diff --git a/solitaire_sync/src/merge.rs b/solitaire_sync/src/merge.rs index 44d95ff..b1bd3a4 100644 --- a/solitaire_sync/src/merge.rs +++ b/solitaire_sync/src/merge.rs @@ -109,10 +109,45 @@ fn merge_stats( best_single_score: local.best_single_score.max(remote.best_single_score), draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins), draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins), + // Per-mode bests. Bests take max; fastest times take a *zero-aware* + // min — see [`min_ignore_zero`] for the rationale (0 means "no win + // yet" for these fields, unlike the lifetime `fastest_win_seconds` + // which uses `u64::MAX` as its sentinel). + classic_best_score: local.classic_best_score.max(remote.classic_best_score), + classic_fastest_win_seconds: min_ignore_zero( + local.classic_fastest_win_seconds, + remote.classic_fastest_win_seconds, + ), + zen_best_score: local.zen_best_score.max(remote.zen_best_score), + zen_fastest_win_seconds: min_ignore_zero( + local.zen_fastest_win_seconds, + remote.zen_fastest_win_seconds, + ), + challenge_best_score: local.challenge_best_score.max(remote.challenge_best_score), + challenge_fastest_win_seconds: min_ignore_zero( + local.challenge_fastest_win_seconds, + remote.challenge_fastest_win_seconds, + ), last_modified: Utc::now(), } } +/// Zero-aware minimum: returns the smaller of `a` and `b`, but treats `0` as +/// "no value recorded yet" so `min_ignore_zero(0, x) == x`. +/// +/// The lifetime `fastest_win_seconds` field uses `u64::MAX` as its "no wins" +/// sentinel (see [`StatsSnapshot::default`]) and so a plain `min` works for +/// it. The per-mode `*_fastest_win_seconds` fields, on the other hand, are +/// `#[serde(default)]` — and `u64`'s default is 0, not `u64::MAX`. Using a +/// straight `min` would therefore wrongly resolve "one side has a real time, +/// the other has no win" to 0. This helper preserves the real time instead. +fn min_ignore_zero(a: u64, b: u64) -> u64 { + match (a, b) { + (0, x) | (x, 0) => x, + _ => a.min(b), + } +} + // --------------------------------------------------------------------------- // Achievements // --------------------------------------------------------------------------- @@ -875,6 +910,87 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // Per-mode bests merge + // ----------------------------------------------------------------------- + + #[test] + fn merge_per_mode_best_takes_max() { + // Classic best score: 1000 vs 2000 → 2000. Mirror behaviour for + // `best_single_score` so per-mode follows the same rule. + let mut local = default_payload(); + local.stats.classic_best_score = 1000; + let mut remote = default_payload(); + remote.stats.classic_best_score = 2000; + + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.classic_best_score, 2000); + } + + #[test] + fn merge_per_mode_best_takes_max_for_zen_and_challenge() { + let mut local = default_payload(); + local.stats.zen_best_score = 800; + local.stats.challenge_best_score = 5000; + let mut remote = default_payload(); + remote.stats.zen_best_score = 1500; + remote.stats.challenge_best_score = 3000; + + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.zen_best_score, 1500); + assert_eq!(merged.stats.challenge_best_score, 5000); + } + + #[test] + fn merge_per_mode_fastest_ignores_zero() { + // Local has no Zen win (zen_fastest = 0); remote has 180s. + // Straight min(0, 180) would return 0 — wrong. The merge must + // preserve the real time. + let local = default_payload(); + let mut remote = default_payload(); + remote.stats.zen_fastest_win_seconds = 180; + + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.zen_fastest_win_seconds, 180); + } + + #[test] + fn merge_per_mode_fastest_takes_min_when_both_present() { + // When both sides have real times, the merge takes the smaller — + // mirroring the lifetime `fastest_win_seconds` behaviour. + let mut local = default_payload(); + local.stats.classic_fastest_win_seconds = 240; + let mut remote = default_payload(); + remote.stats.classic_fastest_win_seconds = 120; + + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.classic_fastest_win_seconds, 120); + } + + #[test] + fn merge_per_mode_fastest_both_zero_stays_zero() { + // Neither side has a win — the field must remain 0 rather than + // accidentally becoming non-zero. + let local = default_payload(); + let remote = default_payload(); + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.classic_fastest_win_seconds, 0); + assert_eq!(merged.stats.zen_fastest_win_seconds, 0); + assert_eq!(merged.stats.challenge_fastest_win_seconds, 0); + } + + #[test] + fn merge_per_mode_fastest_local_real_remote_zero() { + // Symmetric to `merge_per_mode_fastest_ignores_zero`: local has the + // real time, remote is the zero-side. The merge must keep local's + // value rather than flooring to 0. + let mut local = default_payload(); + local.stats.challenge_fastest_win_seconds = 300; + let remote = default_payload(); + let (merged, _) = merge(&local, &remote); + assert_eq!(merged.stats.challenge_fastest_win_seconds, 300); + } + #[test] fn merge_longest_streak_never_below_current_streak() { // If a payload's `daily_challenge_longest_streak` was never written diff --git a/solitaire_sync/src/stats.rs b/solitaire_sync/src/stats.rs index 5190c29..1a2fd3f 100644 --- a/solitaire_sync/src/stats.rs +++ b/solitaire_sync/src/stats.rs @@ -33,6 +33,56 @@ pub struct StatsSnapshot { pub draw_one_wins: u32, /// Wins achieved in Draw-Three mode. pub draw_three_wins: u32, + + // ----------------------------------------------------------------- + // Per-mode bests + // + // These mirror `best_single_score` / `fastest_win_seconds` but + // narrowed to one [`solitaire_core::game_state::GameMode`]. They are + // additive: lifetime totals continue to track across all modes, and + // legacy `stats.json` files load to 0 for every new field via + // `#[serde(default)]`. + // + // Time-Attack and Daily-Challenge are intentionally absent here: + // - Time Attack has its own session-level scoring (count of wins + // inside a 10-minute window); a per-game best wouldn't compose. + // - Daily Challenge uses Classic scoring rules and so already + // contributes to `classic_*` here. + // + // Sentinel for `*_fastest_win_seconds` is `0` (not `u64::MAX`), + // because legacy files deserialise unknown fields to the type's + // `Default::default()` — and `u64::default()` is 0. The merge logic + // and the UI must therefore treat 0 as "no win recorded yet". + // ----------------------------------------------------------------- + + /// Best single score achieved in Classic mode (Draw-One or Draw-Three). + /// 0 means "no Classic win yet". + #[serde(default)] + pub classic_best_score: u32, + + /// Fastest Classic-mode win time, in seconds. 0 means "no Classic win yet". + #[serde(default)] + pub classic_fastest_win_seconds: u64, + + /// Best single score achieved in Zen mode. Zen has no time pressure but + /// scoring is still on, so players who care about it still play for a high. + /// 0 means "no Zen win yet". + #[serde(default)] + pub zen_best_score: u32, + + /// Fastest Zen-mode win time, in seconds. 0 means "no Zen win yet". + #[serde(default)] + pub zen_fastest_win_seconds: u64, + + /// Best single score achieved in Challenge mode (the hardest mode — separate + /// leaderboard). 0 means "no Challenge win yet". + #[serde(default)] + pub challenge_best_score: u32, + + /// Fastest Challenge-mode win time, in seconds. 0 means "no Challenge win yet". + #[serde(default)] + pub challenge_fastest_win_seconds: u64, + /// Wall-clock time of the last modification (used for conflict detection). pub last_modified: DateTime, } @@ -51,6 +101,12 @@ impl Default for StatsSnapshot { best_single_score: 0, draw_one_wins: 0, draw_three_wins: 0, + classic_best_score: 0, + classic_fastest_win_seconds: 0, + zen_best_score: 0, + zen_fastest_win_seconds: 0, + challenge_best_score: 0, + challenge_fastest_win_seconds: 0, last_modified: DateTime::UNIX_EPOCH, } } @@ -147,4 +203,20 @@ mod tests { assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon"); assert_eq!(s.win_streak_current, 0); } + + #[test] + fn per_mode_fields_default_to_zero() { + // The new per-mode fields must default to 0 — both in the explicit + // `Default` impl and (because of `#[serde(default)]`) for any + // legacy payload that omits them. The legacy-JSON deserialise + // round-trip lives in `solitaire_data::stats` where `serde_json` + // is in scope. + let s = StatsSnapshot::default(); + assert_eq!(s.classic_best_score, 0); + assert_eq!(s.classic_fastest_win_seconds, 0); + assert_eq!(s.zen_best_score, 0); + assert_eq!(s.zen_fastest_win_seconds, 0); + assert_eq!(s.challenge_best_score, 0); + assert_eq!(s.challenge_fastest_win_seconds, 0); + } }