Files
Ferrous-Solitaire/docs/superpowers/plans/2026-04-23-phase4-statistics.md
T
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00

39 KiB
Raw Blame History

Phase 4 — Statistics Persistence & Stats Screen

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Persist game statistics to disk and display them in a toggleable bevy_ui overlay.

Architecture: StatsSnapshot is defined and serialized in solitaire_data; StatsPlugin in solitaire_engine loads it on startup, updates it on game events, and saves it atomically. A lightweight bevy_ui overlay (toggled with S) shows the player's stats.

Tech Stack: solitaire_data (stats type + file I/O), solitaire_engine (Bevy plugin + UI), serde_json (serialization), dirs (platform data dir), chrono (timestamps), bevy::ui (overlay screen).


File Map

File Action Responsibility
solitaire_data/src/stats.rs Create StatsSnapshot struct + update_on_win + record_abandoned
solitaire_data/src/storage.rs Create stats_file_path, load_stats_from, save_stats_to, public wrappers
solitaire_data/src/lib.rs Modify Re-export stats and storage modules
solitaire_engine/src/stats_plugin.rs Create StatsResource, StatsPlugin (load/update/save + UI toggle)
solitaire_engine/src/lib.rs Modify Export StatsPlugin, StatsResource
solitaire_app/src/main.rs Modify Register StatsPlugin

Task 1 — StatsSnapshot in solitaire_data

Files:

  • Create: solitaire_data/src/stats.rs
  • Modify: solitaire_data/src/lib.rs

Step 1: Write failing tests

Add to a new file solitaire_data/src/stats.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use solitaire_core::game_state::DrawMode;

    #[test]
    fn default_stats_are_all_zero() {
        let s = StatsSnapshot::default();
        assert_eq!(s.games_played, 0);
        assert_eq!(s.games_won, 0);
        assert_eq!(s.win_streak_current, 0);
        assert_eq!(s.win_streak_best, 0);
        assert_eq!(s.lifetime_score, 0);
        assert_eq!(s.best_single_score, 0);
        assert_eq!(s.fastest_win_seconds, u64::MAX);
    }

    #[test]
    fn first_win_sets_all_fields() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(1500, 120, &DrawMode::DrawOne);
        assert_eq!(s.games_played, 1);
        assert_eq!(s.games_won, 1);
        assert_eq!(s.win_streak_current, 1);
        assert_eq!(s.win_streak_best, 1);
        assert_eq!(s.lifetime_score, 1500);
        assert_eq!(s.best_single_score, 1500);
        assert_eq!(s.fastest_win_seconds, 120);
        assert_eq!(s.avg_time_seconds, 120);
        assert_eq!(s.draw_one_wins, 1);
        assert_eq!(s.draw_three_wins, 0);
    }

    #[test]
    fn streak_tracks_across_wins() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        assert_eq!(s.win_streak_current, 3);
        assert_eq!(s.win_streak_best, 3);
    }

    #[test]
    fn record_abandoned_resets_streak_and_increments_played() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        assert_eq!(s.win_streak_current, 2);
        s.record_abandoned();
        assert_eq!(s.games_played, 3);
        assert_eq!(s.games_lost, 1);
        assert_eq!(s.win_streak_current, 0);
        assert_eq!(s.win_streak_best, 2, "best streak must not drop");
    }

    #[test]
    fn fastest_win_takes_minimum() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 300, &DrawMode::DrawOne);
        s.update_on_win(100, 120, &DrawMode::DrawOne);
        s.update_on_win(100, 500, &DrawMode::DrawOne);
        assert_eq!(s.fastest_win_seconds, 120);
    }

    #[test]
    fn avg_time_is_correct_rolling_average() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 100, &DrawMode::DrawOne);
        s.update_on_win(100, 200, &DrawMode::DrawOne);
        s.update_on_win(100, 300, &DrawMode::DrawOne);
        // (100 + 200 + 300) / 3 = 200
        assert_eq!(s.avg_time_seconds, 200);
    }

    #[test]
    fn best_score_updates_only_on_higher_score() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(500, 60, &DrawMode::DrawOne);
        s.update_on_win(300, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 500);
        s.update_on_win(800, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 800);
    }

    #[test]
    fn negative_score_treated_as_zero() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(-50, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 0);
        assert_eq!(s.lifetime_score, 0);
    }

    #[test]
    fn draw_three_wins_tracked_separately() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawThree);
        assert_eq!(s.draw_one_wins, 1);
        assert_eq!(s.draw_three_wins, 1);
    }
}
  • Step 2: Verify tests fail
