diff --git a/docs/superpowers/plans/2026-04-23-phase4-statistics.md b/docs/superpowers/plans/2026-04-23-phase4-statistics.md new file mode 100644 index 0000000..aa2c64c --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-phase4-statistics.md @@ -0,0 +1,1304 @@ +# 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`: + +```rust +#[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** + +```bash +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: + +```rust +//! 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, +} + +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 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 { + // (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): + +```rust +pub mod stats; +pub use stats::StatsSnapshot; +``` + +- [ ] **Step 5: Run tests and verify they pass** + +```bash +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** + +```bash +cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5 +``` + +Expected: `Finished ... 0 warnings` + +- [ ] **Step 7: Commit** + +```bash +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): + +```rust +#[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** + +```bash +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`: + +```rust +//! 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 { + 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: + +```rust +pub mod storage; +pub use storage::{load_stats, save_stats, stats_file_path}; +``` + +The full `solitaire_data/src/lib.rs` should now be: + +```rust +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; + /// Push the local payload to the backend. Returns the merged server response. + async fn push(&self, payload: &SyncPayload) -> Result; + /// 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** + +```bash +cargo test -p solitaire_data 2>&1 | tail -10 +``` + +Expected: 13 tests all passing (9 stats + 4 storage). + +- [ ] **Step 6: Clippy** + +```bash +cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5 +``` + +Expected: 0 warnings. + +- [ ] **Step 7: Commit** + +```bash +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`: + +```rust +#[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::().is_some()); + } + + #[test] + fn win_event_increments_games_won() { + let mut app = headless_app(); + assert_eq!( + app.world().resource::().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::().0.games_won, + 1 + ); + assert_eq!( + app.world().resource::().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::() + .0 + .move_count = 3; + + app.world_mut() + .send_event(NewGameRequestEvent { seed: Some(999) }); + app.update(); + + let stats = &app.world().resource::().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::().0; + assert_eq!(stats.games_played, 0, "no moves = no abandoned game"); + } +} +``` + +- [ ] **Step 2: Verify tests fail** + +```bash +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`: + +```rust +//! 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::() + .add_event::() + .add_systems( + Update, + (update_stats_on_win, update_stats_on_new_game).after(GameMutation), + ); + } +} + +fn update_stats_on_win( + mut events: EventReader, + game: Res, + mut stats: ResMut, +) { + 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, + game: Res, + mut stats: ResMut, +) { + 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::().is_some()); + } + + #[test] + fn win_event_increments_games_won() { + let mut app = headless_app(); + assert_eq!(app.world().resource::().0.games_won, 0); + + app.world_mut() + .send_event(GameWonEvent { score: 1000, time_seconds: 120 }); + app.update(); + + assert_eq!(app.world().resource::().0.games_won, 1); + assert_eq!(app.world().resource::().0.games_played, 1); + } + + #[test] + fn new_game_after_moves_records_abandoned() { + let mut app = headless_app(); + + app.world_mut() + .resource_mut::() + .0 + .move_count = 3; + + app.world_mut() + .send_event(NewGameRequestEvent { seed: Some(999) }); + app.update(); + + let stats = &app.world().resource::().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::().0; + assert_eq!(stats.games_played, 0); + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test -p solitaire_engine stats_plugin 2>&1 | tail -10 +``` + +Expected: 4 tests passing. + +- [ ] **Step 5: Clippy** + +```bash +cargo clippy -p solitaire_engine -- -D warnings 2>&1 | tail -5 +``` + +Expected: 0 warnings. + +- [ ] **Step 6: Commit** + +```bash +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): + +```rust + #[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::>() + .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::>() + .press(KeyCode::KeyS); + app.update(); + + // Release and re-press so just_pressed fires again. + app.world_mut() + .resource_mut::>() + .release(KeyCode::KeyS); + app.update(); + + app.world_mut() + .resource_mut::>() + .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** + +```bash +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: +```rust +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: +```rust +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: + +```rust +/// Marker component on the stats overlay root node. +#[derive(Component, Debug)] +pub struct StatsScreen; +``` + +Update `StatsPlugin::build` to also register the UI system: + +```rust +impl Plugin for StatsPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(StatsResource(load_stats())) + .add_event::() + .add_event::() + .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`: + +```rust +fn toggle_stats_screen( + mut commands: Commands, + keys: Res>, + stats: Res, + screens: Query>, +) { + 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` registered. Add to `headless_app()` in tests: + +```rust +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::>(); + app.update(); + app +} +``` + +- [ ] **Step 4: Run tests** + +```bash +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: + +```rust +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`** + +```rust +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: "Solitaire Quest".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** + +```bash +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** + +```bash +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** + +```bash +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)** + +```bash +cargo clippy --workspace -- -D warnings 2>&1 | tail -3 +``` + +Expected: `Finished ... 0 warnings` + +- [ ] **Step 3: Smoke-test the running game** + +```bash +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)** + +```bash +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.