3984231c9b
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>
223 lines
7.7 KiB
Rust
223 lines
7.7 KiB
Rust
//! Shared `StatsSnapshot` definition — used by both the game client and the
|
||
//! sync server to represent cumulative player statistics.
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Cumulative game statistics that travel across the sync boundary.
|
||
///
|
||
/// Game-logic mutation helpers that depend on `solitaire_core` types (e.g.
|
||
/// `update_on_win`) are provided via the `StatsExt` extension trait in
|
||
/// `solitaire_data`. File I/O helpers also live in `solitaire_data::storage`.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct StatsSnapshot {
|
||
/// Total number of games started (won + lost + abandoned).
|
||
pub games_played: u32,
|
||
/// Number of games won.
|
||
pub games_won: u32,
|
||
/// Number of games lost or abandoned.
|
||
pub games_lost: u32,
|
||
/// Current win streak length.
|
||
pub win_streak_current: u32,
|
||
/// All-time best win streak.
|
||
pub win_streak_best: u32,
|
||
/// Rolling average of win times in seconds.
|
||
pub avg_time_seconds: u64,
|
||
/// Fastest single win time in seconds. `u64::MAX` when no wins recorded yet.
|
||
pub fastest_win_seconds: u64,
|
||
/// Sum of all winning scores.
|
||
pub lifetime_score: u64,
|
||
/// Highest score achieved in a single game.
|
||
pub best_single_score: u32,
|
||
/// Wins achieved in Draw-One mode.
|
||
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>,
|
||
}
|
||
|
||
impl Default for StatsSnapshot {
|
||
fn default() -> Self {
|
||
Self {
|
||
games_played: 0,
|
||
games_won: 0,
|
||
games_lost: 0,
|
||
win_streak_current: 0,
|
||
win_streak_best: 0,
|
||
avg_time_seconds: 0,
|
||
fastest_win_seconds: u64::MAX,
|
||
lifetime_score: 0,
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl StatsSnapshot {
|
||
/// Record an abandoned game (player started a new game without winning).
|
||
pub fn record_abandoned(&mut self) {
|
||
self.games_played += 1;
|
||
self.games_lost += 1;
|
||
self.win_streak_current = 0;
|
||
self.last_modified = Utc::now();
|
||
}
|
||
|
||
/// Win percentage as 0–100, or `None` if no games played.
|
||
pub fn win_rate(&self) -> Option<f32> {
|
||
if self.games_played == 0 {
|
||
None
|
||
} else {
|
||
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn win_rate_is_none_before_any_game() {
|
||
let s = StatsSnapshot::default();
|
||
assert!(s.win_rate().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn win_rate_100_when_all_games_won() {
|
||
let s = StatsSnapshot {
|
||
games_played: 5,
|
||
games_won: 5,
|
||
..StatsSnapshot::default()
|
||
};
|
||
let rate = s.win_rate().expect("should have a rate");
|
||
assert!((rate - 100.0).abs() < 0.01, "expected 100.0, got {rate}");
|
||
}
|
||
|
||
#[test]
|
||
fn win_rate_50_when_half_won() {
|
||
let s = StatsSnapshot {
|
||
games_played: 10,
|
||
games_won: 5,
|
||
..StatsSnapshot::default()
|
||
};
|
||
let rate = s.win_rate().expect("should have a rate");
|
||
assert!((rate - 50.0).abs() < 0.01, "expected 50.0, got {rate}");
|
||
}
|
||
|
||
#[test]
|
||
fn win_rate_0_when_no_wins() {
|
||
let s = StatsSnapshot {
|
||
games_played: 3,
|
||
games_won: 0,
|
||
..StatsSnapshot::default()
|
||
};
|
||
let rate = s.win_rate().expect("should have a rate");
|
||
assert!((rate - 0.0).abs() < 0.01, "expected 0.0, got {rate}");
|
||
}
|
||
|
||
#[test]
|
||
fn fastest_win_seconds_defaults_to_max() {
|
||
let s = StatsSnapshot::default();
|
||
assert_eq!(s.fastest_win_seconds, u64::MAX);
|
||
}
|
||
|
||
#[test]
|
||
fn record_abandoned_increments_played_and_lost() {
|
||
let mut s = StatsSnapshot::default();
|
||
s.record_abandoned();
|
||
assert_eq!(s.games_played, 1);
|
||
assert_eq!(s.games_lost, 1);
|
||
assert_eq!(s.games_won, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn record_abandoned_resets_win_streak() {
|
||
let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
|
||
s.record_abandoned();
|
||
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
|
||
}
|
||
|
||
#[test]
|
||
fn record_abandoned_preserves_best_streak() {
|
||
let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
|
||
s.record_abandoned();
|
||
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);
|
||
}
|
||
}
|