diff --git a/Cargo.lock b/Cargo.lock index 74dd694..25a1350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "card_game" version = "0.4.0" -source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3" +source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818" dependencies = [ "arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)", "serde", @@ -4600,10 +4600,11 @@ dependencies = [ [[package]] name = "klondike" version = "0.3.0" -source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3" +source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818" dependencies = [ "card_game", "rand 0.10.1", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2ac9f0d..e25c800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,8 @@ solitaire_core = { path = "solitaire_core" } solitaire_sync = { path = "solitaire_sync" } solitaire_data = { path = "solitaire_data" } solitaire_engine = { path = "solitaire_engine" } -klondike = { git = "https://git.aleshym.co/Quaternions/card_game" } -card_game = { git = "https://git.aleshym.co/Quaternions/card_game", features = ["serde"] } +klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] } +card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] } # Bevy with `default-features = false` to avoid the unused # `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 3d7c749..d6fc348 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -295,6 +295,11 @@ impl GameState { } fn replay_config(draw_mode: DrawMode) -> KlondikeConfig { + // Always allow foundation returns during replay, regardless of the + // player's current `take_from_foundation` setting. A move recorded + // when the rule was enabled must replay correctly even if the player + // later disables it; a restrictive replay config would reject it and + // corrupt the save. KlondikeAdapter::config_for(draw_mode, true) } @@ -362,6 +367,11 @@ impl GameState { Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true) } + /// Returns the cards in the requested pile. + /// + /// **Note on `KlondikePile::Stock`:** this variant returns the face-up + /// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`] + /// to read the face-down draw cards. pub fn pile(&self, pile: KlondikePile) -> Vec { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state { diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 353496f..05941e2 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -6,8 +6,13 @@ pub mod klondike_adapter; pub mod pile; pub mod solver; -// Re-export upstream types that cross the solitaire_core API boundary so -// callers can import from one place without a direct `klondike` / `card_game` dep. +// Re-export the upstream types that cross the solitaire_core API boundary so +// downstream crates (engine, wasm) can import from one place without a direct +// `klondike` / `card_game` dep. +// +// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT +// re-exported — they are only used internally in `klondike_adapter.rs` and do +// not appear in any public method signature. pub use card_game::Session; pub use klondike::{Foundation, Klondike, KlondikePile, Tableau}; diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index 31c9197..c798db0 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -1,7 +1,12 @@ -use klondike::{Foundation, KlondikePile, Tableau}; +use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; use proptest::prelude::*; use crate::game_state::{DrawMode, GameState}; +use crate::klondike_adapter::{ + InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation, + SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau, + SavedTableauStack, +}; // --------------------------------------------------------------------------- // Shared helpers @@ -221,4 +226,117 @@ proptest! { ); } } + + // ------------------------------------------------------------------------- + // SavedInstruction ↔ KlondikeInstruction round-trip + // ------------------------------------------------------------------------- + + /// Every valid `SavedInstruction` survives a round-trip through + /// `KlondikeInstruction::try_from(SavedInstruction::from(original))`. + /// + /// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`) + /// and all legal sub-field ranges: + /// - `SavedTableau`: 0–6 + /// - `SavedFoundation`: 0–3 + /// - `SavedSkipCards`: 0–12 + #[test] + fn saved_instruction_round_trip( + instruction in saved_instruction_strategy(), + ) { + let klondike = KlondikeInstruction::try_from(instruction); + prop_assert!( + klondike.is_ok(), + "TryFrom failed for valid SavedInstruction {instruction:?}: {:?}", + klondike.err(), + ); + let saved_again = SavedInstruction::from(klondike.expect("checked above")); + prop_assert_eq!( + saved_again, + instruction, + "round-trip produced a different SavedInstruction", + ); + } +} + +// --------------------------------------------------------------------------- +// Proptest strategies for SavedInstruction and its sub-types +// --------------------------------------------------------------------------- + +fn saved_tableau_strategy() -> impl Strategy { + (0u8..=6).prop_map(SavedTableau) +} + +fn saved_foundation_strategy() -> impl Strategy { + (0u8..=3).prop_map(SavedFoundation) +} + +fn saved_skip_cards_strategy() -> impl Strategy { + (0u8..=12).prop_map(SavedSkipCards) +} + +fn saved_klondike_pile_strategy() -> impl Strategy { + prop_oneof![ + saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau), + Just(SavedKlondikePile::Stock), + saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation), + ] +} + +fn saved_klondike_pile_stack_strategy() -> impl Strategy { + prop_oneof![ + (saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| { + SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards }) + }), + Just(SavedKlondikePileStack::Stock), + saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation), + ] +} + +fn saved_instruction_strategy() -> impl Strategy { + prop_oneof![ + Just(SavedInstruction::RotateStock), + (saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map( + |(src, foundation)| { + SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation }) + } + ), + (saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map( + |(src, tableau)| { + SavedInstruction::DstTableau(SavedDstTableau { src, tableau }) + } + ), + ] +} + +// --------------------------------------------------------------------------- +// Boundary error unit tests (exact out-of-range values) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod saved_instruction_boundary_tests { + use super::*; + + #[test] + fn saved_tableau_7_is_invalid() { + let result = Tableau::try_from(SavedTableau(7)); + assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7))); + } + + #[test] + fn saved_tableau_255_is_invalid() { + let result = Tableau::try_from(SavedTableau(255)); + assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255))); + } + + #[test] + fn saved_foundation_4_is_invalid() { + let result = Foundation::try_from(SavedFoundation(4)); + assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4))); + } + + #[test] + fn saved_skip_cards_13_is_invalid() { + let result = SkipCards::try_from(SavedSkipCards(13)); + assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13))); + } } diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 6441b55..eede94c 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -495,6 +495,98 @@ mod tests { assert_eq!(loaded, StatsSnapshot::default()); } + /// Schema v3 serialises the instruction history, not raw pile state. The + /// deserialiser replays all `saved_moves` to reconstruct every pile. + /// A fresh-game test (zero moves) never exercises that replay path, so + /// this test plays several real moves — including an undo — before + /// saving, then asserts the full pile layout round-trips exactly. + /// + /// `GameState::PartialEq` covers stock, waste, all four foundations, all + /// seven tableau columns, `score`, `move_count`, `undo_count`, and + /// `recycle_count`. Any breakage in `From` or + /// `TryFrom` will cause at least one pile to disagree. + #[test] + fn game_state_v3_mid_game_round_trip() { + use solitaire_core::KlondikePile; + use solitaire_core::game_state::{DrawMode, GameState}; + + let path = gs_path("v3_mid_game"); + let _ = fs::remove_file(&path); + + let mut gs = GameState::new(42, DrawMode::DrawOne); + + // Draw several times to populate the instruction history with + // RotateStock entries and expose waste cards for further moves. + for _ in 0..6 { + if gs.draw().is_err() { + break; + } + } + + // Execute the first available DstTableau or DstFoundation move so the + // instruction history contains a type other than RotateStock. + let moves = gs.possible_instructions(); + if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| { + matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_)) + }) { + let _ = gs.move_cards(from, to, count); + } + + // Undo once: verifies that `undo_count` is persisted and that the + // truncated history (post-undo) replays back to the correct state. + if gs.undo_stack_len() > 0 { + let _ = gs.undo(); + } + + // The instruction history must be non-empty for this test to exercise + // the schema-v3 replay path at all. Seed 42 + 6 draws always succeeds. + assert!( + gs.undo_stack_len() > 0, + "instruction history must be non-empty (seed 42 always produces draws)", + ); + + save_game_state_to(&path, &gs).expect("save"); + let loaded = load_game_state_from(&path) + .expect("a valid in-progress game must load without error"); + + assert_eq!( + loaded, gs, + "all pile layouts and counters must be identical after schema-v3 round-trip", + ); + } + + /// Schema v2 stored raw pile arrays and undo snapshots (no instruction + /// history). Any file claiming `schema_version: 2` must be rejected so + /// players upgrading from an older build start with a fresh game rather + /// than a half-reconstructed state. + #[test] + fn save_format_v2_is_rejected() { + let path = gs_path("schema_v2"); + let _ = fs::remove_file(&path); + + // Structurally valid JSON for `PersistedGameState` but with + // `schema_version: 2`. The schema-version gate in + // `GameState::deserialize` must reject this before replay starts. + let v2_json = r#"{ + "draw_mode": "DrawOne", + "mode": "Classic", + "score": 0, + "elapsed_seconds": 0, + "seed": 42, + "undo_count": 0, + "recycle_count": 0, + "take_from_foundation": true, + "schema_version": 2, + "saved_moves": [] + }"#; + fs::write(&path, v2_json).expect("write v2 fixture"); + + assert!( + load_game_state_from(&path).is_none(), + "schema v2 game_state.json must be rejected — player must start a fresh game", + ); + } + // ----------------------------------------------------------------------- // Time Attack session persistence //