cargo test -p solitaire_data 2>&1 | tail -5

Expected: compile error — stats.rs does not exist.

  • Step 3: Implement StatsSnapshot

Create solitaire_data/src/stats.rs with the full struct and methods:

//! Player statistics — persisted to `stats.json` between sessions.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::DrawMode;

/// Cumulative game statistics. Stored as `stats.json` in the platform data dir.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatsSnapshot {
    pub games_played: u32,
    pub games_won: u32,
    pub games_lost: u32,
    pub win_streak_current: u32,
    pub win_streak_best: u32,
    /// Rolling average of win times in seconds.
    pub avg_time_seconds: u64,
    /// Fastest win time. `u64::MAX` means no wins yet.
    pub fastest_win_seconds: u64,
    /// Sum of all winning scores.
    pub lifetime_score: u64,
    pub best_single_score: u32,
    pub draw_one_wins: u32,
    pub draw_three_wins: u32,
    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,
            last_modified: DateTime::UNIX_EPOCH,
        }
    }
}

impl StatsSnapshot {
    /// Record a completed win. Updates all relevant counters and rolling averages.
    pub fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
        let prev_wins = self.games_won; // capture BEFORE increment
        self.games_played += 1;
        self.games_won += 1;
        self.win_streak_current += 1;
        if self.win_streak_current > self.win_streak_best {
            self.win_streak_best = self.win_streak_current;
        }

        let score_u32 = score.max(0) as u32;
        self.lifetime_score = self.lifetime_score.saturating_add(score_u32 as u64);
        if score_u32 > self.best_single_score {
            self.best_single_score = score_u32;
        }

        if time_seconds < self.fastest_win_seconds {
            self.fastest_win_seconds = time_seconds;
        }

        // Rolling average using u128 to avoid overflow on the intermediate product.
        self.avg_time_seconds = if prev_wins == 0 {
            time_seconds
        } else {
            ((self.avg_time_seconds as u128 * prev_wins as u128 + time_seconds as u128)
                / self.games_won as u128) as u64
        };

        match draw_mode {
            DrawMode::DrawOne => self.draw_one_wins += 1,
            DrawMode::DrawThree => self.draw_three_wins += 1,
        }

