Files
Ferrous-Solitaire/solitaire_sync/src/stats.rs
T
funman300 6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00

235 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 0100, 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);
}
}