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:
+177
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ pub struct LatestReplayPath(pub Option<PathBuf>);
|
||||
#[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::<ButtonInput<KeyCode>>()
|
||||
.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::<StatsResource>().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::<crate::resources::GameStateResource>()
|
||||
.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::<StatsResource>().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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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