        self.last_modified = Utc::now();
    }

    /// Record an abandoned game (player started a new game without winning).
    /// Increments `games_played` and `games_lost`, resets `win_streak_current`.
    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 {
    // (test code from Step 1 goes here)
    use super::*;
    use solitaire_core::game_state::DrawMode;

    #[test]
    fn default_stats_are_all_zero() {
        let s = StatsSnapshot::default();
        assert_eq!(s.games_played, 0);
        assert_eq!(s.games_won, 0);
        assert_eq!(s.win_streak_current, 0);
        assert_eq!(s.win_streak_best, 0);
        assert_eq!(s.lifetime_score, 0);
        assert_eq!(s.best_single_score, 0);
        assert_eq!(s.fastest_win_seconds, u64::MAX);
    }

    #[test]
    fn first_win_sets_all_fields() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(1500, 120, &DrawMode::DrawOne);
        assert_eq!(s.games_played, 1);
        assert_eq!(s.games_won, 1);
        assert_eq!(s.win_streak_current, 1);
        assert_eq!(s.win_streak_best, 1);
        assert_eq!(s.lifetime_score, 1500);
        assert_eq!(s.best_single_score, 1500);
        assert_eq!(s.fastest_win_seconds, 120);
        assert_eq!(s.avg_time_seconds, 120);
        assert_eq!(s.draw_one_wins, 1);
        assert_eq!(s.draw_three_wins, 0);
    }

    #[test]
    fn streak_tracks_across_wins() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        assert_eq!(s.win_streak_current, 3);
        assert_eq!(s.win_streak_best, 3);
    }

    #[test]
    fn record_abandoned_resets_streak_and_increments_played() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        assert_eq!(s.win_streak_current, 2);
        s.record_abandoned();
        assert_eq!(s.games_played, 3);
        assert_eq!(s.games_lost, 1);
        assert_eq!(s.win_streak_current, 0);
        assert_eq!(s.win_streak_best, 2, "best streak must not drop");
    }

    #[test]
    fn fastest_win_takes_minimum() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 300, &DrawMode::DrawOne);
        s.update_on_win(100, 120, &DrawMode::DrawOne);
        s.update_on_win(100, 500, &DrawMode::DrawOne);
        assert_eq!(s.fastest_win_seconds, 120);
    }

    #[test]
    fn avg_time_is_correct_rolling_average() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 100, &DrawMode::DrawOne);
        s.update_on_win(100, 200, &DrawMode::DrawOne);
        s.update_on_win(100, 300, &DrawMode::DrawOne);
        assert_eq!(s.avg_time_seconds, 200);
    }

    #[test]
    fn best_score_updates_only_on_higher_score() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(500, 60, &DrawMode::DrawOne);
        s.update_on_win(300, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 500);
        s.update_on_win(800, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 800);
    }

    #[test]
    fn negative_score_treated_as_zero() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(-50, 60, &DrawMode::DrawOne);
        assert_eq!(s.best_single_score, 0);
        assert_eq!(s.lifetime_score, 0);
    }

    #[test]
    fn draw_three_wins_tracked_separately() {
        let mut s = StatsSnapshot::default();
        s.update_on_win(100, 60, &DrawMode::DrawOne);
        s.update_on_win(100, 60, &DrawMode::DrawThree);
        assert_eq!(s.draw_one_wins, 1);
        assert_eq!(s.draw_three_wins, 1);
    }
}
  • Step 4: Expose the module from solitaire_data/src/lib.rs

Append to the existing lib.rs (after the SyncProvider trait):

pub mod stats;
pub use stats::StatsSnapshot;
  • Step 5: Run tests and verify they pass
cargo test -p solitaire_data 2>&1 | tail -10

Expected output:

test stats::tests::avg_time_is_correct_rolling_average ... ok
test stats::tests::best_score_updates_only_on_higher_score ... ok
test stats::tests::default_stats_are_all_zero ... ok
test stats::tests::draw_three_wins_tracked_separately ... ok
test stats::tests::fastest_win_takes_minimum ... ok
test stats::tests::first_win_sets_all_fields ... ok
test stats::tests::negative_score_treated_as_zero ... ok
test stats::tests::record_abandoned_resets_streak_and_increments_played ... ok
test stats::tests::streak_tracks_across_wins ... ok
test result: ok. 9 passed; 0 failed; ...
  • Step 6: Clippy
cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5

Expected: Finished ... 0 warnings

  • Step 7: Commit
git add solitaire_data/src/stats.rs solitaire_data/src/lib.rs
git commit -m "feat(data): add StatsSnapshot with update_on_win and record_abandoned"

Task 2 — File Persistence in solitaire_data

Files:

  • Create: solitaire_data/src/storage.rs

  • Modify: solitaire_data/src/lib.rs

  • Step 1: Write failing tests

