Files
Ferrous-Solitaire/docs/superpowers/plans/2026-04-23-phase4-statistics.md
T
funman300 e6c67d03c2
Build and Deploy / build-and-push (push) Successful in 4m55s
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

1305 lines
39 KiB
Markdown
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.
# 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<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):
```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<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:
```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<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**
```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::<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**
```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::<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**
```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::<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**
```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::<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`:
```rust
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:
```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::<ButtonInput<KeyCode>>();
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: "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**
```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.