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:
funman300
2026-05-05 18:46:32 +00:00
parent d9f36bf34a
commit 3984231c9b
4 changed files with 550 additions and 2 deletions
+177 -2
View File
@@ -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);
}
}
+185
View File
@@ -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();
+116
View File
@@ -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
+72
View File
@@ -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);
}
}