Add to bottom of solitaire_data/src/storage.rs (new file, just the test module first):

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stats::StatsSnapshot;
    use solitaire_core::game_state::DrawMode;
    use std::env;

    fn tmp_path(name: &str) -> std::path::PathBuf {
        env::temp_dir().join(format!("solitaire_test_{name}.json"))
    }

    #[test]
    fn round_trip_save_and_load() {
        let path = tmp_path("round_trip");
        let _ = std::fs::remove_file(&path); // clean up from prior runs

        let mut stats = StatsSnapshot::default();
        stats.update_on_win(1000, 180, &DrawMode::DrawOne);
        save_stats_to(&path, &stats).expect("save");

        let loaded = load_stats_from(&path);
        assert_eq!(loaded.games_won, 1);
        assert_eq!(loaded.best_single_score, 1000);
        assert_eq!(loaded.fastest_win_seconds, 180);
    }

    #[test]
    fn load_from_missing_file_returns_default() {
        let path = tmp_path("missing_file_abc123");
        let _ = std::fs::remove_file(&path);
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }

    #[test]
    fn save_is_atomic_no_half_written_file() {
        let path = tmp_path("atomic_write");
        let stats = StatsSnapshot::default();
        save_stats_to(&path, &stats).expect("save");

        // Verify the .tmp file was cleaned up after the rename.
        let tmp_path = path.with_extension("json.tmp");
        assert!(
            !tmp_path.exists(),
            ".tmp file should not exist after successful save"
        );
    }

    #[test]
    fn load_from_corrupt_file_returns_default() {
        let path = tmp_path("corrupt");
        std::fs::write(&path, b"not valid json!!!").expect("write corrupt");
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }
}
  • Step 2: Verify tests fail
cargo test -p solitaire_data storage 2>&1 | tail -5

Expected: compile error — storage.rs not found.

  • Step 3: Implement storage.rs

Create solitaire_data/src/storage.rs:

//! Atomic file I/O for `StatsSnapshot` persistence.
//!
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::stats::StatsSnapshot;

const APP_DIR_NAME: &str = "solitaire_quest";
const STATS_FILE_NAME: &str = "stats.json";

/// Returns the platform-specific path to `stats.json`, or `None` if
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> {
    dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
}

/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
/// the file is missing or cannot be deserialized (corrupt/truncated).
pub fn load_stats_from(path: &Path) -> StatsSnapshot {
    let data = match fs::read(path) {
        Ok(d) => d,
        Err(_) => return StatsSnapshot::default(),
    };
    serde_json::from_slice(&data).unwrap_or_default()
}

/// Save stats to an explicit path using an atomic write (`.tmp` → rename).
pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
    // Ensure the parent directory exists.
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let json = serde_json::to_string_pretty(stats)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

    // Write to a temporary file alongside the target.
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, json.as_bytes())?;

    // Atomic rename — on POSIX this is guaranteed atomic.
    fs::rename(&tmp, path)?;
    Ok(())
}

/// Load stats from the platform default path. Returns default if the path
/// is unavailable or the file is missing/corrupt.
pub fn load_stats() -> StatsSnapshot {
    stats_file_path()
        .map(|p| load_stats_from(&p))
        .unwrap_or_default()
}

/// Save stats to the platform default path. Logs a warning if the path is
/// unavailable or the write fails — never panics.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
    let path = stats_file_path().ok_or_else(|| {
        io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
    })?;
    save_stats_to(&path, stats)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stats::StatsSnapshot;
    use solitaire_core::game_state::DrawMode;
    use std::env;

    fn tmp_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!("solitaire_test_{name}.json"))
    }

    #[test]
    fn round_trip_save_and_load() {
        let path = tmp_path("round_trip");
        let _ = fs::remove_file(&path);

        let mut stats = StatsSnapshot::default();
        stats.update_on_win(1000, 180, &DrawMode::DrawOne);
        save_stats_to(&path, &stats).expect("save");

        let loaded = load_stats_from(&path);
        assert_eq!(loaded.games_won, 1);
        assert_eq!(loaded.best_single_score, 1000);
        assert_eq!(loaded.fastest_win_seconds, 180);
    }

    #[test]
    fn load_from_missing_file_returns_default() {
        let path = tmp_path("missing_file_abc123");
        let _ = fs::remove_file(&path);
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }

    #[test]
    fn save_is_atomic_no_half_written_file() {
        let path = tmp_path("atomic_write");
        let stats = StatsSnapshot::default();
        save_stats_to(&path, &stats).expect("save");

        let tmp = path.with_extension("json.tmp");
        assert!(!tmp.exists(), ".tmp file must be cleaned up after rename");
    }

    #[test]
    fn load_from_corrupt_file_returns_default() {
        let path = tmp_path("corrupt");
        fs::write(&path, b"not valid json!!!").expect("write corrupt");
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }
}
  • Step 4: Update solitaire_data/src/lib.rs

