feat(data,sync,engine): per-mode best score and fastest win
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Utc>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user