use crate::error::MoveError; use crate::klondike_adapter::{ DrawMode, KlondikeAdapter, SavedInstruction, foundation_from_slot as adapter_foundation_from_slot, skip_cards_from_count as adapter_skip_cards_from_count, tableau_from_index as adapter_tableau_from_index, }; use card_game::{Card, Game as _, Session, SessionConfig}; use klondike::{ DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, }; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Save-file schema version for `GameState`. Increment when the on-disk /// representation changes incompatibly so `load_game_state_from` can refuse /// older formats and start the player on a fresh game. /// /// History: /// - v1: `Foundation(Suit)` keys. /// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card. /// - v3: session-backed save files using local `SavedInstruction` mirror types /// with u8 indices for enum variants. /// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum /// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated /// on load via `AnyInstruction` transparent deserialization. /// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer /// persisted. They are derived from the upstream `card_game`/`klondike` session /// stats, which are rebuilt by replaying `saved_moves` on load. Older files that /// still carry those keys load fine — the extra fields are ignored. pub const GAME_STATE_SCHEMA_VERSION: u32 = 5; /// Default value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. fn schema_v1() -> u32 { 1 } /// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed /// catalog is drawn from. `Random` skips verification entirely and uses a /// system-time seed — deals may or may not be winnable. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] pub enum DifficultyLevel { #[default] Easy, Medium, Hard, Expert, Grandmaster, /// Unverified system-time seed — may or may not be winnable. Random, } impl DifficultyLevel { /// Short human-readable label shown in the HUD and win summary. pub fn label(self) -> &'static str { match self { Self::Easy => "Easy", Self::Medium => "Medium", Self::Hard => "Hard", Self::Expert => "Expert", Self::Grandmaster => "Grandmaster", Self::Random => "Random", } } } /// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum GameMode { #[default] /// Standard Klondike rules with score and timer. Classic, /// No timer, no score display, ambient audio only. Zen, /// Fixed hard seeds, no undo, must win to advance. Challenge, /// Play as many games as possible within 10 minutes. TimeAttack, /// Seed drawn from a difficulty-tiered catalog; rules identical to Classic. Difficulty(DifficultyLevel), } /// Output struct for schema v4 serialisation. `saved_moves` uses upstream /// `KlondikeInstruction` serde, which produces named enum variants. #[derive(Debug, Clone, Serialize)] struct PersistedGameState { pub draw_mode: DrawMode, pub mode: GameMode, pub elapsed_seconds: u64, pub seed: u64, pub take_from_foundation: bool, pub schema_version: u32, pub saved_moves: Vec, } /// Transparent migration wrapper for deserialisation. /// /// Tries `KlondikeInstruction` (schema v4, named variants) first; if that /// fails (because the value uses u8 indices), falls back to `SavedInstruction` /// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via /// the existing `TryFrom` impl. /// /// `SavedInstruction` remains `pub` in `klondike_adapter` because /// `solitaire_data::ReplayMove` and the WASM replay layer depend on it. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] enum AnyInstruction { V4(KlondikeInstruction), V3(SavedInstruction), } /// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats. /// /// `score`, `undo_count`, and `recycle_count` are intentionally absent: all /// three are rebuilt by replaying the instruction history through the upstream /// session stats. Older save files (v3/v4) still carry those keys; serde ignores /// them. #[derive(Debug, Clone, Deserialize)] struct PersistedGameStateIn { pub draw_mode: DrawMode, #[serde(default)] pub mode: GameMode, pub elapsed_seconds: u64, pub seed: u64, #[serde(default)] pub take_from_foundation: bool, #[serde(default = "schema_v1")] pub schema_version: u32, pub saved_moves: Vec, } #[cfg(feature = "test-support")] /// Test-only override state that shadows the real session pile data. /// /// When `test_pile_state` on `GameState` is `Some`, every pile read method /// first checks for an override here before falling back to the session. /// This lets unit tests in `solitaire_engine` construct arbitrary board /// configurations without needing to drive the full klondike session. #[derive(Clone, Debug, Default)] pub struct TestPileState { /// Override for face-down stock cards. `None` means "use session". pub stock: Option>, /// Override for face-up waste cards. `None` means "use session". pub waste: Option>, /// Per-tableau overrides. Missing keys fall back to the session. /// Each entry carries its own face-up flag so tests can place face-down /// cards (e.g. an un-flipped tableau card). pub tableau: std::collections::HashMap>, /// Per-foundation overrides. Missing keys fall back to the session. pub foundation: std::collections::HashMap>, /// Override for the derived `move_count()`. `None` means "use session /// history length". pub move_count: Option, /// Override for the derived `is_won()`. `None` means "use session win /// state". pub won: Option, /// Override for the derived `is_auto_completable()`. `None` means "derive /// from session state". pub auto_completable: Option, } /// Full state of an in-progress Klondike Solitaire game. /// /// Score, undo count, and recycle count are **not** stored here. They are /// derived on demand from the upstream `card_game`/`klondike` session stats via /// [`GameState::score`], [`GameState::undo_count`], and /// [`GameState::recycle_count`]. The session is the single source of truth; the /// −15 undo penalty is configured on the session ([`Self::session_config`]) and /// applied by the upstream score formula. #[derive(Debug, Clone)] pub struct GameState { /// Top-level mode (Classic / Zen). pub mode: GameMode, /// Seconds elapsed since the game started, used for time-bonus scoring. pub elapsed_seconds: u64, /// RNG seed used to deal this game. Same seed always produces the same layout. pub seed: u64, /// When `true`, the player may move the top card of a foundation pile back /// onto a compatible tableau column. pub take_from_foundation: bool, pub(crate) session: Session, #[cfg(feature = "test-support")] /// Test pile overrides. Always `None` in production runtime code. pub test_pile_state: Option, } impl PartialEq for GameState { fn eq(&self, other: &Self) -> bool { self.draw_mode() == other.draw_mode() && self.mode == other.mode && self.score() == other.score() && self.move_count() == other.move_count() && self.elapsed_seconds == other.elapsed_seconds && self.seed == other.seed && self.is_won() == other.is_won() && self.is_auto_completable() == other.is_auto_completable() && self.undo_count() == other.undo_count() && self.recycle_count() == other.recycle_count() && self.take_from_foundation == other.take_from_foundation && self.stock_cards() == other.stock_cards() && self.waste_cards() == other.waste_cards() && (0..4_u8) .all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()) && (0..7_usize).all(|index| { let Ok(tableau) = Self::tableau_from_index(index) else { return false; }; self.pile(KlondikePile::Tableau(tableau)) == other.pile(KlondikePile::Tableau(tableau)) }) } } impl Eq for GameState {} impl Serialize for GameState { fn serialize(&self, serializer: S) -> Result { PersistedGameState { draw_mode: self.draw_mode(), mode: self.mode, elapsed_seconds: self.elapsed_seconds, seed: self.seed, take_from_foundation: self.take_from_foundation, schema_version: GAME_STATE_SCHEMA_VERSION, saved_moves: self.saved_moves(), } .serialize(serializer) } } impl<'de> Deserialize<'de> for GameState { fn deserialize>(deserializer: D) -> Result { let persisted = PersistedGameStateIn::deserialize(deserializer)?; // Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream // named-variant serde), and v5 (current, derived stats). Reject the rest. match persisted.schema_version { 3..=5 => {} v => { return Err(serde::de::Error::custom(format!( "unsupported GameState schema version {v}" ))); } } let mut game = Self { mode: persisted.mode, elapsed_seconds: persisted.elapsed_seconds, seed: persisted.seed, take_from_foundation: persisted.take_from_foundation, session: Self::new_session(persisted.seed, persisted.draw_mode), #[cfg(feature = "test-support")] test_pile_state: None, }; // Replay the saved instruction history. The upstream session tracks // score components and recycle_count as it processes each move, so the // derived stats are correct once replay completes. `undo_count()` resets // to 0 across save/load because undone moves are not part of the saved // forward history. let replay_config = Self::replay_config(persisted.draw_mode); for any in persisted.saved_moves { // AnyInstruction::V4 arrives directly from upstream serde (schema v4+). // AnyInstruction::V3 was serialised with u8 indices (schema v3) and is // converted here via the existing TryFrom impl. let instruction = match any { AnyInstruction::V4(i) => i, AnyInstruction::V3(s) => { KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)? } }; if !game .session .state() .state() .is_instruction_valid(&replay_config, instruction) { return Err(serde::de::Error::custom( "saved instruction history is invalid for reconstructed session", )); } game.session.process_instruction(instruction); } Ok(game) } } impl GameState { /// Creates a new Classic-mode game dealt from the given seed and draw mode. pub fn new(seed: u64, draw_mode: DrawMode) -> Self { Self::new_with_mode(seed, draw_mode, GameMode::Classic) } /// Creates a new game with an explicit `GameMode`. pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { Self { mode, elapsed_seconds: 0, seed, take_from_foundation: true, session: Self::new_session(seed, draw_mode), #[cfg(feature = "test-support")] test_pile_state: None, } } /// Whether the player draws one or three cards from the stock per turn. /// Derived from the underlying session config (set once at deal time). pub fn draw_mode(&self) -> DrawMode { match self.session.config().inner.draw_stock { DrawStockConfig::DrawOne => DrawMode::DrawOne, DrawStockConfig::DrawThree => DrawMode::DrawThree, } } /// Current game score, derived from the upstream session stats. /// /// The upstream score is a linear sum of move-type counts (foundation/ /// tableau/flip deltas) plus `undos * undo_penalty` (−15 each). Floored at 0 /// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`], /// where scoring is suppressed entirely. /// /// Note: the win-time bonus (`compute_time_bonus`) is layered on by the /// engine's win-summary, not included here — this is the in-play base score. pub fn score(&self) -> i32 { if self.mode == GameMode::Zen { return 0; } self.session .state() .score(self.session.stats(), self.session.config()) .max(0) } /// Number of times `undo()` has been successfully invoked this game, read /// from the upstream session stats. /// /// Resets to 0 across a save/load cycle: only the forward instruction /// history is persisted, so undone moves leave no trace to replay. pub fn undo_count(&self) -> u32 { self.session.stats().undos() } /// Number of times the waste pile has been recycled back to stock this game, /// read from the upstream session stats. /// /// This is a **cumulative** count — the upstream stat is not rolled back when /// a recycle is undone, so it reflects total recycles ever performed. pub fn recycle_count(&self) -> u32 { self.session.stats().stats().recycle_count() } /// Total moves made this game (draws, recycles, and card moves), derived /// from the session's instruction history length. pub fn move_count(&self) -> u32 { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(count) = state.move_count { return count; } Self::u32_from_len(self.session.history().len()) } /// True once all 52 cards are on the foundations. No further moves are /// accepted. Derived from the session win state. pub fn is_won(&self) -> bool { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(won) = state.won { return won; } self.check_win() } /// True when the game can be completed without further player input /// (and is not already won). Derived from the session state. pub fn is_auto_completable(&self) -> bool { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(auto) = state.auto_completable { return auto; } !self.check_win() && self.check_auto_complete() } fn new_session(seed: u64, draw_mode: DrawMode) -> Session { Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode)) } fn session_config(draw_mode: DrawMode) -> SessionConfig { SessionConfig { inner: Self::replay_config(draw_mode), // The −15 WXP undo penalty is now applied by the upstream score // formula (`undos * undo_penalty`) rather than by hand in `undo()`. undo_penalty: -15, ..SessionConfig::default() } } 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) } fn validation_config(&self) -> KlondikeConfig { KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation) } /// Collects the session instruction history as upstream types for schema v4 /// serialisation. fn saved_moves(&self) -> Vec { self.session .history() .iter() .map(|snapshot| *snapshot.instruction()) .collect() } /// Returns the deterministic instruction history for the current deal as /// legacy mirror types. /// /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this /// sequence is sufficient to replay the game state exactly. /// /// Returns [`SavedInstruction`] (u8-index mirror types) for backward /// compatibility with the WASM replay layer and `solitaire_data::ReplayMove` /// format. New code that does not need serde should prefer /// `session().history()` directly. pub fn instruction_history(&self) -> Vec { self.session .history() .iter() .map(|snapshot| SavedInstruction::from(*snapshot.instruction())) .collect() } fn u32_from_len(len: usize) -> u32 { if len > u32::MAX as usize { u32::MAX } else { len as u32 } } pub fn undo_stack_len(&self) -> usize { self.session.history().len() } fn cards_with_face( cards: impl IntoIterator, face_up: bool, ) -> Vec<(Card, bool)> { cards.into_iter().map(|card| (card, face_up)).collect() } pub fn stock_cards(&self) -> Vec<(Card, bool)> { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.stock { return cards.iter().map(|c| (c.clone(), false)).collect(); } let state = self.session.state().state().state(); Self::cards_with_face(state.stock().face_down().iter().cloned(), false) } pub fn waste_cards(&self) -> Vec<(Card, bool)> { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.waste { return cards.iter().map(|c| (c.clone(), true)).collect(); } let state = self.session.state().state().state(); Self::cards_with_face(state.stock().face_up().iter().cloned(), true) } /// Returns the cards in the requested pile as `(card, face_up)` tuples. /// /// **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<(Card, bool)> { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state { match pile { KlondikePile::Stock => { if let Some(ref cards) = state.waste { return cards.iter().map(|c| (c.clone(), true)).collect(); } } KlondikePile::Foundation(f) => { if let Some(cards) = state.foundation.get(&f) { return cards.iter().map(|c| (c.clone(), true)).collect(); } } KlondikePile::Tableau(t) => { if let Some(cards) = state.tableau.get(&t) { return cards.clone(); } } } } let state = self.session.state().state().state(); match pile { KlondikePile::Stock => self.waste_cards(), KlondikePile::Foundation(foundation) => { let cards = match foundation { Foundation::Foundation1 => state.foundation1(), Foundation::Foundation2 => state.foundation2(), Foundation::Foundation3 => state.foundation3(), Foundation::Foundation4 => state.foundation4(), }; Self::cards_with_face(cards.iter().cloned(), true) } KlondikePile::Tableau(tableau) => { let mut cards = Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false); cards.extend(Self::cards_with_face( state.tableau_face_up_cards(tableau).iter().cloned(), true, )); cards } } } pub fn tableau_from_index(index: usize) -> Result { adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource) } pub fn foundation_from_slot(slot: u8) -> Result { adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination) } pub fn foundation_cards(&self, slot: u8) -> Result, MoveError> { let foundation = Self::foundation_from_slot(slot)?; Ok(self.pile(KlondikePile::Foundation(foundation))) } /// Returns `true` when test-only pile overrides are active. #[cfg(feature = "test-support")] pub fn has_test_pile_overrides(&self) -> bool { self.test_pile_state.is_some() } /// Returns `false` in production builds where test pile overrides are absent. #[cfg(not(feature = "test-support"))] pub const fn has_test_pile_overrides(&self) -> bool { false } /// Test-support helper: clear all pile overrides so reads come from the /// underlying klondike session again. #[cfg(feature = "test-support")] pub fn clear_test_pile_overrides(&mut self) { self.test_pile_state = None; } /// Test-support helper: re-deal the current seed under a different draw /// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need /// a specific mode use this instead of mutating a field. #[cfg(feature = "test-support")] pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) { self.session = Self::new_session(self.seed, draw_mode); } /// Test-support helper: override the value returned by [`Self::is_won`] /// without driving the session to a genuine win. #[cfg(feature = "test-support")] pub fn set_test_won(&mut self, won: bool) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.won = Some(won); } /// Test-support helper: override the value returned by /// [`Self::is_auto_completable`]. #[cfg(feature = "test-support")] pub fn set_test_auto_completable(&mut self, auto_completable: bool) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.auto_completable = Some(auto_completable); } /// Test-support helper: override the value returned by /// [`Self::move_count`] without applying real moves. #[cfg(feature = "test-support")] pub fn set_test_move_count(&mut self, move_count: u32) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.move_count = Some(move_count); } /// Test-support helper: perform `n` real undos so [`Self::undo_count`] /// reports `n`. Each iteration draws a card then immediately undoes it, /// leaving the board unchanged but advancing the upstream `undos` counter. /// /// Since `score`/`undo_count`/`recycle_count` are now derived from the /// session stats rather than stored fields, tests drive the real session to /// reach a desired stat instead of assigning the value directly. #[cfg(feature = "test-support")] pub fn force_test_undos(&mut self, n: u32) { for _ in 0..n { if self.draw().is_ok() { let _ = self.undo(); } } } /// Test-support helper: perform `n` real stock recycles so /// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then /// draws once more to recycle, repeated `n` times. #[cfg(feature = "test-support")] pub fn force_test_recycles(&mut self, n: u32) { for _ in 0..n { let mut guard = 0; while !self.stock_cards().is_empty() && guard < 200 { guard += 1; if self.draw().is_err() { break; } } // Stock now empty (waste full) — this draw recycles waste → stock. let _ = self.draw(); } } /// Test-support helper: drive real moves until [`Self::score`] reaches at /// least `target`, returning the resulting score. Prefers foundation moves /// (+10 each) and falls back to the solver-priority move, so a modest target /// is reached within a handful of moves on a typical deal. #[cfg(feature = "test-support")] pub fn force_test_score(&mut self, target: i32) -> i32 { let mut guard = 0; while self.score() < target && !self.is_won() && guard < 4000 { guard += 1; let instructions = self.possible_instructions(); let next = instructions .iter() .copied() .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_))) .or_else(|| instructions.into_iter().next()); match next { Some(instruction) => { if self.apply_instruction(instruction).is_err() { break; } } None => break, } } self.score() } /// Test-support helper: override face-down stock cards returned by /// [`Self::stock_cards`]. #[cfg(feature = "test-support")] pub fn set_test_stock_cards(&mut self, cards: Vec) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.stock = Some(cards); } /// Test-support helper: override face-up waste cards returned by /// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`. #[cfg(feature = "test-support")] pub fn set_test_waste_cards(&mut self, cards: Vec) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.waste = Some(cards); } /// Test-support helper: override cards for a specific tableau column. /// /// All provided cards are treated as face-up. Use /// [`Self::set_test_tableau_cards_with_face`] when a test needs to place /// face-down cards. #[cfg(feature = "test-support")] pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec) { let with_face = cards.into_iter().map(|c| (c, true)).collect(); self.set_test_tableau_cards_with_face(tableau, with_face); } /// Test-support helper: override cards for a specific tableau column, /// specifying each card's face-up flag (`true` = face-up). #[cfg(feature = "test-support")] pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.tableau.insert(tableau, cards); } /// Test-support helper: override cards for a specific foundation pile. #[cfg(feature = "test-support")] pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec) { let state = self .test_pile_state .get_or_insert_with(TestPileState::default); state.foundation.insert(foundation, cards); } /// Test-support helper: override cards for a specific pile. /// /// For `KlondikePile::Stock`, all provided cards go to the face-down stock /// override. Use [`Self::set_test_waste_cards`] to override the waste pile /// separately. #[cfg(feature = "test-support")] pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec) { match pile { KlondikePile::Stock => { self.set_test_stock_cards(cards); } KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards), KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards), } } fn skip_cards_from_usize(skip: usize) -> Result { adapter_skip_cards_from_count(skip) .ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into())) } fn instruction_for_move( &self, from: KlondikePile, to: KlondikePile, count: usize, ) -> Result { match (from, to) { (_, KlondikePile::Stock) => Err(MoveError::InvalidDestination), (KlondikePile::Stock, KlondikePile::Foundation(foundation)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } Ok(KlondikeInstruction::DstFoundation(DstFoundation { src: KlondikePile::Stock, foundation, })) } (KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } Ok(KlondikeInstruction::DstFoundation(DstFoundation { src: KlondikePile::Tableau(src), foundation, })) } (KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err( MoveError::RuleViolation("cannot move between foundation slots".into()), ), (KlondikePile::Stock, KlondikePile::Tableau(dst)) => { if count != 1 { return Err(MoveError::RuleViolation( "only the top waste card may be moved".into(), )); } Ok(KlondikeInstruction::DstTableau(DstTableau { src: KlondikePileStack::Stock, tableau: dst, })) } (KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can return from foundation at a time".into(), )); } Ok(KlondikeInstruction::DstTableau(DstTableau { src: KlondikePileStack::Foundation(foundation), tableau: dst, })) } (KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => { let face_up_count = self .session .state() .state() .state() .tableau_face_up_cards(src) .len(); if count > face_up_count { return Err(MoveError::RuleViolation( "cannot move face-down card".into(), )); } let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?; Ok(KlondikeInstruction::DstTableau(DstTableau { src: KlondikePileStack::Tableau(TableauStack { tableau: src, skip_cards, }), tableau: dst, })) } } } /// Decodes an upstream [`KlondikeInstruction`] into the `(from, to, count)` /// pile coordinates of `solitaire_core`'s own pile model, against the live /// board. Returns `None` for no-op instructions (foundation→foundation, or a /// tableau move of zero cards). /// /// This is the single, canonical translation from the upstream instruction /// move-currency to core's [`KlondikePile`] vocabulary. It lives in core /// because decoding a tableau-run length requires upstream pile-stack types /// (`KlondikePileStack`/`SkipCards`) that the engine and wasm crates do not /// see — relocating it would duplicate this logic across both crates. The /// two edges that genuinely need on-screen positions call it: the engine's /// hint highlight (which pile to glow) and the wasm debug move list (pile /// names + run length serialized to the browser harness). /// /// Game logic — auto-complete, move application, the property tests — stays /// in instruction space and never calls this; applying a move uses /// [`Self::apply_instruction`]. pub fn instruction_to_piles( &self, instruction: KlondikeInstruction, ) -> Option<(KlondikePile, KlondikePile, usize)> { let state = self.session.state().state().state(); match instruction { KlondikeInstruction::RotateStock => { Some((KlondikePile::Stock, KlondikePile::Stock, 1)) } KlondikeInstruction::DstFoundation(dst_foundation) => { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { return None; } let source = match dst_foundation.src { KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau), KlondikePile::Stock => KlondikePile::Stock, KlondikePile::Foundation(_) => return None, }; Some(( source, KlondikePile::Foundation(dst_foundation.foundation), 1, )) } KlondikeInstruction::DstTableau(dst_tableau) => { let (source, count) = match dst_tableau.src { KlondikePileStack::Tableau(tableau_stack) => { let face_up_count = state.tableau_face_up_cards(tableau_stack.tableau).len(); let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?; if count == 0 { return None; } (KlondikePile::Tableau(tableau_stack.tableau), count) } KlondikePileStack::Stock => (KlondikePile::Stock, 1), KlondikePileStack::Foundation(foundation) => { (KlondikePile::Foundation(foundation), 1) } }; Some((source, KlondikePile::Tableau(dst_tableau.tableau), count)) } } } /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock. pub fn draw(&mut self) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } let stock_empty = self.stock_cards().is_empty(); let waste_empty = self.waste_cards().is_empty(); if stock_empty && waste_empty { return Err(MoveError::StockEmpty); } // The session tracks score components and recycle_count as it processes // the instruction; no local bookkeeping required. self.session .process_instruction(KlondikeInstruction::RotateStock); Ok(()) } /// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids. pub fn move_cards( &mut self, from: KlondikePile, to: KlondikePile, count: usize, ) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } if from == to { return Err(MoveError::RuleViolation( "source and destination must differ".into(), )); } let from_pile = self.pile(from); if from_pile.is_empty() { return Err(MoveError::EmptySource); } if count == 0 || count > from_pile.len() { return Err(MoveError::RuleViolation("invalid card count".into())); } let instruction = self.instruction_for_move(from, to, count)?; self.apply_instruction(instruction) } /// Apply an upstream [`KlondikeInstruction`] directly to the live session. /// /// This is the native apply path for moves that already exist in /// instruction form — solver hints, auto-complete, replay, and the property /// tests. User drag-and-drop enters through [`Self::move_cards`], which is a /// thin adapter that converts pile coordinates to an instruction and /// delegates here, so the move bookkeeping (rule validation, the undo /// snapshot, and the session's score/recycle stats) lives in exactly one /// place. /// /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// current position, or [`MoveError::GameAlreadyWon`] once the game is over. pub fn apply_instruction( &mut self, instruction: KlondikeInstruction, ) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } let config = self.validation_config(); if !self .session .state() .state() .is_instruction_valid(&config, instruction) { return Err(MoveError::RuleViolation("move violates rules".into())); } // The session records the move snapshot and updates score/recycle stats. self.session.process_instruction(instruction); Ok(()) } /// Restore the most recent undo snapshot. /// /// The −15 undo penalty is applied by the upstream score formula /// (`undos * undo_penalty`), and the session increments its `undos` counter, /// so this method only has to delegate to [`Session::undo`] after the mode /// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values. pub fn undo(&mut self) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } if self.mode == GameMode::Challenge { return Err(MoveError::RuleViolation( "undo is disabled in Challenge mode".into(), )); } if self.session.history().is_empty() { return Err(MoveError::UndoStackEmpty); } self.session.undo(); Ok(()) } /// Returns `true` when all four foundation slots each contain a complete A→K sequence. pub fn check_win(&self) -> bool { self.session.state().state().is_win() } /// Returns `true` when the game can be completed without further player input /// (stock empty, waste empty, all tableau cards face-up). pub fn check_auto_complete(&self) -> bool { self.session.state().state().is_win_trivial() } /// Returns all currently valid moves as upstream [`KlondikeInstruction`]s, /// ordered by the `klondike` solver's move priority. /// /// This is the engine's move currency. Callers that need on-screen pile /// positions — hint highlighting and the wasm debug move list — decode each /// instruction with [`Self::instruction_to_piles`] at their UI edge. pub fn possible_instructions(&self) -> Vec { if self.is_won() { return Vec::new(); } let config = self.validation_config(); self.session .state() .state() .get_sorted_moves(&config) .into_iter() .collect() } /// Returns `true` when `move_cards(from, to, count)` would currently succeed. pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool { if self.is_won() || from == to { return false; } let from_pile = self.pile(*from); if count == 0 || count > from_pile.len() { return false; } let Ok(instruction) = self.instruction_for_move(*from, *to, count) else { return false; }; let config = self.validation_config(); self.session .state() .state() .is_instruction_valid(&config, instruction) } /// Returns the current pile containing `card`, if any. pub fn pile_containing_card(&self, card: Card) -> Option { if self.stock_cards().iter().any(|(c, _)| *c == card) || self.waste_cards().iter().any(|(c, _)| *c == card) { return Some(KlondikePile::Stock); } for slot in 0..4_u8 { let foundation = Self::foundation_from_slot(slot).ok()?; let pile = self.pile(KlondikePile::Foundation(foundation)); if pile.iter().any(|(c, _)| *c == card) { return Some(KlondikePile::Foundation(foundation)); } } for index in 0..7_usize { let tableau = Self::tableau_from_index(index).ok()?; let pile = self.pile(KlondikePile::Tableau(tableau)); if pile.iter().any(|(c, _)| *c == card) { return Some(KlondikePile::Tableau(tableau)); } } None } /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent. pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> { if !self.is_auto_completable() || self.is_won() { return None; } // A foundation-bound single-card move is exactly a `DstFoundation` // instruction whose source is not itself a foundation. Match the // instruction variant directly rather than projecting every candidate // to `(from, to, count)` pile coordinates — auto-complete is pure game // logic and never needs on-screen positions. self.possible_instructions() .into_iter() .find_map(|instruction| match instruction { KlondikeInstruction::DstFoundation(dst) if !matches!(dst.src, KlondikePile::Foundation(_)) => { Some((dst.src, KlondikePile::Foundation(dst.foundation))) } _ => None, }) } /// Read-only access to the underlying [`card_game::Session`] for this deal. /// /// Exposes `session.history()` (deterministic replay) and `session.solve()` /// (DFS solver) to crates outside `solitaire_core` without surfacing the /// mutable field. Internal code that needs to mutate the session accesses /// the `pub(crate)` field directly. pub fn session(&self) -> &Session { &self.session } } #[cfg(test)] mod tests { use super::*; /// Resolve every legal instruction to its `(from, to, count)` piles for /// tests that assert against pile positions. Mirrors what a UI edge does /// via [`GameState::instruction_to_piles`]. fn legal_pile_moves(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> { game.possible_instructions() .into_iter() .filter_map(|instruction| game.instruction_to_piles(instruction)) .collect() } fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> { const MAX_SEED: u64 = 512; const MAX_STEPS: usize = 160; for seed in 1..=MAX_SEED { let mut game = GameState::new(seed, DrawMode::DrawOne); game.take_from_foundation = true; for _ in 0..MAX_STEPS { let moves = legal_pile_moves(&game); if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| { *count == 1 && matches!(from, KlondikePile::Foundation(_)) && matches!(to, KlondikePile::Tableau(_)) }) { return Some((game, from, to)); } if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| { *count == 1 && !matches!(from, KlondikePile::Foundation(_)) && matches!(to, KlondikePile::Foundation(_)) }) && game.move_cards(from, to, count).is_ok() { continue; } if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty()) && game.draw().is_ok() { continue; } if let Some((from, to, count)) = moves .iter() .copied() .find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_))) && game.move_cards(from, to, count).is_ok() { continue; } break; } } None } /// Drive a DrawOne game until a recycle is available, perform it, and return /// the game. Returns `None` if no recycle position is found within the /// iteration limit (shouldn't happen in practice). fn game_at_first_recycle() -> Option { for seed in 1..=256_u64 { let mut game = GameState::new(seed, DrawMode::DrawOne); for _ in 0..200 { if game.stock_cards().is_empty() && !game.waste_cards().is_empty() { // This draw will recycle. game.draw().ok()?; return Some(game); } let _ = game.draw(); } } None } #[test] fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() { // Upstream `KlondikeStats::recycle_count` counts every recycle ever // performed; it is intentionally NOT decremented when a recycle is // undone (the session restores the board but leaves the stat). This is // the post-migration semantics: a cumulative count, not a net count. let mut game = game_at_first_recycle().expect("could not reach recycle"); assert_eq!(game.recycle_count(), 1, "first recycle should give count=1"); game.undo().expect("undo should succeed"); assert_eq!( game.recycle_count(), 1, "recycle_count is cumulative: undoing a recycle does not roll it back", ); } #[test] fn undo_applies_minus_15_penalty_via_upstream_score() { // A foundation move scores +10 upstream; undoing it nets the move score // back to 0 and adds the −15 undo penalty, which `score()` floors at 0. let mut game = GameState::new(1, DrawMode::DrawOne); // Find and play any scoring move, then undo it. let scoring_move = game .possible_instructions() .into_iter() .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_))); if let Some(instruction) = scoring_move { game.apply_instruction(instruction) .expect("scoring move should apply"); assert!(game.score() > 0, "a foundation move should raise the score"); game.undo().expect("undo should succeed"); assert_eq!(game.undo_count(), 1, "undo increments the upstream counter"); // base score returns to 0, minus 15 undo penalty, floored at 0. assert_eq!(game.score(), 0, "score floors at 0 after the undo penalty"); } } #[test] fn take_from_foundation_allows_legal_return_move() { let (mut game, from, to) = find_foundation_return_position() .expect("expected to find a deterministic foundation->tableau return move"); game.take_from_foundation = true; assert!(game.can_move_cards(&from, &to, 1)); assert!( legal_pile_moves(&game) .iter() .any(|(f, t, c)| *f == from && *t == to && *c == 1) ); assert!(game.move_cards(from, to, 1).is_ok()); } #[test] fn take_from_foundation_disabled_blocks_return_move_everywhere() { let (mut game, from, to) = find_foundation_return_position() .expect("expected to find a deterministic foundation->tableau return move"); game.take_from_foundation = false; assert!(!game.can_move_cards(&from, &to, 1)); assert!( legal_pile_moves(&game) .iter() .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_))) ); assert!(game.move_cards(from, to, 1).is_err()); } }