Add storage module and re-exports after the stats module lines:

pub mod storage;
pub use storage::{load_stats, save_stats, stats_file_path};

The full solitaire_data/src/lib.rs should now be:

use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
use thiserror::Error;

/// All errors that can arise during sync operations.
#[derive(Debug, Error)]
pub enum SyncError {
    #[error("unsupported platform for this sync backend")]
    UnsupportedPlatform,
    #[error("network error: {0}")]
    Network(String),
    #[error("authentication error: {0}")]
    Auth(String),
    #[error("serialization error: {0}")]
    Serialization(String),
}

/// Every sync backend implements this trait. The SyncPlugin only calls these
/// methods — it never matches on a backend enum variant.
#[async_trait]
pub trait SyncProvider: Send + Sync {
    /// Fetch the remote sync payload. Returns the latest server state for merging.
    async fn pull(&self) -> Result<SyncPayload, SyncError>;
    /// Push the local payload to the backend. Returns the merged server response.
    async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
    /// Human-readable name of this backend, used in settings UI and logs.
    fn backend_name(&self) -> &'static str;
    /// Returns true if the user is currently authenticated with this backend.
    fn is_authenticated(&self) -> bool;
    /// Mirror an achievement unlock to this backend (no-op for most backends).
    async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
        Ok(())
    }
}

pub mod stats;
pub use stats::StatsSnapshot;

pub mod storage;
pub use storage::{load_stats, save_stats, stats_file_path};
  • Step 5: Run tests and verify they pass
cargo test -p solitaire_data 2>&1 | tail -10

Expected: 13 tests all passing (9 stats + 4 storage).

  • Step 6: Clippy
cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5

Expected: 0 warnings.

  • Step 7: Commit
git add solitaire_data/src/storage.rs solitaire_data/src/lib.rs
git commit -m "feat(data): add atomic stats persistence (load_stats_from, save_stats_to)"

Task 3 — StatsPlugin in solitaire_engine

Files:

  • Create: solitaire_engine/src/stats_plugin.rs

  • Modify: solitaire_engine/src/lib.rs

  • Step 1: Write failing tests

Write the test module at the bottom of the (not-yet-existing) solitaire_engine/src/stats_plugin.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::game_plugin::GamePlugin;
    use crate::table_plugin::TablePlugin;
    use solitaire_data::StatsSnapshot;

    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(GamePlugin)
            .add_plugins(TablePlugin)
            .add_plugins(StatsPlugin);
        app.update();
        app
    }

    #[test]
    fn stats_resource_exists_after_startup() {
        let app = headless_app();
        assert!(app.world().get_resource::<StatsResource>().is_some());
    }

    #[test]
    fn win_event_increments_games_won() {
        let mut app = headless_app();
        assert_eq!(
            app.world().resource::<StatsResource>().0.games_won,
            0
        );
        app.world_mut().send_event(GameWonEvent {
            score: 1000,
            time_seconds: 120,
        });
        // Override draw_mode so handle_move picks DrawOne (default is DrawOne).
        app.update();
        assert_eq!(
            app.world().resource::<StatsResource>().0.games_won,
            1
        );
        assert_eq!(
            app.world().resource::<StatsResource>().0.games_played,
            1
        );
    }

    #[test]
    fn new_game_after_moves_records_abandoned() {
        let mut app = headless_app();

        // Simulate move_count > 0 by directly mutating the resource.
        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .move_count = 3;

        app.world_mut()
            .send_event(NewGameRequestEvent { seed: Some(999) });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 1, "abandoned game counted as played");
        assert_eq!(stats.games_lost, 1);
        assert_eq!(stats.win_streak_current, 0);
    }

    #[test]
    fn new_game_without_moves_does_not_record_abandoned() {
        let mut app = headless_app();
        // move_count is 0 by default after new game
        app.world_mut()
            .send_event(NewGameRequestEvent { seed: Some(42) });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 0, "no moves = no abandoned game");
    }
}
  • Step 2: Verify tests fail
