//! 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, } 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 { 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); } }