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`.
|
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
|
||||||
pub use solitaire_sync::StatsSnapshot;
|
pub use solitaire_sync::StatsSnapshot;
|
||||||
|
|
||||||
/// Extension trait providing game-logic mutation helpers for [`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 {
|
pub trait StatsExt {
|
||||||
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
|
/// 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);
|
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 {
|
impl StatsExt for StatsSnapshot {
|
||||||
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
|
|||||||
|
|
||||||
self.last_modified = Utc::now();
|
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)]
|
#[cfg(test)]
|
||||||
@@ -177,4 +233,123 @@ mod tests {
|
|||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
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)]
|
#[derive(Component, Debug)]
|
||||||
pub struct WatchReplayButton;
|
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.
|
/// Registers stats resources, update systems, and the UI toggle.
|
||||||
pub struct StatsPlugin {
|
pub struct StatsPlugin {
|
||||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||||
@@ -236,6 +245,13 @@ fn update_stats_on_win(
|
|||||||
stats
|
stats
|
||||||
.0
|
.0
|
||||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
.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;
|
let new_streak = stats.0.win_streak_current;
|
||||||
// Fire the streak-milestone event only on the threshold
|
// Fire the streak-milestone event only on the threshold
|
||||||
// crossing — `prev < threshold && new >= threshold`. This
|
// crossing — `prev < threshold && new >= threshold`. This
|
||||||
@@ -460,6 +476,46 @@ fn spawn_stats_screen(
|
|||||||
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
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 ---
|
// --- progression section ---
|
||||||
if let Some(p) = progress {
|
if let Some(p) = progress {
|
||||||
card.spawn((
|
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
|
/// 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
|
/// descriptor below, inside a fixed-min-width column with a subtle
|
||||||
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
|
/// 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]
|
#[test]
|
||||||
fn pressing_s_twice_closes_stats_screen() {
|
fn pressing_s_twice_closes_stats_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -109,10 +109,45 @@ fn merge_stats(
|
|||||||
best_single_score: local.best_single_score.max(remote.best_single_score),
|
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_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||||
draw_three_wins: local.draw_three_wins.max(remote.draw_three_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(),
|
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
|
// 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]
|
#[test]
|
||||||
fn merge_longest_streak_never_below_current_streak() {
|
fn merge_longest_streak_never_below_current_streak() {
|
||||||
// If a payload's `daily_challenge_longest_streak` was never written
|
// If a payload's `daily_challenge_longest_streak` was never written
|
||||||
|
|||||||
@@ -33,6 +33,56 @@ pub struct StatsSnapshot {
|
|||||||
pub draw_one_wins: u32,
|
pub draw_one_wins: u32,
|
||||||
/// Wins achieved in Draw-Three mode.
|
/// Wins achieved in Draw-Three mode.
|
||||||
pub draw_three_wins: u32,
|
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).
|
/// Wall-clock time of the last modification (used for conflict detection).
|
||||||
pub last_modified: DateTime<Utc>,
|
pub last_modified: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -51,6 +101,12 @@ impl Default for StatsSnapshot {
|
|||||||
best_single_score: 0,
|
best_single_score: 0,
|
||||||
draw_one_wins: 0,
|
draw_one_wins: 0,
|
||||||
draw_three_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,
|
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_best, 7, "best streak must not be reduced on abandon");
|
||||||
assert_eq!(s.win_streak_current, 0);
|
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