cargo test -p solitaire_engine stats_plugin 2>&1 | tail -5

Expected: compile error — stats_plugin module not found.

  • Step 3: Implement stats_plugin.rs

Create solitaire_engine/src/stats_plugin.rs:

//! Loads, updates, and persists `StatsSnapshot` in response to game events.
//!
//! Stats are loaded from disk in `Startup` and saved after every event that
//! modifies them. File I/O is synchronous (stats.json is tiny, <1 KB).

use bevy::prelude::*;
use solitaire_data::{load_stats, save_stats, StatsSnapshot};

use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;

/// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)]
pub struct StatsResource(pub StatsSnapshot);

/// Registers stats resources and the systems that keep them in sync.
pub struct StatsPlugin;

impl Plugin for StatsPlugin {
    fn build(&self, app: &mut App) {
        app.insert_resource(StatsResource(load_stats()))
            .add_event::<GameWonEvent>()
            .add_event::<NewGameRequestEvent>()
            .add_systems(
                Update,
                (update_stats_on_win, update_stats_on_new_game).after(GameMutation),
            );
    }
}

fn update_stats_on_win(
    mut events: EventReader<GameWonEvent>,
    game: Res<GameStateResource>,
    mut stats: ResMut<StatsResource>,
) {
    for ev in events.read() {
        stats.0.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
        if let Err(e) = save_stats(&stats.0) {
            warn!("failed to save stats after win: {e}");
        }
    }
}

fn update_stats_on_new_game(
    mut events: EventReader<NewGameRequestEvent>,
    game: Res<GameStateResource>,
    mut stats: ResMut<StatsResource>,
) {
    for _ in events.read() {
        // Only count as abandoned if the player made at least one move and did
        // not win — a re-deal from a brand-new untouched game is not a loss.
        if game.0.move_count > 0 && !game.0.is_won {
            stats.0.record_abandoned();
            if let Err(e) = save_stats(&stats.0) {
                warn!("failed to save stats after abandoned game: {e}");
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::GameWonEvent;
    use crate::game_plugin::GamePlugin;
    use crate::table_plugin::TablePlugin;

    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(GamePlugin)
            .add_plugins(TablePlugin)
            .add_plugins(StatsPlugin);
        app.update();
        app
    }

    #[test]
    fn stats_resource_exists_after_startup() {
        let app = headless_app();
        assert!(app.world().get_resource::<StatsResource>().is_some());
    }

    #[test]
    fn win_event_increments_games_won() {
        let mut app = headless_app();
        assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);

        app.world_mut()
            .send_event(GameWonEvent { score: 1000, time_seconds: 120 });
        app.update();

        assert_eq!(app.world().resource::<StatsResource>().0.games_won, 1);
        assert_eq!(app.world().resource::<StatsResource>().0.games_played, 1);
    }

    #[test]
    fn new_game_after_moves_records_abandoned() {
        let mut app = headless_app();

        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .move_count = 3;

        app.world_mut()
            .send_event(NewGameRequestEvent { seed: Some(999) });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 1);
        assert_eq!(stats.games_lost, 1);
        assert_eq!(stats.win_streak_current, 0);
    }

    #[test]
    fn new_game_without_moves_does_not_record_abandoned() {
        let mut app = headless_app();
        app.world_mut()
            .send_event(NewGameRequestEvent { seed: Some(42) });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 0);
    }
}
  • Step 4: Run tests
cargo test -p solitaire_engine stats_plugin 2>&1 | tail -10

Expected: 4 tests passing.

  • Step 5: Clippy
cargo clippy -p solitaire_engine -- -D warnings 2>&1 | tail -5

Expected: 0 warnings.

  • Step 6: Commit
git add solitaire_engine/src/stats_plugin.rs
git commit -m "feat(engine): add StatsPlugin with persistent StatsResource"

Task 4 — Stats Screen (bevy_ui overlay)

Files:

  • Modify: solitaire_engine/src/stats_plugin.rs — add UI toggle systems
  • Modify: solitaire_engine/src/lib.rs — export StatsPlugin, StatsResource
  • Modify: solitaire_app/src/main.rs — register StatsPlugin

