From b21cfa8c5a37ba0a7c0fedca4a9d83aa32ee3ff6 Mon Sep 17 00:00:00 2001 From: folkengine Date: Sat, 9 May 2026 18:58:07 -0700 Subject: [PATCH 1/3] feat: game history audit and replay - Add GfError::IoError and GfError::NoReplayData variants - Add TurnRecord::actions: Option> for replay path - Restructure GameCollection from newtype to versioned struct with gfcore_version, format_version, and games fields - Add GameCollection::save() and save_to() for timestamped file output - Add src/history/audit.rs: AuditResult and GameRecord::audit() with 7 structural invariant checks; GameCollection::audit_all() - Add src/history/replay.rs: ReplayResult and GameRecord::replay() using stored initial_draw_pile for deterministic re-deal; GameCollection::replay_all() - Capture pending_turn_actions in Game struct; flush into TurnRecord - Store initial_draw_pile in GameRecord before dealing for replay - Re-export AuditResult and ReplayResult from prelude - Add audit_all and replay_all integration tests (5 games each) - Add record.audit() call in bot_marathon validate_last_game --- Cargo.lock | 2 +- Cargo.toml | 2 +- Makefile | 7 +- .../plans/2026-05-09-game-history-audit.md | 1784 +++++++++++++++++ .../2026-05-09-game-history-audit-design.md | 274 +++ src/error/mod.rs | 53 + src/game/state.rs | 64 +- src/history/audit.rs | 427 ++++ src/history/mod.rs | 17 +- src/history/record.rs | 219 +- src/history/replay.rs | 277 +++ src/prelude.rs | 2 +- tests/bot_marathon.rs | 219 ++ tests/history_integration.rs | 95 + 14 files changed, 3412 insertions(+), 30 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-09-game-history-audit.md create mode 100644 docs/superpowers/specs/2026-05-09-game-history-audit-design.md create mode 100644 src/history/audit.rs create mode 100644 src/history/replay.rs create mode 100644 tests/bot_marathon.rs diff --git a/Cargo.lock b/Cargo.lock index f658daa..be5e732 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ [[package]] name = "gfcore" -version = "0.0.2" +version = "0.0.3" dependencies = [ "cardpack", "console_error_panic_hook", diff --git a/Cargo.toml b/Cargo.toml index a59d9f1..7e90d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gfcore" description = "Go Fish card game engine" -version = "0.0.2" +version = "0.0.3" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/Makefile b/Makefile index c23e7ba..f4e1187 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean build test test-unit test-doc build-wasm test-wasm coverage build_test fmt clippy create_docs ayce default help docs test-nightly clippy-nightly nightly miri mutants tree tree-duplicates deny audit unused-deps install-tools install-nextest install-mutants install-llvm-cov install-wasm-bindgen-cli watch install-watch +.PHONY: clean build test test-unit test-doc build-wasm test-wasm coverage build_test fmt clippy create_docs ayce default help docs test-nightly clippy-nightly nightly miri mutants marathon tree tree-duplicates deny audit unused-deps install-tools install-nextest install-mutants install-llvm-cov install-wasm-bindgen-cli watch install-watch # Default target default: ayce @@ -12,6 +12,7 @@ help: @echo " make test - Run all tests (nextest for unit, cargo test for doc)" @echo " make test-unit - Run unit tests via cargo-nextest" @echo " make test-doc - Run doc tests via cargo test --doc" + @echo " make marathon - Run 100-game bot marathon stress test" @echo " make build-wasm - Build for wasm32-unknown-unknown with --features wasm" @echo " make test-wasm - Run wasm runtime tests (requires wasm-bindgen-cli + node)" @echo " make coverage - Generate test coverage report via cargo-llvm-cov" @@ -70,6 +71,10 @@ define check_nextest fi endef +# Run the 100-game bot marathon stress test +marathon: + cargo test --test bot_marathon -- --include-ignored --nocapture + # Run unit tests via nextest test-unit: $(check_nextest) diff --git a/docs/superpowers/plans/2026-05-09-game-history-audit.md b/docs/superpowers/plans/2026-05-09-game-history-audit.md new file mode 100644 index 0000000..c7c2adf --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-game-history-audit.md @@ -0,0 +1,1784 @@ +# Game History Audit & Replay Implementation Plan + +> **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:** Add structural audit (`GameRecord::audit()`) and engine-replay (`GameRecord::replay()`) capabilities to `gfcore`'s history system, alongside a versioned `GameCollection` struct with file-save support and per-turn action recording in the engine. + +**Architecture:** Split `src/history/record.rs` into three focused files: `record.rs` (data types + I/O), `audit.rs` (structural invariant checks), `replay.rs` (engine re-run). The engine (`state.rs`) gains a `pending_turn_actions` accumulator alongside the existing `pending_turn_events`. All new API is native-only; WASM stays unchanged. + +**Tech Stack:** Rust 2021 edition; serde + serde_norway + serde_json (existing); uuid (existing); `std::fs` for save (native-only, `cfg`-gated). + +--- + +### Task 1: GfError — add IoError and NoReplayData variants + +**Files:** +- Modify: `src/error/mod.rs` + +- [ ] **Step 1: Add failing tests** + +In the `#[cfg(test)]` block at the bottom of `src/error/mod.rs`, add after the existing tests: + +```rust +#[test] +fn test_io_error_display() { + let err = GfError::IoError("permission denied".to_string()); + assert_eq!(err.to_string(), "io error: permission denied"); +} + +#[test] +fn test_no_replay_data_display() { + let err = GfError::NoReplayData; + assert_eq!( + err.to_string(), + "no replay data: at least one turn has no stored actions", + ); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test --lib -- error 2>&1 | tail -20 +``` + +Expected: compile error — `GfError::IoError` and `GfError::NoReplayData` do not exist. + +- [ ] **Step 3: Add variants to GfError** + +In `src/error/mod.rs`, add two variants before the closing `}` of the `GfError` enum, after `ParseError`: + +```rust +/// A filesystem operation failed during save. +/// +/// The inner `String` contains the OS error message. +/// +/// # Examples +/// +/// ``` +/// use gfcore::prelude::GfError; +/// +/// let err = GfError::IoError("permission denied".to_string()); +/// assert_eq!(err.to_string(), "io error: permission denied"); +/// ``` +IoError(String), + +/// `replay()` was called on a record where at least one turn has no stored actions. +/// +/// This error is expected when replaying records produced before action +/// recording was added (e.g., WASM games or old tests). +/// +/// # Examples +/// +/// ``` +/// use gfcore::prelude::GfError; +/// +/// let err = GfError::NoReplayData; +/// assert_eq!( +/// err.to_string(), +/// "no replay data: at least one turn has no stored actions", +/// ); +/// ``` +NoReplayData, +``` + +- [ ] **Step 4: Update Display** + +In the `fmt::Display` impl, add arms after `Self::ParseError(msg)`: + +```rust +Self::IoError(msg) => write!(f, "io error: {msg}"), +Self::NoReplayData => { + f.write_str("no replay data: at least one turn has no stored actions") +} +``` + +- [ ] **Step 5: Update existing display coverage test** + +The `test_display_messages_are_non_empty` test iterates a hardcoded array. Extend it to include the two new variants: + +```rust +let variants = [ + GfError::InvalidAsk, + GfError::InvalidTarget, + GfError::OutOfTurn, + GfError::NotEnoughPlayers, + GfError::TooManyPlayers, + GfError::GameAlreadyOver, + GfError::EmptyDrawPile, + GfError::ParseError("bad".into()), + GfError::IoError("disk full".into()), + GfError::NoReplayData, +]; +``` + +- [ ] **Step 6: Run tests and clippy** + +```bash +cargo test --lib -- error && cargo clippy --all-features -- -D warnings +``` + +Expected: all error tests pass, no warnings. + +- [ ] **Step 7: Commit** + +``` +git add src/error/mod.rs +git commit -m "feat(error): add IoError and NoReplayData variants to GfError" +``` + +--- + +### Task 2: TurnRecord — add optional actions field + +**Files:** +- Modify: `src/history/record.rs` +- Modify: `tests/history_integration.rs` (compile fix — TurnRecord struct literals) + +- [ ] **Step 1: Add failing tests** + +In the `#[cfg(test)]` block in `src/history/record.rs`, add: + +```rust +#[test] +fn test_turn_record_actions_default_is_none() { + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }; + assert!(turn.actions.is_none()); +} + +#[test] +fn test_turn_record_with_actions_yaml_round_trip() { + use crate::game::PlayerAction; + use cardpack::prelude::{DeckedBase, Standard52}; + let rank = Standard52::basic_pile().v()[0].rank; + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: Some(vec![ + PlayerAction::Ask { target: 1, rank }, + PlayerAction::Draw, + ]), + }; + let yaml = serde_norway::to_string(&turn).unwrap(); + let back: TurnRecord = serde_norway::from_str(&yaml).unwrap(); + assert_eq!(turn, back); +} + +#[test] +fn test_turn_record_none_actions_omitted_from_yaml() { + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }; + let yaml = serde_norway::to_string(&turn).unwrap(); + assert!(!yaml.contains("actions")); +} +``` + +- [ ] **Step 2: Run to verify compile failure** + +```bash +cargo test --lib -- record 2>&1 | tail -10 +``` + +Expected: compile error — struct literal missing `actions` field, or field not found. + +- [ ] **Step 3: Update the import in record.rs** + +Replace: + +```rust +use crate::game::GameEvent; +``` + +with: + +```rust +use crate::game::{GameEvent, PlayerAction}; +``` + +- [ ] **Step 4: Add the actions field to TurnRecord** + +In the `TurnRecord` struct, after `books_after_turn`: + +```rust +/// Actions submitted by the player during this turn, in order. +/// +/// `None` if this record was created without action recording (e.g., WASM +/// games or records pre-dating this feature). `Some(...)` enables +/// [`GameRecord::replay`]. +#[serde(default, skip_serializing_if = "Option::is_none")] +pub actions: Option>, +``` + +- [ ] **Step 5: Update the TurnRecord doc test** + +The existing doc example constructs the struct without `actions`. Add `actions: None` and an assertion: + +```rust +/// ``` +/// use gfcore::history::TurnRecord; +/// +/// let turn = TurnRecord { +/// player: 0, +/// events: vec![], +/// books_after_turn: vec![0, 0], +/// actions: None, +/// }; +/// assert_eq!(turn.player, 0); +/// assert!(turn.events.is_empty()); +/// assert!(turn.actions.is_none()); +/// ``` +``` + +- [ ] **Step 6: Fix TurnRecord construction in the existing record.rs unit test** + +`test_game_record_with_turns_round_trip` constructs a `TurnRecord`. Add `actions: None`: + +```rust +let turn = TurnRecord { + player: 0, + events: vec![ + GameEvent::Asked { asker: 0, target: 1, rank: "A".to_string() }, + GameEvent::GoFish { player: 0 }, + GameEvent::Drew { player: 0, matched: false }, + ], + books_after_turn: vec![0, 0], + actions: None, +}; +``` + +- [ ] **Step 7: Fix TurnRecord construction in tests/history_integration.rs** + +`play_and_record()` constructs two `TurnRecord`s. Add `actions: None` to both: + +```rust +record.turns.push(TurnRecord { + player: current_turn_player, + events: std::mem::take(&mut current_turn_events), + books_after_turn, + actions: None, +}); +``` + +And the flush guard at the end of `play_and_record()`: + +```rust +record.turns.push(TurnRecord { + player: current_turn_player, + events: current_turn_events, + books_after_turn, + actions: None, +}); +``` + +- [ ] **Step 8: Run all tests and clippy** + +```bash +cargo test --all-features && cargo clippy --all-features -- -D warnings +``` + +Expected: all tests pass, no warnings. Round-trips work for both `None` (field omitted) and `Some(...)`. + +- [ ] **Step 9: Commit** + +``` +git add src/history/record.rs tests/history_integration.rs +git commit -m "feat(history): add optional actions field to TurnRecord" +``` + +--- + +### Task 3: GameCollection — versioned struct, FORMAT\_VERSION, save()/save\_to() + +**Files:** +- Modify: `src/history/record.rs` + +This is the largest task. The newtype `struct GameCollection(Vec)` becomes a named struct with metadata. + +- [ ] **Step 1: Add failing tests** + +In `record.rs` tests, add: + +```rust +#[test] +fn test_game_collection_has_format_version() { + let col = GameCollection::new(); + assert_eq!(col.format_version, FORMAT_VERSION); +} + +#[test] +fn test_game_collection_has_gfcore_version() { + let col = GameCollection::new(); + assert!(!col.gfcore_version.is_empty()); +} + +#[test] +fn test_game_collection_yaml_contains_version_fields() { + let col = GameCollection::new(); + let yaml = col.to_yaml().unwrap(); + assert!(yaml.contains("format_version")); + assert!(yaml.contains("gfcore_version")); + assert!(yaml.contains("games")); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_game_collection_save_to_temp_dir() { + let mut col = GameCollection::new(); + col.push(make_record()); + let path = std::env::temp_dir() + .join("gfcore_test_save_to.yaml") + .to_string_lossy() + .to_string(); + let result = col.save_to(&path); + assert!(result.is_ok(), "save_to failed: {:?}", result); + assert!(std::path::Path::new(&path).exists()); + let yaml = std::fs::read_to_string(&path).unwrap(); + let loaded = GameCollection::from_yaml(&yaml).unwrap(); + assert_eq!(col, loaded); + let _ = std::fs::remove_file(&path); +} +``` + +- [ ] **Step 2: Run to verify compile failure** + +```bash +cargo test --lib -- record 2>&1 | tail -10 +``` + +Expected: errors — `FORMAT_VERSION`, `col.format_version`, `col.gfcore_version`, `save_to` undefined. + +- [ ] **Step 3: Add FORMAT\_VERSION and serde helper functions** + +After the existing imports at the top of `src/history/record.rs`, add: + +```rust +/// The serialization format version written into every new [`GameCollection`]. +pub const FORMAT_VERSION: u32 = 1; + +fn default_gfcore_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +fn default_format_version() -> u32 { + FORMAT_VERSION +} +``` + +- [ ] **Step 4: Replace the GameCollection newtype with a versioned struct** + +Remove the old definition: + +```rust +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct GameCollection(Vec); +``` + +Replace with: + +```rust +/// An ordered, versioned collection of [`GameRecord`]s. +/// +/// Serializes as a YAML/JSON object with `gfcore_version`, `format_version`, +/// and `games` keys. +/// +/// # Examples +/// +/// ``` +/// use gfcore::history::{GameCollection, GameRecord, FORMAT_VERSION}; +/// +/// let mut col = GameCollection::new(); +/// assert!(col.is_empty()); +/// assert_eq!(col.format_version, FORMAT_VERSION); +/// col.push(GameRecord::new("Standard", vec!["Alice".to_string()])); +/// assert_eq!(col.len(), 1); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GameCollection { + /// The `gfcore` crate version that created this collection (baked in at compile time). + #[serde(default = "default_gfcore_version")] + pub gfcore_version: String, + /// The serialization format version. Always [`FORMAT_VERSION`] for newly created collections. + #[serde(default = "default_format_version")] + pub format_version: u32, + /// The game records in this collection, in insertion order. + pub games: Vec, +} +``` + +- [ ] **Step 5: Update all GameCollection impl methods** + +Replace every method that accessed `self.0` to use `self.games`. Replace `new()` to initialize all three fields: + +`new()`: +```rust +/// Creates an empty [`GameCollection`] with the current crate version and +/// [`FORMAT_VERSION`] set. +/// +/// # Examples +/// +/// ``` +/// use gfcore::history::{GameCollection, FORMAT_VERSION}; +/// +/// let col = GameCollection::new(); +/// assert!(col.is_empty()); +/// assert_eq!(col.format_version, FORMAT_VERSION); +/// ``` +#[must_use] +pub fn new() -> Self { + Self { + gfcore_version: env!("CARGO_PKG_VERSION").to_string(), + format_version: FORMAT_VERSION, + games: Vec::new(), + } +} +``` + +`push()`, `len()`, `is_empty()`, `iter()` — replace `self.0` with `self.games` in each body. + +`Index` impl: +```rust +impl std::ops::Index for GameCollection { + type Output = GameRecord; + fn index(&self, idx: usize) -> &Self::Output { + &self.games[idx] + } +} +``` + +Add `Default` impl (the struct cannot derive it because `gfcore_version` needs a runtime call): +```rust +impl Default for GameCollection { + fn default() -> Self { + Self::new() + } +} +``` + +- [ ] **Step 6: Add save() and save\_to() methods** + +Add to the `impl GameCollection` block, below `from_json`: + +```rust +/// Writes this collection to `generated/_.yaml`. +/// +/// Creates the `generated/` directory if it does not already exist. +/// Returns the path written on success. +/// +/// # Errors +/// +/// - [`GfError::IoError`] — directory creation or file write failed. +/// - [`GfError::ParseError`] — YAML serialization failed. +/// +/// # Examples +/// +/// ```no_run +/// use gfcore::history::GameCollection; +/// +/// let col = GameCollection::new(); +/// let path = col.save("my_session").expect("save must succeed"); +/// assert!(path.contains("my_session")); +/// ``` +#[cfg(not(target_arch = "wasm32"))] +pub fn save(&self, run_name: &str) -> Result { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let path = format!("generated/{run_name}_{ts}.yaml"); + self.save_to(&path) +} + +/// Writes this collection to `path`, creating parent directories as needed. +/// +/// Returns `path` as a `String` on success. +/// +/// # Errors +/// +/// - [`GfError::IoError`] — directory creation or file write failed. +/// - [`GfError::ParseError`] — YAML serialization failed. +/// +/// # Examples +/// +/// ```no_run +/// use gfcore::history::GameCollection; +/// +/// let col = GameCollection::new(); +/// let path = col.save_to("/tmp/test_collection.yaml").expect("save must succeed"); +/// assert_eq!(path, "/tmp/test_collection.yaml"); +/// ``` +#[cfg(not(target_arch = "wasm32"))] +pub fn save_to(&self, path: &str) -> Result { + let yaml = self.to_yaml()?; + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|e| GfError::IoError(e.to_string()))?; + } + } + std::fs::write(path, &yaml).map_err(|e| GfError::IoError(e.to_string()))?; + Ok(path.to_string()) +} +``` + +- [ ] **Step 7: Run all tests and clippy** + +```bash +cargo test --all-features && cargo clippy --all-features -- -D warnings +``` + +Expected: all tests pass. The three new tests (format\_version, gfcore\_version, yaml\_contains\_fields) all pass. The `save_to` test writes and reads back correctly. No warnings. + +- [ ] **Step 8: Commit** + +``` +git add src/history/record.rs +git commit -m "feat(history): GameCollection versioned struct, FORMAT_VERSION, save()/save_to()" +``` + +--- + +### Task 4: history/mod.rs — scaffold audit and replay submodules + +**Files:** +- Modify: `src/history/mod.rs` +- Create: `src/history/audit.rs` (stub) +- Create: `src/history/replay.rs` (stub) + +The goal of this task is to get a clean compile and passing doc tests with stubs. The full implementations come in Tasks 5 and 7. + +- [ ] **Step 1: Create stub src/history/audit.rs** + +```rust +//! Structural audit of [`GameRecord`] and [`GameCollection`]. + +use serde::{Deserialize, Serialize}; + +use super::record::{GameCollection, GameRecord}; + +/// The result of a structural audit of a single [`GameRecord`]. +/// +/// Produced by [`GameRecord::audit`] and collected by [`GameCollection::audit_all`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuditResult { + /// The game ID from the audited record. + pub game_id: String, + /// `true` iff no violations were found. + pub is_consistent: bool, + /// Book counts per player as of the last recorded turn (empty if no turns). + pub final_books: Vec, + /// Human-readable violation descriptions. Empty when `is_consistent`. + pub violations: Vec, +} + +impl GameRecord { + /// Validates structural invariants of this record. + /// + /// Infallible — violations accumulate in [`AuditResult::violations`] + /// rather than returning an error. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameRecord; + /// + /// let record = GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()]); + /// let result = record.audit(); + /// assert!(result.is_consistent); + /// assert!(result.violations.is_empty()); + /// ``` + pub fn audit(&self) -> AuditResult { + AuditResult { + game_id: self.id.clone(), + is_consistent: true, + final_books: vec![], + violations: vec![], + } + } +} + +impl GameCollection { + /// Audits every game in this collection and returns one [`AuditResult`] per game. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::{GameCollection, GameRecord}; + /// + /// let mut col = GameCollection::new(); + /// col.push(GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()])); + /// let results = col.audit_all(); + /// assert_eq!(results.len(), 1); + /// assert!(results[0].is_consistent); + /// ``` + pub fn audit_all(&self) -> Vec { + self.games.iter().map(GameRecord::audit).collect() + } +} +``` + +- [ ] **Step 2: Create stub src/history/replay.rs** + +```rust +//! Engine-replay verification for [`GameRecord`] and [`GameCollection`]. + +use crate::error::GfError; + +use super::record::{GameCollection, GameRecord}; + +/// The result of replaying a [`GameRecord`] through a fresh engine instance. +/// +/// Produced by [`GameRecord::replay`] and collected by [`GameCollection::replay_all`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplayResult { + /// The game ID from the replayed record. + pub game_id: String, + /// `true` iff every turn's replayed book counts match the stored counts + /// and the final winner matches. + pub is_consistent: bool, + /// Book counts per player as of the last replayed turn. + pub final_books: Vec, + /// Index of the first turn where replayed state diverged, or `None`. + pub mismatch_at_turn: Option, +} + +impl GameRecord { + /// Re-runs stored actions through a fresh [`crate::game::Game`] engine + /// and compares results to stored turn data. + /// + /// # Errors + /// + /// - [`GfError::NoReplayData`] — at least one turn has `actions: None`. + /// - [`GfError::ParseError`] — the variant name is not recognised. + /// - Engine errors propagated from [`crate::game::Game::act`]. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameRecord; + /// + /// // A record with no turns is trivially consistent. + /// let record = GameRecord::new("Standard Go Fish", vec!["Alice".to_string(), "Bob".to_string()]); + /// let result = record.replay().expect("empty record replay must succeed"); + /// assert!(result.is_consistent); + /// ``` + pub fn replay(&self) -> Result { + // Stub — full implementation in Task 7. + Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent: true, + final_books: vec![], + mismatch_at_turn: None, + }) + } +} + +impl GameCollection { + /// Replays every game in this collection, returning one `Result` per game. + /// + /// Individual failures do not abort the batch. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameCollection; + /// + /// let col = GameCollection::new(); + /// let results = col.replay_all(); + /// assert!(results.is_empty()); + /// ``` + pub fn replay_all(&self) -> Vec> { + self.games.iter().map(GameRecord::replay).collect() + } +} +``` + +- [ ] **Step 3: Update src/history/mod.rs** + +Replace the entire file contents: + +```rust +//! Game history recording and YAML/JSON serialization. +//! +//! Requires the `history` feature (enabled by default). +//! +//! # Overview +//! +//! - [`TurnRecord`] — one player's turn: events emitted, book counts, and +//! optionally the actions taken (enables replay). +//! - [`GameRecord`] — full game record with UUID, timestamp, players, turns, winner. +//! - [`GameCollection`] — versioned, ordered list of [`GameRecord`]s with +//! round-trip serialization and file-save support. +//! - [`AuditResult`] — structural invariant check result from [`GameRecord::audit`]. +//! - [`ReplayResult`] — engine-replay check result from [`GameRecord::replay`]. +//! +//! # Examples +//! +//! ``` +//! use gfcore::history::{GameCollection, GameRecord, TurnRecord}; +//! +//! let record = GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()]); +//! let yaml = record.to_yaml().expect("serialize"); +//! let parsed = GameRecord::from_yaml(&yaml).expect("deserialize"); +//! assert_eq!(record, parsed); +//! +//! let mut col = GameCollection::new(); +//! col.push(record); +//! assert_eq!(col.len(), 1); +//! +//! let results = col.audit_all(); +//! assert!(results[0].is_consistent); +//! ``` + +pub mod record; +pub mod audit; +pub mod replay; + +pub use record::{FORMAT_VERSION, GameCollection, GameRecord, TurnRecord}; +pub use audit::AuditResult; +pub use replay::ReplayResult; +``` + +- [ ] **Step 4: Run all tests and clippy** + +```bash +cargo test --all-features && cargo clippy --all-features -- -D warnings +``` + +Expected: all tests pass (stubs compile cleanly). No warnings. + +- [ ] **Step 5: Commit** + +``` +git add src/history/mod.rs src/history/audit.rs src/history/replay.rs +git commit -m "feat(history): scaffold audit and replay submodules with stubs" +``` + +--- + +### Task 5: audit.rs — full implementation + +**Files:** +- Modify: `src/history/audit.rs` + +- [ ] **Step 1: Add unit tests for every violation type** + +Add a `#[cfg(test)]` block at the bottom of `src/history/audit.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::game::GameEvent; + use crate::history::record::{GameCollection, TurnRecord}; + + fn make_clean_record(player_count: usize, turn_count: usize) -> GameRecord { + let players: Vec = (0..player_count).map(|i| format!("P{i}")).collect(); + let mut r = GameRecord::new("Standard", players); + for t in 0..turn_count { + r.turns.push(TurnRecord { + player: t % player_count, + events: vec![GameEvent::Drew { player: t % player_count, matched: false }], + books_after_turn: vec![0; player_count], + actions: None, + }); + } + r + } + + #[test] + fn test_audit_clean_record_is_consistent() { + let record = make_clean_record(2, 3); + let result = record.audit(); + assert!(result.is_consistent); + assert!(result.violations.is_empty()); + } + + #[test] + fn test_audit_empty_record_no_turns_is_consistent() { + let record = GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()]); + let result = record.audit(); + assert!(result.is_consistent); + assert!(result.violations.is_empty()); + assert!(result.final_books.is_empty()); + } + + #[test] + fn test_audit_single_player_is_violation() { + let record = GameRecord::new("Standard", vec!["Solo".to_string()]); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("player"))); + } + + #[test] + fn test_audit_turn_player_out_of_range() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 5, + events: vec![GameEvent::Drew { player: 5, matched: false }], + books_after_turn: vec![0, 0], + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_books_after_turn_wrong_length() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![0], // should be length 2 + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_empty_events_in_turn() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_book_counts_decreasing_is_violation() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![2, 0], + actions: None, + }); + record.turns.push(TurnRecord { + player: 1, + events: vec![GameEvent::Drew { player: 1, matched: false }], + books_after_turn: vec![1, 0], // player 0 decreased + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("decreas"))); + } + + #[test] + fn test_audit_total_books_exceeds_13() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![10, 5], // 15 > 13 + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + // The violation message includes the actual total (15). + assert!(result.violations.iter().any(|v| v.contains("15"))); + } + + #[test] + fn test_audit_winner_some_but_another_has_more_books() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![3, 5], + actions: None, + }); + record.winner = Some(0); // player 1 has more books + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_some_but_tied() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![5, 5], + actions: None, + }); + record.winner = Some(0); // tied — no unique winner + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_none_but_clear_leader() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![5, 3], + actions: None, + }); + record.winner = None; // player 0 has unique max — must be declared winner + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_correct_unique_max() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![5, 3], + actions: None, + }); + record.winner = Some(0); + let result = record.audit(); + assert!(result.is_consistent, "violations: {:?}", result.violations); + } + + #[test] + fn test_audit_winner_none_correct_when_tied() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![4, 4], + actions: None, + }); + record.winner = None; + let result = record.audit(); + assert!(result.is_consistent, "violations: {:?}", result.violations); + } + + #[test] + fn test_audit_final_books_populated_from_last_turn() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![2, 3], + actions: None, + }); + record.winner = Some(1); + let result = record.audit(); + assert_eq!(result.final_books, vec![2, 3]); + } + + #[test] + fn test_audit_all_consistent_collection() { + let mut col = GameCollection::new(); + col.push(make_clean_record(2, 3)); + col.push(make_clean_record(3, 4)); + let results = col.audit_all(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.is_consistent)); + } +} +``` + +- [ ] **Step 2: Run tests to see most violation tests fail** + +```bash +cargo test --lib -- audit 2>&1 | tail -30 +``` + +Expected: the stub returns `is_consistent: true` for everything, so violation tests fail. `test_audit_clean_record_is_consistent`, `test_audit_empty_record_no_turns_is_consistent`, and `test_audit_all_consistent_collection` may pass; all others fail. + +- [ ] **Step 3: Implement GameRecord::audit()** + +Replace the stub body of `audit()`: + +```rust +pub fn audit(&self) -> AuditResult { + let mut violations: Vec = Vec::new(); + let player_count = self.players.len(); + + // Check 1: at least 2 players. + if player_count < 2 { + violations.push(format!( + "player count is {player_count}; must be at least 2" + )); + } + + for (i, turn) in self.turns.iter().enumerate() { + // Check 2: turn player index in range. + if turn.player >= player_count { + violations.push(format!( + "turn {i}: player index {} out of range (players: {player_count})", + turn.player + )); + } + + // Check 3: books_after_turn length matches player count. + if turn.books_after_turn.len() != player_count { + violations.push(format!( + "turn {i}: books_after_turn length {} != player count {player_count}", + turn.books_after_turn.len() + )); + } + + // Check 4: events must not be empty. + if turn.events.is_empty() { + violations.push(format!("turn {i}: events list is empty")); + } + } + + // Check 5: book counts non-decreasing per player. + for (i, turn) in self.turns.iter().enumerate().skip(1) { + let prev = &self.turns[i - 1].books_after_turn; + let curr = &turn.books_after_turn; + if prev.len() == player_count && curr.len() == player_count { + for p in 0..player_count { + if curr[p] < prev[p] { + violations.push(format!( + "player {p} book count decreased from {} to {} at turn {i}", + prev[p], curr[p] + )); + } + } + } + } + + // Check 6: total books <= 13 (Standard52 deck maximum). + if let Some(last) = self.turns.last() { + let total: usize = last.books_after_turn.iter().sum(); + if total > 13 { + violations.push(format!( + "total books {total} exceeds maximum of 13 (Standard52 deck yields at most 13 books)" + )); + } + } + + // Check 7: winner consistent with final book counts. + if let Some(last) = self.turns.last() { + let books = &last.books_after_turn; + if books.len() == player_count && player_count >= 2 { + let max_books = *books.iter().max().unwrap_or(&0); + let leaders: Vec = (0..player_count) + .filter(|&p| books[p] == max_books) + .collect(); + let unique_leader = leaders.len() == 1; + + match self.winner { + Some(w) => { + if w >= player_count { + violations.push(format!( + "winner index {w} is out of range (players: {player_count})" + )); + } else if !unique_leader { + violations.push(format!( + "winner declared as player {w} but final books are tied (books: {books:?})" + )); + } else if leaders[0] != w { + violations.push(format!( + "winner declared as player {w} but player {} has more books (books: {books:?})", + leaders[0] + )); + } + } + None => { + if unique_leader && max_books > 0 { + violations.push(format!( + "winner is None but player {} has unique max book count of \ + {max_books} (books: {books:?})", + leaders[0] + )); + } + } + } + } + } + + let final_books = self + .turns + .last() + .map(|t| t.books_after_turn.clone()) + .unwrap_or_default(); + + AuditResult { + game_id: self.id.clone(), + is_consistent: violations.is_empty(), + final_books, + violations, + } +} +``` + +- [ ] **Step 4: Run audit tests** + +```bash +cargo test --lib -- audit && cargo clippy --all-features -- -D warnings +``` + +Expected: all 14 audit tests pass. No warnings. + +- [ ] **Step 5: Run all tests to check no regressions** + +```bash +cargo test --all-features +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +``` +git add src/history/audit.rs +git commit -m "feat(history): implement GameRecord::audit() with full structural checks" +``` + +--- + +### Task 6: state.rs — pending\_turn\_actions accumulator + +**Files:** +- Modify: `src/game/state.rs` + +This task wires action recording into the engine. After this task, every `TurnRecord` produced by `game.record()` will have `actions: Some(...)`. + +- [ ] **Step 1: Run existing tests as baseline** + +```bash +cargo test --all-features 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 2: Add pending\_turn\_actions field to Game struct** + +In the `Game` struct, after `pending_turn_events`: + +```rust +/// Actions submitted during the current turn, flushed into [`TurnRecord::actions`]. +#[cfg(feature = "history")] +pending_turn_actions: Vec, +``` + +- [ ] **Step 3: Initialize in Game::new()** + +In the `let mut game = Self { ... }` initializer, after `pending_turn_events: Vec::new()`: + +```rust +#[cfg(feature = "history")] +pending_turn_actions: Vec::new(), +``` + +- [ ] **Step 4: Push Ask action in handle\_ask() after validation** + +In `handle_ask()`, after the `InvalidAsk` check and before `self.ask_log.push(...)`, add: + +```rust +// Record the validated action for replay. +#[cfg(feature = "history")] +self.pending_turn_actions.push(PlayerAction::Ask { target, rank }); +``` + +- [ ] **Step 5: Push Draw action in handle\_draw() after validation** + +In `handle_draw()`, after the phase check and before `let asked_rank = self.last_asked_rank.take();`, add: + +```rust +// Record the validated action for replay. +#[cfg(feature = "history")] +self.pending_turn_actions.push(PlayerAction::Draw); +``` + +- [ ] **Step 6: Flush pending\_turn\_actions in flush\_turn()** + +Update the full `flush_turn()` method to include `actions`: + +```rust +#[cfg(feature = "history")] +fn flush_turn(&mut self) { + if self.pending_turn_events.is_empty() { + return; + } + let books: Vec = self.players.iter().map(Player::book_count).collect(); + self.history.turns.push(TurnRecord { + player: self.pending_turn_player, + events: std::mem::take(&mut self.pending_turn_events), + books_after_turn: books, + actions: Some(std::mem::take(&mut self.pending_turn_actions)), + }); +} +``` + +- [ ] **Step 7: Include pending\_turn\_actions in the record() snapshot** + +Update `record()` to include in-flight actions: + +```rust +#[cfg(feature = "history")] +pub fn record(&self) -> GameRecord { + let mut record = self.history.clone(); + if !self.pending_turn_events.is_empty() { + let books: Vec = self.players.iter().map(Player::book_count).collect(); + record.turns.push(TurnRecord { + player: self.pending_turn_player, + events: self.pending_turn_events.clone(), + books_after_turn: books, + actions: Some(self.pending_turn_actions.clone()), + }); + } + record +} +``` + +- [ ] **Step 8: Run all tests and clippy** + +```bash +cargo test --all-features && cargo clippy --all-features -- -D warnings +``` + +Expected: all tests pass. `game.record().turns` now have `actions: Some(...)`. The existing integration tests (which check turn count, winner, and YAML round-trip) all still pass because the `actions` field round-trips correctly. No warnings. + +- [ ] **Step 9: Commit** + +``` +git add src/game/state.rs +git commit -m "feat(game): record player actions per turn for engine replay" +``` + +--- + +### Task 7: replay.rs — full implementation + +**Files:** +- Modify: `src/history/replay.rs` + +- [ ] **Step 1: Add unit tests** + +Add a `#[cfg(test)]` block at the bottom of `src/history/replay.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::bot::BotProfile; + use crate::error::GfError; + use crate::game::{Game, GameEvent, GamePhase, PlayerAction}; + use crate::history::record::{GameCollection, TurnRecord}; + use crate::player::Player; + use crate::rules::GameVariant; + + fn play_full_game() -> GameRecord { + let profiles = BotProfile::default_profiles(); + let players: Vec = profiles.iter().map(|p| Player::new(p.name.clone())).collect(); + let mut game = Game::new(GameVariant::Standard, players).unwrap(); + for _ in 0..13_000 { + if game.is_over() { break; } + let state = game.state().unwrap(); + let cp = state.current_player; + match state.phase { + GamePhase::WaitingForAsk | GamePhase::BookCompleted => { + let hand = state.players.iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + let action = profiles[cp % profiles.len()] + .decide(&hand, &state.players, &state.ask_log); + game.act(action).unwrap(); + } + GamePhase::WaitingForDraw => { game.act(PlayerAction::Draw).unwrap(); } + GamePhase::GameOver => break, + } + } + assert!(game.is_over(), "game must finish within budget"); + game.record() + } + + #[test] + fn test_replay_empty_record_is_consistent() { + let record = GameRecord::new( + "Standard Go Fish", + vec!["Alice".to_string(), "Bob".to_string()], + ); + let result = record.replay().unwrap(); + assert!(result.is_consistent); + assert!(result.mismatch_at_turn.is_none()); + } + + #[test] + fn test_replay_played_game_is_consistent() { + let record = play_full_game(); + let result = record.replay().expect("replay must succeed on engine-recorded game"); + assert!( + result.is_consistent, + "mismatch_at_turn={:?}, final_books={:?}", + result.mismatch_at_turn, result.final_books + ); + } + + #[test] + fn test_replay_returns_no_replay_data_when_actions_none() { + let mut record = GameRecord::new( + "Standard Go Fish", + vec!["Alice".to_string(), "Bob".to_string()], + ); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![0, 0], + actions: None, + }); + let err = record.replay().unwrap_err(); + assert_eq!(err, GfError::NoReplayData); + } + + #[test] + fn test_replay_unknown_variant_returns_parse_error() { + let mut record = GameRecord::new( + "Unknown Variant", + vec!["Alice".to_string(), "Bob".to_string()], + ); + // Must have a turn with actions so we reach parse_variant(). + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { player: 0, matched: false }], + books_after_turn: vec![0, 0], + actions: Some(vec![PlayerAction::Draw]), + }); + let err = record.replay().unwrap_err(); + assert!(matches!(err, GfError::ParseError(_))); + } + + #[test] + fn test_replay_all_played_collection_all_consistent() { + let r1 = play_full_game(); + let r2 = play_full_game(); + let mut col = GameCollection::new(); + col.push(r1); + col.push(r2); + let results = col.replay_all(); + assert_eq!(results.len(), 2); + for (i, res) in results.iter().enumerate() { + assert!( + res.as_ref().unwrap().is_consistent, + "game {i} replay was inconsistent" + ); + } + } +} +``` + +- [ ] **Step 2: Run to verify failures** + +```bash +cargo test --lib -- replay 2>&1 | tail -20 +``` + +Expected: `test_replay_empty_record_is_consistent` passes (stub returns Ok). `test_replay_returns_no_replay_data_when_actions_none` FAILS (stub always returns Ok). `test_replay_unknown_variant_returns_parse_error` FAILS. `test_replay_played_game_is_consistent` superficially passes (stub says consistent) but doesn't actually verify anything. + +- [ ] **Step 3: Add imports and the variant parser** + +At the top of `src/history/replay.rs`, add the required imports: + +```rust +use crate::error::GfError; +use crate::game::{Game, PlayerAction}; +use crate::player::Player; +use crate::rules::GameVariant; + +use super::record::{GameCollection, GameRecord}; +``` + +And a private helper that maps the stored variant name string to a `GameVariant`: + +```rust +fn parse_variant(name: &str) -> Result { + match name { + "Standard Go Fish" => Ok(GameVariant::Standard), + "Happy Families" => Ok(GameVariant::HappyFamilies), + "Quartet" => Ok(GameVariant::Quartet), + _ => Err(GfError::ParseError(format!("unknown game variant: {name}"))), + } +} +``` + +- [ ] **Step 4: Implement GameRecord::replay()** + +Replace the stub body: + +```rust +pub fn replay(&self) -> Result { + // No turns: trivially consistent. + if self.turns.is_empty() { + return Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent: true, + final_books: vec![], + mismatch_at_turn: None, + }); + } + + // All turns must have stored actions before we do any work. + if self.turns.iter().any(|t| t.actions.is_none()) { + return Err(GfError::NoReplayData); + } + + let variant = parse_variant(&self.variant)?; + let players: Vec = self.players.iter().map(|n| Player::new(n)).collect(); + let mut game = Game::new(variant, players)?; + + let mut final_books = vec![0usize; self.players.len()]; + let mut mismatch_at_turn: Option = None; + + 'turns: for (i, turn) in self.turns.iter().enumerate() { + let actions = turn.actions.as_ref().expect("checked above"); + for &action in actions { + game.act(action)?; + } + + let state = game.state()?; + let replayed_books: Vec = state.players.iter().map(|p| p.books).collect(); + final_books = replayed_books.clone(); + + if replayed_books != turn.books_after_turn { + mismatch_at_turn = Some(i); + break 'turns; + } + } + + let winner_matches = game.record().winner == self.winner; + let is_consistent = mismatch_at_turn.is_none() && winner_matches; + + Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent, + final_books, + mismatch_at_turn, + }) +} +``` + +- [ ] **Step 5: Run replay tests** + +```bash +cargo test --lib -- replay && cargo clippy --all-features -- -D warnings +``` + +Expected: all 5 replay tests pass. No warnings. + +- [ ] **Step 6: Run all tests to check no regressions** + +```bash +cargo test --all-features +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +``` +git add src/history/replay.rs +git commit -m "feat(history): implement GameRecord::replay() and GameCollection::replay_all()" +``` + +--- + +### Task 8: prelude.rs — re-export AuditResult and ReplayResult + +**Files:** +- Modify: `src/prelude.rs` + +- [ ] **Step 1: Update the history re-export block** + +Replace: + +```rust +#[cfg(feature = "history")] +pub use crate::history::{GameCollection, GameRecord, TurnRecord}; +``` + +with: + +```rust +#[cfg(feature = "history")] +pub use crate::history::{AuditResult, GameCollection, GameRecord, ReplayResult, TurnRecord}; +``` + +- [ ] **Step 2: Run doc tests and all tests** + +```bash +cargo test --doc --all-features && cargo test --all-features +``` + +Expected: all pass. `AuditResult` and `ReplayResult` are now accessible via `use gfcore::prelude::*`. + +- [ ] **Step 3: Commit** + +``` +git add src/prelude.rs +git commit -m "feat(prelude): re-export AuditResult and ReplayResult" +``` + +--- + +### Task 9: tests/history\_integration.rs — update and extend + +**Files:** +- Modify: `tests/history_integration.rs` + +- [ ] **Step 1: Verify existing tests still pass** + +The `GameCollection` round-trip tests (`test_game_collection_yaml_round_trip`, `test_game_collection_json_round_trip`) serialize with `new()`, round-trip through serde, and compare with `PartialEq`. Both sides use the new struct shape, so they pass without body changes. + +```bash +cargo test --test history_integration --all-features 2>&1 | tail -15 +``` + +Expected: all existing tests pass. + +- [ ] **Step 2: Add imports to history\_integration.rs** + +Add at the top of the file alongside the existing imports: + +```rust +use gfcore::bot::BotProfile; +use gfcore::prelude::AuditResult; +``` + +(These are needed by the new tests below.) + +- [ ] **Step 3: Add audit integration test** + +Append at the bottom of `tests/history_integration.rs`: + +```rust +// --------------------------------------------------------------------------- +// Audit integration tests +// --------------------------------------------------------------------------- + +/// Play 5 bot games and assert that audit_all() reports all consistent. +#[test] +fn test_audit_all_on_played_collection() { + let profiles = BotProfile::default_profiles(); + let mut collection = gfcore::history::GameCollection::new(); + + for _ in 0..5 { + let players: Vec = profiles + .iter() + .map(|p| Player::new(p.name.clone())) + .collect(); + let mut game = Game::new(GameVariant::Standard, players).expect("valid game"); + + for _ in 0..13_000 { + if game.is_over() { break; } + let state = game.state().expect("state"); + let cp = state.current_player; + match state.phase { + GamePhase::WaitingForAsk | GamePhase::BookCompleted => { + let hand = state.players.iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + let action = profiles[cp % profiles.len()] + .decide(&hand, &state.players, &state.ask_log); + game.act(action).expect("act"); + } + GamePhase::WaitingForDraw => { game.act(PlayerAction::Draw).expect("draw"); } + GamePhase::GameOver => break, + } + } + assert!(game.is_over()); + collection.push(game.record()); + } + + let results = collection.audit_all(); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + assert!( + result.is_consistent, + "game {i} failed audit: {:?}", + result.violations + ); + } +} +``` + +- [ ] **Step 4: Add replay integration test** + +```rust +// --------------------------------------------------------------------------- +// Replay integration tests +// --------------------------------------------------------------------------- + +/// Play 5 bot games with action recording and assert replay_all() is consistent. +#[test] +fn test_replay_all_on_played_collection() { + let profiles = BotProfile::default_profiles(); + let mut collection = gfcore::history::GameCollection::new(); + + for _ in 0..5 { + let players: Vec = profiles + .iter() + .map(|p| Player::new(p.name.clone())) + .collect(); + let mut game = Game::new(GameVariant::Standard, players).expect("valid game"); + + for _ in 0..13_000 { + if game.is_over() { break; } + let state = game.state().expect("state"); + let cp = state.current_player; + match state.phase { + GamePhase::WaitingForAsk | GamePhase::BookCompleted => { + let hand = state.players.iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + let action = profiles[cp % profiles.len()] + .decide(&hand, &state.players, &state.ask_log); + game.act(action).expect("act"); + } + GamePhase::WaitingForDraw => { game.act(PlayerAction::Draw).expect("draw"); } + GamePhase::GameOver => break, + } + } + assert!(game.is_over()); + collection.push(game.record()); + } + + let results = collection.replay_all(); + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + let replay = result + .as_ref() + .unwrap_or_else(|e| panic!("game {i} replay error: {e}")); + assert!( + replay.is_consistent, + "game {i} replay inconsistent: mismatch_at_turn={:?}", + replay.mismatch_at_turn + ); + } +} +``` + +- [ ] **Step 5: Run integration tests** + +```bash +cargo test --test history_integration --all-features 2>&1 | tail -20 +``` + +Expected: all tests pass including the two new integration tests. + +- [ ] **Step 6: Run full test suite** + +```bash +cargo test --all-features +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +``` +git add tests/history_integration.rs +git commit -m "test(history): add audit_all and replay_all integration tests" +``` + +--- + +### Task 10: bot\_marathon.rs — structural audit in validate\_last\_game + +**Files:** +- Modify: `tests/bot_marathon.rs` + +- [ ] **Step 1: Add audit call to validate\_last\_game()** + +In `validate_last_game`, after the YAML round-trip block, add: + +```rust +// Every completed game must pass a structural audit. +let audit = record.audit(); +if !audit.is_consistent { + dump_and_panic( + game_num, + "audit", + format!("structural audit failed: {:?}", audit.violations), + collection, + ); +} +``` + +No new imports are needed — `record.audit()` is a method on `&GameRecord` (already in scope), and `audit.is_consistent` / `audit.violations` are plain field accesses. + +- [ ] **Step 2: Run the marathon test** + +```bash +cargo test --test bot_marathon -- --include-ignored --nocapture 2>&1 | tail -10 +``` + +Expected: +``` +bot_marathon: 10/100 games complete +... +bot_marathon: complete — 100 games played without error +``` + +- [ ] **Step 3: Run full test suite and clippy** + +```bash +cargo test --all-features && cargo clippy --all-features -- -D warnings +``` + +Expected: all tests pass, no warnings. + +- [ ] **Step 4: Commit** + +``` +git add tests/bot_marathon.rs +git commit -m "test(marathon): add structural audit to validate_last_game" +``` + +--- + +## Self-Review + +### Spec coverage + +| Spec requirement | Task | +|---|---| +| `FORMAT_VERSION: u32 = 1` | Task 3 | +| `TurnRecord::actions: Option>` | Task 2 | +| `GameCollection` newtype → versioned struct | Task 3 | +| `GfError::IoError`, `GfError::NoReplayData` | Task 1 | +| `Game::pending_turn_actions` accumulator | Task 6 | +| `AuditResult`, `GameRecord::audit()`, `audit_all()` | Tasks 4 + 5 | +| All 11 audit violation types from spec | Task 5 | +| `ReplayResult`, `GameRecord::replay()`, `replay_all()` | Tasks 4 + 7 | +| `save()`, `save_to()` | Task 3 | +| `prelude.rs` re-exports | Task 8 | +| `history_integration.rs` updated + new tests | Task 9 | +| `bot_marathon.rs` audit call | Task 10 | + +### Type consistency + +- `TurnRecord::actions: Option>` — defined Task 2, used in Tasks 5, 6, 7 +- `GameCollection::games: Vec` — defined Task 3, accessed in Tasks 4, 5, 7, 9 +- `FORMAT_VERSION: u32` — defined Task 3, re-exported in Task 4, tested in Task 3 +- `AuditResult` — stub in Task 4, implemented in Task 5, re-exported in Tasks 4 + 8 +- `ReplayResult` — stub in Task 4, implemented in Task 7, re-exported in Tasks 4 + 8 +- `parse_variant()` maps `"Standard Go Fish"` / `"Happy Families"` / `"Quartet"` — defined and used only in Task 7 diff --git a/docs/superpowers/specs/2026-05-09-game-history-audit-design.md b/docs/superpowers/specs/2026-05-09-game-history-audit-design.md new file mode 100644 index 0000000..42c8a71 --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-game-history-audit-design.md @@ -0,0 +1,274 @@ +# Game History Audit & Replay — Design Spec + +**Date:** 2026-05-09 +**Status:** Approved +**Scope:** `gfcore` — `src/history/`, `src/error/`, `src/game/state.rs`, `src/prelude.rs`, integration tests + +--- + +## Problem + +Apps like `gfarena` can export a game's YAML history via `get_game_yaml()` (single `GameRecord`) +or accumulate a session into a `GameCollection`. There is currently no way to: + +1. Validate that an exported file is internally consistent (book conservation, winner correctness, etc.) +2. Re-run recorded actions through a fresh engine instance and confirm the outcomes match +3. Save a `GameCollection` to a timestamped file on disk + +This spec adds all three capabilities, modelled after pkcore's `HandHistory::replay` / +`HandCollection::replay_all` pattern. + +--- + +## Decisions + +| Question | Decision | +|---|---| +| Audit depth | Both structural (any file) + engine replay (requires stored actions) | +| Version handling | No migration burden — no files exist in the wild yet | +| Save location | `save(run_name)` → `generated/_.yaml`; `save_to(path)` for explicit paths | +| WASM exposure | None — audit/replay is native-only | + +--- + +## Module Structure + +Current `src/history/` has only `mod.rs` + `record.rs`. After this change: + +``` +src/history/ + mod.rs — re-exports: GameRecord, GameCollection, TurnRecord, + AuditResult, ReplayResult, FORMAT_VERSION + record.rs — data types, serde, GameCollection versioning, save()/save_to() + audit.rs — AuditResult; GameRecord::audit(); GameCollection::audit_all() + replay.rs — ReplayResult; GameRecord::replay(); GameCollection::replay_all() +``` + +`audit.rs` and `replay.rs` extend types from `record.rs` via `impl` blocks. +`record.rs` stays focused on data definition and I/O. + +--- + +## Data Model Changes + +### `FORMAT_VERSION` + +```rust +pub const FORMAT_VERSION: u32 = 1; +``` + +The initial versioned format. Written into every new `GameCollection`. + +### `TurnRecord` — new field + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub actions: Option>, +``` + +- `None` — recorded without the replay path (e.g. WASM games, old unit tests). +- `Some(...)` — recorded by the full native engine; enables `replay()`. + +`PlayerAction` is already `Serialize + Deserialize`. + +### `GameCollection` — structural change + +From newtype `(Vec)` to: + +```rust +pub struct GameCollection { + #[serde(default = "default_gfcore_version")] + pub gfcore_version: String, // e.g. "0.0.2" + #[serde(default = "default_format_version")] + pub format_version: u32, // always FORMAT_VERSION (1) for now + pub games: Vec, +} +``` + +**Breaking YAML format change:** bare array → keyed object. Acceptable at `0.0.x` with no +existing files in the wild. `Index` and `iter()` are preserved via updated impls. + +### `GameRecord` — no format_version field + +The presence or absence of `TurnRecord::actions` is the capability signal. No separate +`format_version` field is needed on `GameRecord`. + +### `GfError` — two new variants + +```rust +/// A filesystem operation failed during save. +IoError(String), + +/// replay() was called on a record where at least one turn has no stored actions. +NoReplayData, +``` + +`From` is **not** added. Conversion is done manually in `save`/`save_to` +to keep `GfError: Clone + PartialEq`. + +### `Game` (`state.rs`) — action accumulator + +`pending_turn_actions: Vec` is added alongside `pending_turn_events`. +Populated in `handle_ask()` and `handle_draw()`, flushed into `TurnRecord::actions` +by `flush_turn()`. Both fields are gated `#[cfg(feature = "history")]`. + +--- + +## API Surface + +### `audit.rs` + +```rust +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuditResult { + pub game_id: String, + pub is_consistent: bool, + pub final_books: Vec, // per-player book counts at last turn + pub violations: Vec, // empty when is_consistent +} + +impl GameRecord { + /// Validates structural invariants. Infallible — violations go into AuditResult. + pub fn audit(&self) -> AuditResult { ... } +} + +impl GameCollection { + pub fn audit_all(&self) -> Vec { ... } +} +``` + +**`audit()` checks (in order):** + +1. `players.len() >= 2` +2. For each turn: `turn.player < players.len()` +3. For each turn: `turn.books_after_turn.len() == players.len()` +4. For each turn: `!turn.events.is_empty()` +5. Book counts non-decreasing per player across turns +6. `total_books <= 13` (conservative max for all built-in 52-card variants) +7. Winner consistent with final book counts: + - `Some(w)`: player `w` in range and holds the unique max book count + - `None` with turns present: no single player holds a unique max (tie or all-zero) + +### `replay.rs` + +```rust +#[derive(Debug, Clone, PartialEq)] +pub struct ReplayResult { + pub game_id: String, + pub is_consistent: bool, + pub final_books: Vec, + pub mismatch_at_turn: Option, // first turn where replay diverged +} + +impl GameRecord { + /// Re-runs stored actions through a fresh Game engine. + /// Returns Err(GfError::NoReplayData) if any turn lacks actions. + pub fn replay(&self) -> Result { ... } +} + +impl GameCollection { + /// Replays all games; per-record Results allow partial success. + pub fn replay_all(&self) -> Vec> { ... } +} +``` + +**`replay()` logic:** + +1. If any `turn.actions` is `None` → `Err(GfError::NoReplayData)` +2. Create `Game::new(variant, players)` with the same players as the record +3. For each `TurnRecord`: + - Feed `turn.actions.unwrap()` through `game.act()` in order + - Compare resulting `books_after_turn` to stored `turn.books_after_turn` + - On first mismatch: record `mismatch_at_turn = Some(i)`, set `is_consistent = false`, stop +4. Compare final `game.record().winner` to `self.winner` + +The variant name string (`record.variant`) is mapped back to `GameVariant` for `Game::new()`. +Unknown variants → `Err(GfError::ParseError(...))`. + +### `record.rs` additions + +```rust +impl GameCollection { + /// Writes to generated/_.yaml. Creates the directory if needed. + /// Returns the path written on success. + #[cfg(not(target_arch = "wasm32"))] + pub fn save(&self, run_name: &str) -> Result { ... } + + /// Writes to the caller-supplied path. Creates parent directories if needed. + /// Returns the path written on success. + #[cfg(not(target_arch = "wasm32"))] + pub fn save_to(&self, path: &str) -> Result { ... } +} +``` + +### `prelude.rs` additions + +```rust +pub use crate::history::{AuditResult, ReplayResult}; +``` + +--- + +## Error Handling + +- `audit()` is infallible. Violations accumulate in `AuditResult::violations`; `is_consistent` + is `false` iff any violation exists. +- `replay()` returns `Result`. `GfError::NoReplayData` is the expected "not recorded with actions" + case. Other errors (`ParseError`, `InvalidAsk`, etc.) indicate corrupt or engine-inconsistent data. +- `save()`/`save_to()` return `Result` where `Err` wraps `GfError::IoError`. +- `replay_all()` returns `Vec>` — one entry per game — so a single un-replayable game + does not abort the batch. + +--- + +## Testing + +### Unit tests (in each new file) + +**`audit.rs`** — one test per violation type: +- player count < 2 +- turn player index out of range +- `books_after_turn` length mismatch +- empty events in a turn +- book counts decreasing +- total books > 13 +- `winner: Some(w)` but another player has more books +- `winner: Some(w)` but multiple players are tied +- `winner: None` with a clear unique leader +- clean record with turns → `is_consistent: true` +- empty record (no turns) → `is_consistent: true` + +**`replay.rs`** — construct `GameRecord`s with known actions, assert `is_consistent`; assert +`NoReplayData` for records without actions. + +**`record.rs`** — update existing `GameCollection` unit tests for the new struct shape +(YAML now has `gfcore_version`/`format_version`/`games` keys). Add `save_to()` test writing +to `std::env::temp_dir()`. + +### Integration tests (`tests/history_integration.rs`) + +- `test_audit_all_on_played_collection` — plays 5 bot games, calls `audit_all()`, asserts all + `is_consistent`. +- `test_replay_all_on_played_collection` — same 5 games, calls `replay_all()`, asserts all + `Ok` and `is_consistent`. Proves action-recording and event-recording paths agree. + +### Marathon update (`tests/bot_marathon.rs`) + +`validate_last_game` gains `record.audit()` alongside the YAML round-trip, so every one of +the 100 marathon games is structurally audited automatically. + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/history/mod.rs` | add `pub mod audit; pub mod replay;`; update re-exports | +| `src/history/record.rs` | `GameCollection` newtype → struct; add `FORMAT_VERSION`; add `save()`/`save_to()`; update all impls and unit tests | +| `src/history/audit.rs` | new file | +| `src/history/replay.rs` | new file | +| `src/error/mod.rs` | add `IoError`, `NoReplayData`; update `Display` and tests | +| `src/game/state.rs` | add `pending_turn_actions`; populate in `handle_ask`/`handle_draw`; flush in `flush_turn` | +| `src/prelude.rs` | re-export `AuditResult`, `ReplayResult` | +| `tests/history_integration.rs` | update existing tests for new `GameCollection` shape; add audit/replay integration tests | +| `tests/bot_marathon.rs` | add `record.audit()` call in `validate_last_game` | diff --git a/src/error/mod.rs b/src/error/mod.rs index 73f89a8..bde7c1f 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -149,6 +149,38 @@ pub enum GfError { /// ); /// ``` ParseError(String), + + /// A filesystem operation failed during save. + /// + /// The inner `String` contains the OS error message. + /// + /// # Examples + /// + /// ``` + /// use gfcore::prelude::GfError; + /// + /// let err = GfError::IoError("permission denied".to_string()); + /// assert_eq!(err.to_string(), "io error: permission denied"); + /// ``` + IoError(String), + + /// `replay()` was called on a record where at least one turn has no stored actions. + /// + /// This error is expected when replaying records produced before action + /// recording was added (e.g., WASM games or old tests). + /// + /// # Examples + /// + /// ``` + /// use gfcore::prelude::GfError; + /// + /// let err = GfError::NoReplayData; + /// assert_eq!( + /// err.to_string(), + /// "no replay data: record is missing actions or initial deck state", + /// ); + /// ``` + NoReplayData, } impl fmt::Display for GfError { @@ -168,6 +200,10 @@ impl fmt::Display for GfError { Self::GameAlreadyOver => f.write_str("action called after the game has already ended"), Self::EmptyDrawPile => f.write_str("draw attempted on an empty draw pile"), Self::ParseError(msg) => write!(f, "parse error: {msg}"), + Self::IoError(msg) => write!(f, "io error: {msg}"), + Self::NoReplayData => { + f.write_str("no replay data: record is missing actions or initial deck state") + } } } } @@ -247,9 +283,26 @@ mod tests { GfError::GameAlreadyOver, GfError::EmptyDrawPile, GfError::ParseError("bad".into()), + GfError::IoError("disk full".into()), + GfError::NoReplayData, ]; for v in &variants { assert!(!v.to_string().is_empty(), "{v:?} Display must not be empty"); } } + + #[test] + fn test_io_error_display() { + let err = GfError::IoError("permission denied".to_string()); + assert_eq!(err.to_string(), "io error: permission denied"); + } + + #[test] + fn test_no_replay_data_display() { + let err = GfError::NoReplayData; + assert_eq!( + err.to_string(), + "no replay data: record is missing actions or initial deck state", + ); + } } diff --git a/src/game/state.rs b/src/game/state.rs index 74a71d5..f5a0f4e 100644 --- a/src/game/state.rs +++ b/src/game/state.rs @@ -219,6 +219,9 @@ pub struct Game { /// Events accumulated for the current turn, flushed when the turn ends. #[cfg(feature = "history")] pending_turn_events: Vec, + /// Actions submitted during the current turn, flushed into [`TurnRecord::actions`]. + #[cfg(feature = "history")] + pending_turn_actions: Vec, /// Completed turn records plus metadata; grows as turns are flushed. #[cfg(feature = "history")] history: GameRecord, @@ -249,7 +252,28 @@ impl Game { /// let game = Game::new(GameVariant::Standard, players); /// assert!(game.is_ok()); /// ``` - pub fn new(variant: GameVariant, mut players: Vec) -> Result { + pub fn new(variant: GameVariant, players: Vec) -> Result { + let draw_pile = variant.rules().deck(); + Self::setup(variant, players, draw_pile) + } + + /// Creates a game using the provided pre-built draw pile instead of dealing from a + /// freshly shuffled deck. Used by [`crate::history::replay`] to reconstruct the + /// exact initial state recorded in a [`crate::history::GameRecord`]. + #[cfg(feature = "history")] + pub(crate) fn new_with_deck( + variant: GameVariant, + players: Vec, + draw_pile: cardpack::prelude::BasicPile, + ) -> Result { + Self::setup(variant, players, draw_pile) + } + + fn setup( + variant: GameVariant, + mut players: Vec, + mut draw_pile: cardpack::prelude::BasicPile, + ) -> Result { let rules = variant.rules(); let player_count = players.len(); @@ -260,7 +284,15 @@ impl Game { return Err(GfError::TooManyPlayers); } - let mut draw_pile = rules.deck(); + // Build the initial GameRecord before dealing (captures initial_draw_pile). + #[cfg(feature = "history")] + let history = { + let player_names: Vec = players.iter().map(|p| p.name.clone()).collect(); + let mut record = GameRecord::new(variant.rules().name(), player_names); + record.initial_draw_pile = Some(draw_pile.clone()); + record + }; + let hand_size = rules.initial_hand_size(player_count); // Deal cards in round-robin order. @@ -277,13 +309,6 @@ impl Game { Self::collect_books_for_player(player, rules); } - // Build the initial GameRecord before variant/players are moved. - #[cfg(feature = "history")] - let history = { - let player_names: Vec = players.iter().map(|p| p.name.clone()).collect(); - GameRecord::new(variant.rules().name(), player_names) - }; - let mut game = Self { variant, players, @@ -299,6 +324,8 @@ impl Game { #[cfg(feature = "history")] pending_turn_events: Vec::new(), #[cfg(feature = "history")] + pending_turn_actions: Vec::new(), + #[cfg(feature = "history")] history, }; @@ -496,6 +523,11 @@ impl Game { player: self.pending_turn_player, events: self.pending_turn_events.clone(), books_after_turn: books, + actions: if self.pending_turn_actions.is_empty() { + None + } else { + Some(self.pending_turn_actions.clone()) + }, }); } record @@ -527,6 +559,11 @@ impl Game { return Err(GfError::InvalidAsk); } + // Record the validated action for replay. + #[cfg(feature = "history")] + self.pending_turn_actions + .push(PlayerAction::Ask { target, rank }); + // Record the ask in the public log before branching on Go-Fish/give. self.ask_log.push(AskEntry { asker: cp, @@ -613,6 +650,10 @@ impl Game { return Err(GfError::OutOfTurn); } + // Record the validated action for replay. + #[cfg(feature = "history")] + self.pending_turn_actions.push(PlayerAction::Draw); + // Save and clear last_asked_rank atomically so early-exit paths never // leave a stale value while the match check below still uses it. let asked_rank = self.last_asked_rank.take(); @@ -850,6 +891,11 @@ impl Game { player: self.pending_turn_player, events: std::mem::take(&mut self.pending_turn_events), books_after_turn: books, + actions: if self.pending_turn_actions.is_empty() { + None + } else { + Some(std::mem::take(&mut self.pending_turn_actions)) + }, }); } diff --git a/src/history/audit.rs b/src/history/audit.rs new file mode 100644 index 0000000..98ed7fd --- /dev/null +++ b/src/history/audit.rs @@ -0,0 +1,427 @@ +//! Structural audit of [`GameRecord`] and [`GameCollection`]. + +use serde::{Deserialize, Serialize}; + +use super::record::{GameCollection, GameRecord}; + +/// The result of a structural audit of a single [`GameRecord`]. +/// +/// Produced by [`GameRecord::audit`] and collected by [`GameCollection::audit_all`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuditResult { + /// The game ID from the audited record. + pub game_id: String, + /// `true` iff no violations were found. + pub is_consistent: bool, + /// Book counts per player as of the last recorded turn (empty if no turns). + pub final_books: Vec, + /// Human-readable violation descriptions. Empty when `is_consistent`. + pub violations: Vec, +} + +impl GameRecord { + /// Validates structural invariants of this record. + /// + /// Infallible — violations accumulate in [`AuditResult::violations`] + /// rather than returning an error. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameRecord; + /// + /// let record = GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()]); + /// let result = record.audit(); + /// assert!(result.is_consistent); + /// assert!(result.violations.is_empty()); + /// ``` + #[must_use] + pub fn audit(&self) -> AuditResult { + let mut violations: Vec = Vec::new(); + let player_count = self.players.len(); + + // Check 1: at least 2 players. + if player_count < 2 { + violations.push(format!( + "player count is {player_count}; must be at least 2" + )); + } + + for (i, turn) in self.turns.iter().enumerate() { + // Check 2: turn player index in range. + if turn.player >= player_count { + violations.push(format!( + "turn {i}: player index {} out of range (players: {player_count})", + turn.player + )); + } + + // Check 3: books_after_turn length matches player count. + if turn.books_after_turn.len() != player_count { + violations.push(format!( + "turn {i}: books_after_turn length {} != player count {player_count}", + turn.books_after_turn.len() + )); + } + + // Check 4: events must not be empty. + if turn.events.is_empty() { + violations.push(format!("turn {i}: events list is empty")); + } + } + + // Check 5: book counts non-decreasing per player. + for (i, turn) in self.turns.iter().enumerate().skip(1) { + let prev = &self.turns[i - 1].books_after_turn; + let curr = &turn.books_after_turn; + if prev.len() == player_count && curr.len() == player_count { + for p in 0..player_count { + if curr[p] < prev[p] { + violations.push(format!( + "player {p} book count decreased from {} to {} at turn {i}", + prev[p], curr[p] + )); + } + } + } + } + + // Check 6: total books <= 13 (Standard52 deck maximum). + if let Some(last) = self.turns.last() { + let total: usize = last.books_after_turn.iter().sum(); + if total > 13 { + violations.push(format!( + "total books {total} exceeds maximum of 13 (Standard52 deck yields at most 13 books)" + )); + } + } + + // Check 7: winner consistent with final book counts. + if let Some(last) = self.turns.last() { + let books = &last.books_after_turn; + if books.len() == player_count && player_count >= 2 { + let max_books = *books.iter().max().unwrap_or(&0); + let leaders: Vec = (0..player_count) + .filter(|&p| books[p] == max_books) + .collect(); + let unique_leader = leaders.len() == 1; + + match self.winner { + Some(w) => { + if w >= player_count { + violations.push(format!( + "winner index {w} is out of range (players: {player_count})" + )); + } else if !unique_leader { + violations.push(format!( + "winner declared as player {w} but final books are tied (books: {books:?})" + )); + } else if leaders[0] != w { + violations.push(format!( + "winner declared as player {w} but player {} has more books (books: {books:?})", + leaders[0] + )); + } + } + None => { + if unique_leader && max_books > 0 { + violations.push(format!( + "winner is None but player {} has unique max book count of \ + {max_books} (books: {books:?})", + leaders[0] + )); + } + } + } + } + } + + let final_books = self + .turns + .last() + .map(|t| t.books_after_turn.clone()) + .unwrap_or_default(); + + AuditResult { + game_id: self.id.clone(), + is_consistent: violations.is_empty(), + final_books, + violations, + } + } +} + +impl GameCollection { + /// Audits every game in this collection and returns one [`AuditResult`] per game. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::{GameCollection, GameRecord}; + /// + /// let mut col = GameCollection::new(); + /// col.push(GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()])); + /// let results = col.audit_all(); + /// assert_eq!(results.len(), 1); + /// assert!(results[0].is_consistent); + /// ``` + pub fn audit_all(&self) -> Vec { + self.games.iter().map(GameRecord::audit).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::GameEvent; + use crate::history::record::{GameCollection, TurnRecord}; + + fn make_clean_record(player_count: usize, turn_count: usize) -> GameRecord { + let players: Vec = (0..player_count).map(|i| format!("P{i}")).collect(); + let mut r = GameRecord::new("Standard", players); + for t in 0..turn_count { + r.turns.push(TurnRecord { + player: t % player_count, + events: vec![GameEvent::Drew { + player: t % player_count, + matched: false, + }], + books_after_turn: vec![0; player_count], + actions: None, + }); + } + r + } + + #[test] + fn test_audit_clean_record_is_consistent() { + let record = make_clean_record(2, 3); + let result = record.audit(); + assert!(result.is_consistent); + assert!(result.violations.is_empty()); + } + + #[test] + fn test_audit_empty_record_no_turns_is_consistent() { + let record = GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()]); + let result = record.audit(); + assert!(result.is_consistent); + assert!(result.violations.is_empty()); + assert!(result.final_books.is_empty()); + } + + #[test] + fn test_audit_single_player_is_violation() { + let record = GameRecord::new("Standard", vec!["Solo".to_string()]); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("player"))); + } + + #[test] + fn test_audit_turn_player_out_of_range() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 5, + events: vec![GameEvent::Drew { + player: 5, + matched: false, + }], + books_after_turn: vec![0, 0], + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_books_after_turn_wrong_length() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![0], // should be length 2 + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_empty_events_in_turn() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("turn 0"))); + } + + #[test] + fn test_audit_book_counts_decreasing_is_violation() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![2, 0], + actions: None, + }); + record.turns.push(TurnRecord { + player: 1, + events: vec![GameEvent::Drew { + player: 1, + matched: false, + }], + books_after_turn: vec![1, 0], // player 0 decreased + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("decreas"))); + } + + #[test] + fn test_audit_total_books_exceeds_13() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![10, 5], // 15 > 13 + actions: None, + }); + let result = record.audit(); + assert!(!result.is_consistent); + // The violation message includes the actual total (15). + assert!(result.violations.iter().any(|v| v.contains("15"))); + } + + #[test] + fn test_audit_winner_some_but_another_has_more_books() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![3, 5], + actions: None, + }); + record.winner = Some(0); // player 1 has more books + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_some_but_tied() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![5, 5], + actions: None, + }); + record.winner = Some(0); // tied — no unique winner + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_none_but_clear_leader() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![5, 3], + actions: None, + }); + record.winner = None; // player 0 has unique max — must be declared winner + let result = record.audit(); + assert!(!result.is_consistent); + assert!(result.violations.iter().any(|v| v.contains("winner"))); + } + + #[test] + fn test_audit_winner_correct_unique_max() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![5, 3], + actions: None, + }); + record.winner = Some(0); + let result = record.audit(); + assert!(result.is_consistent, "violations: {:?}", result.violations); + } + + #[test] + fn test_audit_winner_none_correct_when_tied() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![4, 4], + actions: None, + }); + record.winner = None; + let result = record.audit(); + assert!(result.is_consistent, "violations: {:?}", result.violations); + } + + #[test] + fn test_audit_final_books_populated_from_last_turn() { + let mut record = make_clean_record(2, 0); + record.turns.push(TurnRecord { + player: 0, + events: vec![GameEvent::Drew { + player: 0, + matched: false, + }], + books_after_turn: vec![2, 3], + actions: None, + }); + record.winner = Some(1); + let result = record.audit(); + assert_eq!(result.final_books, vec![2, 3]); + } + + #[test] + fn test_audit_all_consistent_collection() { + let mut col = GameCollection::new(); + col.push(make_clean_record(2, 3)); + col.push(make_clean_record(3, 4)); + let results = col.audit_all(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.is_consistent)); + } +} diff --git a/src/history/mod.rs b/src/history/mod.rs index d8c9904..1af7266 100644 --- a/src/history/mod.rs +++ b/src/history/mod.rs @@ -4,9 +4,13 @@ //! //! # Overview //! -//! - [`TurnRecord`] — one player's turn: events emitted and book counts after. +//! - [`TurnRecord`] — one player's turn: events emitted, book counts, and +//! optionally the actions taken (enables replay). //! - [`GameRecord`] — full game record with UUID, timestamp, players, turns, winner. -//! - [`GameCollection`] — ordered list of [`GameRecord`]s with round-trip serialization. +//! - [`GameCollection`] — versioned, ordered list of [`GameRecord`]s with +//! round-trip serialization and file-save support. +//! - [`AuditResult`] — structural invariant check result from [`GameRecord::audit`]. +//! - [`ReplayResult`] — engine-replay check result from [`GameRecord::replay`]. //! //! # Examples //! @@ -21,8 +25,15 @@ //! let mut col = GameCollection::new(); //! col.push(record); //! assert_eq!(col.len(), 1); +//! +//! let results = col.audit_all(); +//! assert!(results[0].is_consistent); //! ``` +pub mod audit; pub mod record; +pub mod replay; -pub use record::{GameCollection, GameRecord, TurnRecord}; +pub use audit::AuditResult; +pub use record::{FORMAT_VERSION, GameCollection, GameRecord, TurnRecord}; +pub use replay::ReplayResult; diff --git a/src/history/record.rs b/src/history/record.rs index 0c44acc..06e6da3 100644 --- a/src/history/record.rs +++ b/src/history/record.rs @@ -4,13 +4,25 @@ //! call [`GameRecord::to_yaml`] or [`GameRecord::to_json`] to persist it. //! Load it back with [`GameRecord::from_yaml`] or [`GameRecord::from_json`]. +use cardpack::prelude::BasicPile; use serde::{Deserialize, Serialize}; #[cfg(not(target_arch = "wasm32"))] use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; use crate::error::GfError; -use crate::game::GameEvent; +use crate::game::{GameEvent, PlayerAction}; + +/// The serialization format version written into every new [`GameCollection`]. +pub const FORMAT_VERSION: u32 = 1; + +fn default_gfcore_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +fn default_format_version() -> u32 { + FORMAT_VERSION +} // --------------------------------------------------------------------------- // TurnRecord @@ -28,9 +40,11 @@ use crate::game::GameEvent; /// player: 0, /// events: vec![], /// books_after_turn: vec![0, 0], +/// actions: None, /// }; /// assert_eq!(turn.player, 0); /// assert!(turn.events.is_empty()); +/// assert!(turn.actions.is_none()); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TurnRecord { @@ -41,6 +55,13 @@ pub struct TurnRecord { /// Book counts per player after this turn completes. /// Index matches the player index. pub books_after_turn: Vec, + /// Actions submitted by the player during this turn, in order. + /// + /// `None` if this record was created without action recording (e.g., WASM + /// games or records pre-dating this feature). `Some(...)` enables + /// [`GameRecord::replay`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, } // --------------------------------------------------------------------------- @@ -82,6 +103,10 @@ pub struct GameRecord { pub turns: Vec, /// Index of the winning player once the game is over, or `None` for a tie. pub winner: Option, + /// The full draw pile before the initial deal, enabling deterministic replay. + /// `None` for records created without the replay path (e.g. WASM, old unit tests). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_draw_pile: Option, } impl GameRecord { @@ -115,6 +140,7 @@ impl GameRecord { players, turns: Vec::new(), winner: None, + initial_draw_pile: None, } } @@ -205,37 +231,54 @@ impl GameRecord { // GameCollection // --------------------------------------------------------------------------- -/// An ordered collection of [`GameRecord`]s. +/// An ordered, versioned collection of [`GameRecord`]s. /// -/// Serializes as a YAML/JSON sequence of records, not a wrapped object. +/// Serializes as a YAML/JSON object with `gfcore_version`, `format_version`, +/// and `games` keys. /// /// # Examples /// /// ``` -/// use gfcore::history::{GameCollection, GameRecord}; +/// use gfcore::history::{GameCollection, GameRecord, FORMAT_VERSION}; /// /// let mut col = GameCollection::new(); /// assert!(col.is_empty()); +/// assert_eq!(col.format_version, FORMAT_VERSION); /// col.push(GameRecord::new("Standard", vec!["Alice".to_string()])); /// assert_eq!(col.len(), 1); /// ``` -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct GameCollection(Vec); +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GameCollection { + /// The `gfcore` crate version that created this collection (baked in at compile time). + #[serde(default = "default_gfcore_version")] + pub gfcore_version: String, + /// The serialization format version. Always [`FORMAT_VERSION`] for newly created collections. + #[serde(default = "default_format_version")] + pub format_version: u32, + /// The game records in this collection, in insertion order. + pub games: Vec, +} impl GameCollection { - /// Creates an empty [`GameCollection`]. + /// Creates an empty [`GameCollection`] with the current crate version and + /// [`FORMAT_VERSION`] set. /// /// # Examples /// /// ``` - /// use gfcore::history::GameCollection; + /// use gfcore::history::{GameCollection, FORMAT_VERSION}; /// /// let col = GameCollection::new(); /// assert!(col.is_empty()); + /// assert_eq!(col.format_version, FORMAT_VERSION); /// ``` #[must_use] pub fn new() -> Self { - Self(Vec::new()) + Self { + gfcore_version: env!("CARGO_PKG_VERSION").to_string(), + format_version: FORMAT_VERSION, + games: Vec::new(), + } } /// Appends a [`GameRecord`] to the collection. @@ -250,7 +293,7 @@ impl GameCollection { /// assert_eq!(col.len(), 1); /// ``` pub fn push(&mut self, record: GameRecord) { - self.0.push(record); + self.games.push(record); } /// Returns the number of records in the collection. @@ -265,7 +308,7 @@ impl GameCollection { /// ``` #[must_use] pub fn len(&self) -> usize { - self.0.len() + self.games.len() } /// Returns `true` if the collection contains no records. @@ -280,7 +323,7 @@ impl GameCollection { /// ``` #[must_use] pub fn is_empty(&self) -> bool { - self.0.is_empty() + self.games.is_empty() } /// Returns an iterator over the records in this collection. @@ -295,7 +338,7 @@ impl GameCollection { /// assert_eq!(col.iter().count(), 1); /// ``` pub fn iter(&self) -> impl Iterator { - self.0.iter() + self.games.iter() } /// Serializes this collection to a YAML string. @@ -381,13 +424,79 @@ impl GameCollection { pub fn from_json(s: &str) -> Result { serde_json::from_str(s).map_err(GfError::from) } + + /// Writes this collection to `generated/_.yaml`. + /// + /// The `generated/` directory is relative to the process's current working + /// directory and is created automatically if it does not already exist. + /// Returns the path written on success. + /// + /// # Errors + /// + /// - [`GfError::IoError`] — directory creation or file write failed. + /// - [`GfError::ParseError`] — YAML serialization failed. + /// + /// # Examples + /// + /// ```no_run + /// use gfcore::history::GameCollection; + /// + /// let col = GameCollection::new(); + /// let path = col.save("my_session").expect("save must succeed"); + /// assert!(path.contains("my_session")); + /// ``` + #[cfg(not(target_arch = "wasm32"))] + pub fn save(&self, run_name: &str) -> Result { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let path = format!("generated/{run_name}_{ts}.yaml"); + self.save_to(&path) + } + + /// Writes this collection to `path`, creating parent directories as needed. + /// + /// Returns `path` as a `String` on success. + /// + /// # Errors + /// + /// - [`GfError::IoError`] — directory creation or file write failed. + /// - [`GfError::ParseError`] — YAML serialization failed. + /// + /// # Examples + /// + /// ```no_run + /// use gfcore::history::GameCollection; + /// + /// let col = GameCollection::new(); + /// let path = col.save_to("/tmp/test_collection.yaml").expect("save must succeed"); + /// assert_eq!(path, "/tmp/test_collection.yaml"); + /// ``` + #[cfg(not(target_arch = "wasm32"))] + pub fn save_to(&self, path: &str) -> Result { + let yaml = self.to_yaml()?; + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| GfError::IoError(e.to_string()))?; + } + } + std::fs::write(path, &yaml).map_err(|e| GfError::IoError(e.to_string()))?; + Ok(path.to_string()) + } +} + +impl Default for GameCollection { + fn default() -> Self { + Self::new() + } } impl std::ops::Index for GameCollection { type Output = GameRecord; fn index(&self, idx: usize) -> &Self::Output { - &self.0[idx] + &self.games[idx] } } @@ -462,6 +571,7 @@ mod tests { }, ], books_after_turn: vec![0, 0], + actions: None, }; r.turns.push(turn); r.winner = Some(0); @@ -530,4 +640,85 @@ mod tests { let back = GameCollection::from_yaml(&yaml).unwrap(); assert_eq!(col, back); } + + #[test] + fn test_turn_record_actions_default_is_none() { + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }; + assert!(turn.actions.is_none()); + } + + #[test] + fn test_turn_record_with_actions_yaml_round_trip() { + use crate::game::PlayerAction; + use cardpack::prelude::{DeckedBase, Standard52}; + let rank = Standard52::basic_pile().v()[0].rank; + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: Some(vec![ + PlayerAction::Ask { target: 1, rank }, + PlayerAction::Draw, + ]), + }; + let yaml = serde_norway::to_string(&turn).unwrap(); + let back: TurnRecord = serde_norway::from_str(&yaml).unwrap(); + assert_eq!(turn, back); + } + + #[test] + fn test_turn_record_none_actions_omitted_from_yaml() { + let turn = TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }; + let yaml = serde_norway::to_string(&turn).unwrap(); + assert!(!yaml.contains("actions")); + } + + #[test] + fn test_game_collection_has_format_version() { + let col = GameCollection::new(); + assert_eq!(col.format_version, FORMAT_VERSION); + } + + #[test] + fn test_game_collection_has_gfcore_version() { + let col = GameCollection::new(); + assert!(!col.gfcore_version.is_empty()); + } + + #[test] + fn test_game_collection_yaml_contains_version_fields() { + let col = GameCollection::new(); + let yaml = col.to_yaml().unwrap(); + assert!(yaml.contains("format_version")); + assert!(yaml.contains("gfcore_version")); + assert!(yaml.contains("games")); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn test_game_collection_save_to_temp_dir() { + let mut col = GameCollection::new(); + col.push(make_record()); + let path = std::env::temp_dir() + .join("gfcore_test_save_to.yaml") + .to_string_lossy() + .to_string(); + let result = col.save_to(&path); + assert!(result.is_ok(), "save_to failed: {:?}", result); + assert!(std::path::Path::new(&path).exists()); + let yaml = std::fs::read_to_string(&path).unwrap(); + let loaded = GameCollection::from_yaml(&yaml).unwrap(); + assert_eq!(col, loaded); + let _ = std::fs::remove_file(&path); + } } diff --git a/src/history/replay.rs b/src/history/replay.rs new file mode 100644 index 0000000..6b5ceb0 --- /dev/null +++ b/src/history/replay.rs @@ -0,0 +1,277 @@ +//! Engine-replay verification for [`GameRecord`] and [`GameCollection`]. +//! +//! Provides [`ReplayResult`] and the [`GameRecord::replay`] and +//! [`GameCollection::replay_all`] methods. Both re-run stored +//! [`crate::history::TurnRecord`] actions through a fresh +//! [`crate::game::Game`] engine instance and compare the replayed turn state +//! to the stored data. + +use crate::error::GfError; +use crate::game::Game; +use crate::player::Player; +use crate::rules::GameVariant; + +use super::record::{GameCollection, GameRecord}; + +/// Maps a stored variant-name string back to a [`GameVariant`]. +/// +/// The stored string is produced by `variant.rules().name()` at game creation +/// and saved into [`GameRecord::variant`]. +fn parse_variant(name: &str) -> Result { + match name { + "Standard Go Fish" => Ok(GameVariant::Standard), + "Happy Families" => Ok(GameVariant::HappyFamilies), + "Quartet" => Ok(GameVariant::Quartet), + other => Err(GfError::ParseError(format!( + "unknown game variant: {other:?}" + ))), + } +} + +/// The result of replaying a [`GameRecord`] through a fresh engine instance. +/// +/// Produced by [`GameRecord::replay`] and collected by +/// [`GameCollection::replay_all`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplayResult { + /// The game ID from the replayed record. + pub game_id: String, + /// `true` iff every turn's replayed book counts match the stored counts + /// and the final winner matches. + pub is_consistent: bool, + /// Book counts per player as of the last replayed turn. + pub final_books: Vec, + /// Index of the first turn where replayed state diverged, or `None`. + pub mismatch_at_turn: Option, +} + +impl GameRecord { + /// Re-runs stored actions through a fresh [`crate::game::Game`] engine + /// and compares results to stored turn data. + /// + /// # Errors + /// + /// - [`GfError::NoReplayData`] — at least one turn has `actions: None`, or + /// the record has no `initial_draw_pile` (recorded without the replay path). + /// - [`GfError::ParseError`] — the variant name is not recognised. + /// - Any engine error propagated from [`crate::game::Game::act`]. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameRecord; + /// + /// // A record with no turns is trivially consistent. + /// let record = GameRecord::new( + /// "Standard Go Fish", + /// vec!["Alice".to_string(), "Bob".to_string()], + /// ); + /// let result = record.replay().expect("empty record replay must succeed"); + /// assert!(result.is_consistent); + /// ``` + pub fn replay(&self) -> Result { + // A record with no turns is trivially consistent with no engine needed. + if self.turns.is_empty() { + return Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent: true, + final_books: vec![], + mismatch_at_turn: None, + }); + } + + // Collect all action lists upfront; returns Err if any turn has actions: None. + let all_actions = self + .turns + .iter() + .map(|turn| turn.actions.clone().ok_or(GfError::NoReplayData)) + .collect::, _>>()?; + + let draw_pile = self + .initial_draw_pile + .clone() + .ok_or(GfError::NoReplayData)?; + + let variant = parse_variant(&self.variant)?; + let players: Vec = self + .players + .iter() + .map(|name| Player::new(name.clone())) + .collect(); + let mut game = Game::new_with_deck(variant, players, draw_pile)?; + + // Replay all actions. Engine errors (e.g. InvalidAsk from data corruption) propagate. + for (_, actions) in self.turns.iter().zip(all_actions) { + for action in actions { + game.act(action)?; + } + } + + // Single snapshot after replay is complete — avoids O(n²) cloning. + let final_record = game.record(); + + // Compare per-turn book counts; report the first divergence. + for (i, (stored, replayed)) in self.turns.iter().zip(final_record.turns.iter()).enumerate() + { + if replayed.books_after_turn != stored.books_after_turn { + return Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent: false, + final_books: replayed.books_after_turn.clone(), + mismatch_at_turn: Some(i), + }); + } + } + + // Structural mismatch: engine produced a different number of turns. + if final_record.turns.len() != self.turns.len() { + let mismatch_at_turn = self.turns.len().min(final_record.turns.len()); + let final_books = final_record + .turns + .last() + .map(|t| t.books_after_turn.clone()) + .unwrap_or_default(); + return Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent: false, + final_books, + mismatch_at_turn: Some(mismatch_at_turn), + }); + } + + let is_consistent = final_record.winner == self.winner; + let final_books = final_record + .turns + .last() + .map(|t| t.books_after_turn.clone()) + .unwrap_or_default(); + + Ok(ReplayResult { + game_id: self.id.clone(), + is_consistent, + final_books, + mismatch_at_turn: None, + }) + } +} + +impl GameCollection { + /// Replays every game in this collection, returning one `Result` per game. + /// + /// Individual failures do not abort the batch. + /// + /// # Examples + /// + /// ``` + /// use gfcore::history::GameCollection; + /// + /// let col = GameCollection::new(); + /// let results = col.replay_all(); + /// assert!(results.is_empty()); + /// ``` + pub fn replay_all(&self) -> Vec> { + self.games.iter().map(GameRecord::replay).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bot::BotProfile; + use crate::error::GfError; + use crate::game::{Game, GamePhase, PlayerAction}; + use crate::history::record::{GameCollection, GameRecord, TurnRecord}; + use crate::player::Player; + use crate::rules::GameVariant; + + /// Plays a two-player bot game of Standard Go Fish to completion. + fn play_to_completion() -> GameRecord { + let profiles = [BotProfile::basic("Alice"), BotProfile::basic("Bob")]; + let players = vec![ + Player::new("Alice".to_string()), + Player::new("Bob".to_string()), + ]; + let mut game = Game::new(GameVariant::Standard, players).unwrap(); + + while !game.is_over() { + let state = game.state().unwrap(); + let cp = state.current_player; + let action = match state.phase { + GamePhase::WaitingForDraw => PlayerAction::Draw, + GamePhase::GameOver => break, + _ => { + let hand = state + .players + .iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + profiles[cp % profiles.len()].decide(&hand, &state.players, &state.ask_log) + } + }; + game.act(action).unwrap(); + } + + game.record() + } + + #[test] + fn test_replay_no_actions_returns_error() { + let mut record = GameRecord::new( + "Standard Go Fish", + vec!["Alice".to_string(), "Bob".to_string()], + ); + record.turns.push(TurnRecord { + player: 0, + events: vec![], + books_after_turn: vec![0, 0], + actions: None, + }); + let result = record.replay(); + assert!( + matches!(result, Err(GfError::NoReplayData)), + "expected Err(NoReplayData), got {result:?}" + ); + } + + #[test] + fn test_replay_consistent_game() { + let record = play_to_completion(); + + assert!( + record.turns.iter().all(|t| t.actions.is_some()), + "all turns must have stored actions" + ); + assert!( + record.initial_draw_pile.is_some(), + "record must have initial_draw_pile for replay" + ); + + let result = record + .replay() + .expect("replay of a completed game must succeed"); + assert!( + result.is_consistent, + "replay must be consistent; mismatch at turn: {:?}", + result.mismatch_at_turn + ); + assert!(result.mismatch_at_turn.is_none()); + } + + #[test] + fn test_replay_all_returns_vec() { + let record = play_to_completion(); + let mut col = GameCollection::new(); + col.push(record); + + let results = col.replay_all(); + assert_eq!(results.len(), 1); + let result = results[0].as_ref().expect("replay must succeed"); + assert!( + result.is_consistent, + "replayed game must be consistent; mismatch at turn: {:?}", + result.mismatch_at_turn + ); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index c39d214..37e3174 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -17,4 +17,4 @@ pub use crate::rules::{ }; #[cfg(feature = "history")] -pub use crate::history::{GameCollection, GameRecord, TurnRecord}; +pub use crate::history::{AuditResult, GameCollection, GameRecord, ReplayResult, TurnRecord}; diff --git a/tests/bot_marathon.rs b/tests/bot_marathon.rs new file mode 100644 index 0000000..1d3dce6 --- /dev/null +++ b/tests/bot_marathon.rs @@ -0,0 +1,219 @@ +//! Integration test: 100-game bot marathon with all 4 standard bot profiles. +#![allow(non_snake_case)] +//! +//! Plays 100 games of Standard Go Fish with 4 bot players (2 `BasicStrategy`, +//! 2 `RandomStrategy`). After every game the test: +//! - asserts book conservation (total books ≤ 13 for a Standard52 deck) +//! - validates the winner field is consistent with player count +//! - serializes the `GameRecord` to YAML, deserializes it, and asserts +//! round-trip equality +//! +//! The built-in game-over check and deadlock detection in [`Game::act`] are +//! exercised automatically on every game. +//! +//! On any error the full collection is serialized to a YAML file and the path +//! is included in the panic message for offline debugging. +//! +//! Run with: +//! ```text +//! cargo test --test bot_marathon -- --include-ignored --nocapture +//! ``` + +#![cfg(feature = "history")] + +use gfcore::bot::BotProfile; +use gfcore::history::{GameCollection, GameRecord}; +use gfcore::prelude::{Game, GamePhase, GameVariant, Player, PlayerAction}; + +const NUM_GAMES: usize = 100; +const PROGRESS_INTERVAL: usize = 10; +const MAX_STEPS_PER_GAME: usize = 13_000; +const MAX_BOOKS_STANDARD: usize = 13; + +/// Serializes the full game collection to YAML, writes it to a file, and +/// panics with a summary message. +/// +/// The output path is controlled by the `MARATHON_DUMP_PATH` environment +/// variable (default: `marathon_failure.yaml`), letting CI point an artifact +/// upload step at the right location. +/// +/// Returns `!` so it can be used in `unwrap_or_else` closures. +fn dump_and_panic(game_num: usize, context: &str, msg: String, collection: &GameCollection) -> ! { + let yaml = collection + .to_yaml() + .unwrap_or_else(|e| format!("(YAML serialization also failed: {e})")); + let path = + std::env::var("MARATHON_DUMP_PATH").unwrap_or_else(|_| "marathon_failure.yaml".to_string()); + let _ = std::fs::write(&path, &yaml); + panic!( + "bot_marathon FAILED at game {game_num} [{context}]: {msg}\n\ + (YAML written to {path} — download the CI artifact if the log is truncated)" + ); +} + +/// Plays one Standard Go Fish game to completion using the given bot profiles. +/// +/// Each player is assigned to `profiles[player_index % profiles.len()]`. +/// Returns the completed `GameRecord` from `Game::record()`, or calls +/// [`dump_and_panic`] if the game errors or fails to terminate. +fn play_one_game( + game_num: usize, + profiles: &[BotProfile], + collection: &GameCollection, +) -> GameRecord { + let players: Vec = profiles + .iter() + .map(|p| Player::new(p.name.clone())) + .collect(); + + let mut game = match Game::new(GameVariant::Standard, players) { + Ok(g) => g, + Err(e) => dump_and_panic(game_num, "Game::new", e.to_string(), collection), + }; + + for _ in 0..MAX_STEPS_PER_GAME { + if game.is_over() { + break; + } + + let state = match game.state() { + Ok(s) => s, + Err(e) => dump_and_panic(game_num, "state", e.to_string(), collection), + }; + + let action = match state.phase { + GamePhase::WaitingForAsk | GamePhase::BookCompleted => { + let cp = state.current_player; + let hand = state + .players + .iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + profiles[cp % profiles.len()].decide(&hand, &state.players, &state.ask_log) + } + GamePhase::WaitingForDraw => PlayerAction::Draw, + GamePhase::GameOver => break, + }; + + if let Err(e) = game.act(action) { + dump_and_panic(game_num, "act", e.to_string(), collection); + } + } + + if !game.is_over() { + dump_and_panic( + game_num, + "termination", + format!("game did not terminate within {MAX_STEPS_PER_GAME} steps"), + collection, + ); + } + + game.record() +} + +/// Validates the most recently pushed game in `collection`. +/// +/// Checks: +/// - book conservation (total books ≤ `MAX_BOOKS_STANDARD`) +/// - winner index in range +/// - YAML round-trip equality +/// +/// On any failure calls [`dump_and_panic`] with the full collection. +fn validate_last_game(game_num: usize, collection: &GameCollection) { + let record = match collection.iter().last() { + Some(r) => r, + None => dump_and_panic( + game_num, + "validate", + "collection is empty".to_string(), + collection, + ), + }; + + // Book conservation: a Standard52 deck yields at most 13 books. + let total_books: usize = record + .turns + .last() + .map(|t| t.books_after_turn.iter().sum()) + .unwrap_or(0); + + if total_books > MAX_BOOKS_STANDARD { + dump_and_panic( + game_num, + "book_conservation", + format!("total books {total_books} exceeds maximum of {MAX_BOOKS_STANDARD}"), + collection, + ); + } + + // Winner index must be a valid player index when set. + if let Some(winner) = record.winner { + if winner >= record.players.len() { + dump_and_panic( + game_num, + "winner_range", + format!( + "winner index {winner} out of range (players: {})", + record.players.len() + ), + collection, + ); + } + } + + // Structural audit: book conservation, winner consistency, turn integrity. + let audit = record.audit(); + if !audit.is_consistent { + dump_and_panic( + game_num, + "audit", + format!("audit violations: {:?}", audit.violations), + collection, + ); + } + + // YAML round-trip must preserve all fields exactly. + let yaml = record + .to_yaml() + .unwrap_or_else(|e| dump_and_panic(game_num, "to_yaml", e.to_string(), collection)); + let parsed = GameRecord::from_yaml(&yaml) + .unwrap_or_else(|e| dump_and_panic(game_num, "from_yaml", e.to_string(), collection)); + if record != &parsed { + dump_and_panic( + game_num, + "round_trip", + "YAML round-trip produced unequal record".to_string(), + collection, + ); + } +} + +/// Plays 100 games of Standard Go Fish with all 4 default bot profiles, +/// validating book conservation and YAML round-trip after every game. +/// +/// Run with: +/// +/// ```text +/// cargo test --test bot_marathon -- --include-ignored --nocapture +/// ``` +#[test] +#[ignore = "marathon: 100 games with 4 bot profiles; use --include-ignored"] +fn bot_marathon__100_games_without_error() { + let profiles = BotProfile::default_profiles(); + let mut collection = GameCollection::new(); + + for game_num in 1..=NUM_GAMES { + let record = play_one_game(game_num, &profiles, &collection); + collection.push(record); + validate_last_game(game_num, &collection); + + if game_num % PROGRESS_INTERVAL == 0 { + println!("bot_marathon: {game_num}/{NUM_GAMES} games complete"); + } + } + + println!("bot_marathon: complete — {NUM_GAMES} games played without error"); +} diff --git a/tests/history_integration.rs b/tests/history_integration.rs index 1a4b8a8..e1efa0b 100644 --- a/tests/history_integration.rs +++ b/tests/history_integration.rs @@ -10,6 +10,7 @@ #![cfg(feature = "history")] +use gfcore::bot::BotProfile; use gfcore::history::{GameCollection, GameRecord, TurnRecord}; use gfcore::prelude::{Game, GameEvent, GamePhase, GameVariant, Player, PlayerAction}; @@ -134,6 +135,7 @@ fn play_and_record() -> GameRecord { player: current_turn_player, events: std::mem::take(&mut current_turn_events), books_after_turn, + actions: None, }); // Start fresh for the next turn. @@ -159,6 +161,7 @@ fn play_and_record() -> GameRecord { player: current_turn_player, events: current_turn_events, books_after_turn, + actions: None, }); } @@ -363,3 +366,95 @@ fn test_game_collection_multiple_records() { assert_eq!(back[0], r1); assert_eq!(back[1], r2); } + +// --------------------------------------------------------------------------- +// Helper: play using Game::act() so actions are auto-recorded +// --------------------------------------------------------------------------- + +/// Plays a Standard Go Fish game to completion using two `BotProfile` bots. +/// +/// Returns `game.record()`, which has `TurnRecord::actions` populated for +/// every turn (enabling replay) and `initial_draw_pile` set. +fn play_game_with_bot_profiles() -> GameRecord { + let profiles = [BotProfile::basic("Alice"), BotProfile::basic("Bob")]; + let players = vec![ + Player::new("Alice".to_string()), + Player::new("Bob".to_string()), + ]; + let mut game = Game::new(GameVariant::Standard, players).expect("valid 2-player game"); + + for _ in 0..13_000 { + if game.is_over() { + break; + } + let state = game.state().expect("state available"); + let cp = state.current_player; + let action = match state.phase { + GamePhase::WaitingForDraw => PlayerAction::Draw, + GamePhase::GameOver => break, + _ => { + let hand = state + .players + .iter() + .find(|v| v.index == cp) + .and_then(|v| v.hand.as_ref()) + .cloned() + .unwrap_or_default(); + profiles[cp % profiles.len()].decide(&hand, &state.players, &state.ask_log) + } + }; + game.act(action).expect("bot action must not error"); + } + + assert!(game.is_over(), "game must finish within budget"); + game.record() +} + +// --------------------------------------------------------------------------- +// Audit integration tests +// --------------------------------------------------------------------------- + +#[test] +fn test_audit_all_on_played_collection() { + let mut col = GameCollection::new(); + for _ in 0..5 { + col.push(play_game_with_bot_profiles()); + } + + let results = col.audit_all(); + assert_eq!(results.len(), 5, "must have one audit result per game"); + + for (i, result) in results.iter().enumerate() { + assert!( + result.is_consistent, + "game {i} audit failed with violations: {:?}", + result.violations + ); + } +} + +// --------------------------------------------------------------------------- +// Replay integration tests +// --------------------------------------------------------------------------- + +#[test] +fn test_replay_all_on_played_collection() { + let mut col = GameCollection::new(); + for _ in 0..5 { + col.push(play_game_with_bot_profiles()); + } + + let results = col.replay_all(); + assert_eq!(results.len(), 5, "must have one replay result per game"); + + for (i, result) in results.iter().enumerate() { + let result = result + .as_ref() + .unwrap_or_else(|e| panic!("game {i} replay returned Err: {e}")); + assert!( + result.is_consistent, + "game {i} replay diverged at turn {:?}", + result.mismatch_at_turn + ); + } +} From 00bafe338c2dff0fdc65a33ceac0077a440d9132 Mon Sep 17 00:00:00 2001 From: folkengine Date: Sat, 9 May 2026 19:38:02 -0700 Subject: [PATCH 2/3] =?UTF-8?q?1.=20src/history/replay.rs=20=E2=80=94=20re?= =?UTF-8?q?moved=20the=20unused=20use=20super::*;=20import=202.=20tests/hi?= =?UTF-8?q?story=5Fintegration.rs=20=E2=80=94=20play=5Fgame=5Fwith=5Fbot?= =?UTF-8?q?=5Fprofiles()=20now=20uses=20BotProfile::default=5Fprofiles()?= =?UTF-8?q?=20(4=20players,=20mixed=20BasicStrategy=20+=20RandomStrategy),?= =?UTF-8?q?=20matching=20the=20bot=5Fmarathon=20setup=20that's=20prove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/history/replay.rs | 1 - tests/history_integration.rs | 15 ++++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/history/replay.rs b/src/history/replay.rs index 6b5ceb0..13a36d4 100644 --- a/src/history/replay.rs +++ b/src/history/replay.rs @@ -176,7 +176,6 @@ impl GameCollection { #[cfg(test)] mod tests { - use super::*; use crate::bot::BotProfile; use crate::error::GfError; use crate::game::{Game, GamePhase, PlayerAction}; diff --git a/tests/history_integration.rs b/tests/history_integration.rs index e1efa0b..8cd6d1d 100644 --- a/tests/history_integration.rs +++ b/tests/history_integration.rs @@ -371,17 +371,18 @@ fn test_game_collection_multiple_records() { // Helper: play using Game::act() so actions are auto-recorded // --------------------------------------------------------------------------- -/// Plays a Standard Go Fish game to completion using two `BotProfile` bots. +/// Plays a Standard Go Fish game to completion using the default four bot profiles +/// (the same setup used by `bot_marathon`). /// /// Returns `game.record()`, which has `TurnRecord::actions` populated for /// every turn (enabling replay) and `initial_draw_pile` set. fn play_game_with_bot_profiles() -> GameRecord { - let profiles = [BotProfile::basic("Alice"), BotProfile::basic("Bob")]; - let players = vec![ - Player::new("Alice".to_string()), - Player::new("Bob".to_string()), - ]; - let mut game = Game::new(GameVariant::Standard, players).expect("valid 2-player game"); + let profiles = BotProfile::default_profiles(); + let players: Vec = profiles + .iter() + .map(|p| Player::new(p.name.clone())) + .collect(); + let mut game = Game::new(GameVariant::Standard, players).expect("valid multi-player game"); for _ in 0..13_000 { if game.is_over() { From e4e5e86ef47ebece3b4814a727c144f942ae0b31 Mon Sep 17 00:00:00 2001 From: folkengine Date: Sun, 10 May 2026 04:39:05 -0700 Subject: [PATCH 3/3] removed miri from cicd --- .github/workflows/CI.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index c5d701a..94e4b76 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -77,16 +77,16 @@ jobs: - uses: actions/checkout@v6 - uses: EmbarkStudios/cargo-deny-action@v2 - miri: - name: Miri - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@miri - - run: cargo miri test - env: - MIRIFLAGS: -Zmiri-strict-provenance -Zmiri-disable-isolation +# miri: +# name: Miri +# runs-on: ubuntu-latest +# timeout-minutes: 45 +# steps: +# - uses: actions/checkout@v6 +# - uses: dtolnay/rust-toolchain@miri +# - run: cargo miri test +# env: +# MIRIFLAGS: -Zmiri-strict-provenance -Zmiri-disable-isolation wasm: name: Wasm