The stats screen is a full-window overlay spawned on demand. It reuses StatsPlugin — no separate plugin needed.

  • Step 1: Write failing tests

Add these tests to stats_plugin.rs (inside the existing tests module):

    #[test]
    fn pressing_s_spawns_stats_screen() {
        let mut app = headless_app();
        assert_eq!(
            app.world_mut().query::<&StatsScreen>().iter(app.world()).count(),
            0,
            "screen must not exist before toggle"
        );

        // Simulate pressing S.
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        assert_eq!(
            app.world_mut().query::<&StatsScreen>().iter(app.world()).count(),
            1,
            "screen must appear after first S press"
        );
    }

    #[test]
    fn pressing_s_twice_closes_stats_screen() {
        let mut app = headless_app();

        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        // Release and re-press so just_pressed fires again.
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .release(KeyCode::KeyS);
        app.update();

        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        assert_eq!(
            app.world_mut().query::<&StatsScreen>().iter(app.world()).count(),
            0,
            "screen must close after second S press"
        );
    }
  • Step 2: Verify tests fail
cargo test -p solitaire_engine pressing_s 2>&1 | tail -5

Expected: compile error — StatsScreen not found.

  • Step 3: Implement stats screen toggle

Add the following to solitaire_engine/src/stats_plugin.rs — insert after the update_stats_on_new_game function and before the tests module:

First add imports at the top of the file:

use bevy::input::ButtonInput;
use solitaire_data::{load_stats, save_stats, StatsSnapshot};

(replace the existing use solitaire_data::{load_stats, save_stats, StatsSnapshot}; import)

Add the full import block at the top:

use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::{load_stats, save_stats, StatsSnapshot};

use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;

Add the StatsScreen marker and StatsPlugin::build update:

/// Marker component on the stats overlay root node.
#[derive(Component, Debug)]
pub struct StatsScreen;

Update StatsPlugin::build to also register the UI system:

impl Plugin for StatsPlugin {
    fn build(&self, app: &mut App) {
        app.insert_resource(StatsResource(load_stats()))
            .add_event::<GameWonEvent>()
            .add_event::<NewGameRequestEvent>()
            .add_systems(
                Update,
                (
                    update_stats_on_win,
                    update_stats_on_new_game,
                    toggle_stats_screen,
                )
                    .after(GameMutation),
            );
    }
}

Add the toggle and spawn/despawn functions after update_stats_on_new_game:

fn toggle_stats_screen(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    stats: Res<StatsResource>,
    screens: Query<Entity, With<StatsScreen>>,
) {
    if !keys.just_pressed(KeyCode::KeyS) {
        return;
    }
    if let Ok(entity) = screens.get_single() {
        commands.entity(entity).despawn_recursive();
    } else {
        spawn_stats_screen(&mut commands, &stats.0);
    }
}

fn spawn_stats_screen(commands: &mut Commands, stats: &StatsSnapshot) {
    let win_rate = stats
        .win_rate()
        .map_or("N/A".to_string(), |r| format!("{r:.1}%"));
    let fastest = if stats.fastest_win_seconds == u64::MAX {
        "N/A".to_string()
    } else {
        format_duration(stats.fastest_win_seconds)
    };
    let avg = if stats.games_won == 0 {
        "N/A".to_string()
    } else {
        format_duration(stats.avg_time_seconds)
    };

    let lines = vec![
        "=== Statistics ===".to_string(),
        format!("Games Played:  {}", stats.games_played),
        format!("Games Won:     {}", stats.games_won),
        format!("Win Rate:      {win_rate}"),
        format!("Win Streak:    {} (Best: {})", stats.win_streak_current, stats.win_streak_best),
        format!("Best Score:    {}", stats.best_single_score),
        format!("Fastest Win:   {fastest}"),
        format!("Avg Win Time:  {avg}"),
        String::new(),
        "Press S to close".to_string(),
    ];

    commands
        .spawn((
            StatsScreen,
            Node {
                position_type: PositionType::Absolute,
                left: Val::Percent(0.0),
                top: Val::Percent(0.0),
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                row_gap: Val::Px(6.0),
                ..default()
            },
            BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
            ZIndex(200),
        ))
        .with_children(|b| {
            for line in lines {
                b.spawn((
                    Text::new(line),
                    TextFont { font_size: 24.0, ..default() },
                    TextColor(Color::srgb(0.95, 0.95, 0.90)),
                ));
            }
        });
}

fn format_duration(secs: u64) -> String {
    let m = secs / 60;
    let s = secs % 60;
    format!("{m}m {s:02}s")
}

The headless app needs ButtonInput<KeyCode> registered. Add to headless_app() in tests:

fn headless_app() -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(GamePlugin)
        .add_plugins(TablePlugin)
        .add_plugins(StatsPlugin);
    app.init_resource::<ButtonInput<KeyCode>>();
    app.update();
    app
}
  • Step 4: Run tests
cargo test -p solitaire_engine stats_plugin 2>&1 | tail -10

Expected: all 6 stats_plugin tests passing.

  • Step 5: Update solitaire_engine/src/lib.rs

Add stats_plugin module and exports. The full updated section:

pub mod animation_plugin;
pub mod card_plugin;
pub mod events;
pub mod game_plugin;
pub mod input_plugin;
pub mod layout;
pub mod resources;
pub mod stats_plugin;
pub mod table_plugin;

pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
    AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
    NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
pub use game_plugin::{GameMutation, GamePlugin};
pub use input_plugin::InputPlugin;
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
  • Step 6: Update solitaire_app/src/main.rs
use bevy::prelude::*;
use solitaire_engine::{AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, StatsPlugin, TablePlugin};

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Ferrous Solitaire".into(),
                    resolution: (1280.0, 800.0).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(GamePlugin)
        .add_plugins(TablePlugin)
        .add_plugins(CardPlugin)
        .add_plugins(InputPlugin)
        .add_plugins(AnimationPlugin)
        .add_plugins(StatsPlugin)
        .run();
}
  • Step 7: Full workspace test + clippy
cargo test --workspace 2>&1 | grep -E "FAILED|test result"
cargo clippy --workspace -- -D warnings 2>&1 | tail -5

Expected: all tests passing, 0 clippy warnings.

  • Step 8: Commit
git add solitaire_engine/src/stats_plugin.rs solitaire_engine/src/lib.rs solitaire_app/src/main.rs
git commit -m "feat(engine): add stats screen overlay toggled with S key (Phase 4)"

Task 5 — Final Gate

Files: none new — just verification.

  • Step 1: Full workspace test
cargo test --workspace 2>&1 | grep -E "test result|FAILED"

Expected: all test results show ok, no FAILED lines. Total passing count should be ≥ 120 (110 existing + ~13 new).

  • Step 2: Clippy (zero warnings)
cargo clippy --workspace -- -D warnings 2>&1 | tail -3

Expected: Finished ... 0 warnings

  • Step 3: Smoke-test the running game
cargo run -p solitaire_app --features bevy/dynamic_linking

Verify manually:

  • Game window opens and cards render

  • Press S → stats overlay appears showing zeros (or loaded stats)

  • Press S again → overlay closes

  • Play a game to completion (drag cards, press D to draw, U to undo)

  • Win detection triggers cascade animation

  • Press S → games_played = 1, games_won = 1 displayed

  • Step 4: Update SESSION_HANDOFF.md

Update docs/SESSION_HANDOFF.md:

  • Mark Phase 4 complete in the commit history table

  • Update "What Is Next" to point to Phase 5 (Achievements)

  • Update the running test count

  • Step 5: Final commit (if anything changed during smoke test)

git add -p  # review any fixes made during smoke test
git commit -m "chore: update session handoff for Phase 4 completion"

Cross-Cutting Rules (reminder)

  • solitaire_core and solitaire_sync must NOT gain new dependencies.
  • save_stats / load_stats handle dirs::data_dir() = None without panicking.
  • No unwrap() in new code — use if let, unwrap_or_default(), or ?.
  • cargo clippy --workspace -- -D warnings must pass after every task.
  • cargo test --workspace must pass after every task.