use crate::card::Card; use crate::error::MoveError; use crate::klondike_adapter::{ KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus, 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::{Game as _, Session, SessionConfig}; use klondike::{ 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 (current): `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. pub const GAME_STATE_SCHEMA_VERSION: u32 = 4; /// Default value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. fn schema_v1() -> u32 { 1 } /// Whether cards are drawn one at a time or three at a time from the stock. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { /// Draw one card from stock per turn. DrawOne, /// Draw three cards from stock per turn; only the top is playable. DrawThree, } /// 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 score: i32, pub elapsed_seconds: u64, pub seed: u64, pub undo_count: u32, pub recycle_count: u32, 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 both schema v3 and v4 `saved_moves` formats. /// /// `recycle_count` is intentionally absent: the value is rebuilt from the /// instruction replay so that stale counts (from the pre-Phase-3 undo drift /// bug) are corrected on load. Serde ignores the field in the JSON. #[derive(Debug, Clone, Deserialize)] struct PersistedGameStateIn { pub draw_mode: DrawMode, #[serde(default)] pub mode: GameMode, pub score: i32, pub elapsed_seconds: u64, pub seed: u64, pub undo_count: u32, #[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. pub tableau: std::collections::HashMap>, /// Per-foundation overrides. Missing keys fall back to the session. pub foundation: std::collections::HashMap>, } /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone)] pub struct GameState { /// Whether the player draws one or three cards from the stock per turn. pub draw_mode: DrawMode, /// Top-level mode (Classic / Zen). pub mode: GameMode, /// Current game score. Can be negative (undo penalties subtract from score). pub score: i32, /// Total moves made this game, including draws and stock recycles. pub move_count: u32, /// 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, /// True once all 52 cards are on the foundations. No further moves are accepted. pub is_won: bool, /// True when the game can be completed without further input. pub is_auto_completable: bool, /// Number of times `undo()` has been successfully invoked this game. pub undo_count: u32, /// Number of times the waste pile has been recycled back to stock this game. pub recycle_count: u32, /// When `true`, the player may move the top card of a foundation pile back /// onto a compatible tableau column. pub take_from_foundation: bool, /// Save-file schema version. pub schema_version: u32, pub(crate) session: Session, /// Score recorded immediately before each instruction was applied. /// Parallel to `session.history()` during live play; used by `undo()` to /// correctly restore the pre-move score before applying the undo penalty. /// Empty after a load (can't be reconstructed from history alone). score_history: Vec, /// Whether each entry in `session.history()` was a stock recycle. /// Parallel to `session.history()`; rebuilt from replay on load so that /// `undo()` correctly decrements `recycle_count` even across save/load cycles. is_recycle_history: Vec, #[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.schema_version == other.schema_version && 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, score: self.score, elapsed_seconds: self.elapsed_seconds, seed: self.seed, undo_count: self.undo_count, recycle_count: self.recycle_count, take_from_foundation: self.take_from_foundation, schema_version: self.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) and v4 (current, // upstream named-variant serde). Reject everything else. match persisted.schema_version { 3 | 4 => {} v => { return Err(serde::de::Error::custom(format!( "unsupported GameState schema version {v}" ))); } } let mut game = Self { draw_mode: persisted.draw_mode, mode: persisted.mode, score: persisted.score, move_count: 0, elapsed_seconds: persisted.elapsed_seconds, seed: persisted.seed, is_won: false, is_auto_completable: false, undo_count: persisted.undo_count, // Rebuilt from the replay loop below; persisted value may be stale // due to the pre-Phase-3 undo drift bug. recycle_count: 0, take_from_foundation: persisted.take_from_foundation, // Always stamp the current schema version after a successful load so // storage.rs schema checks pass and re-saving writes the v4 format. schema_version: GAME_STATE_SCHEMA_VERSION, session: Self::new_session(persisted.seed, persisted.draw_mode), // score_history cannot be faithfully rebuilt from the instruction // history because live-play undo penalties are not recorded in // saved_moves. Leave empty; undo() falls back to old behaviour for // any move made before this load (see undo() for details). score_history: Vec::new(), // is_recycle_history IS rebuilt: recycle detection only needs the // pre-instruction session state, which is available during replay. is_recycle_history: Vec::new(), #[cfg(feature = "test-support")] test_pile_state: None, }; let replay_config = Self::replay_config(game.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)? } }; // Detect recycle BEFORE processing so that the pre-instruction // session state (face-down stock) is still available. let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock) && game.stock_cards().is_empty() && !game.waste_cards().is_empty(); 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); game.is_recycle_history.push(is_recycle); if is_recycle { game.recycle_count = game.recycle_count.saturating_add(1); } } game.move_count = Self::u32_from_len(game.session.history().len()); game.is_won = game.check_win(); game.is_auto_completable = !game.is_won && game.check_auto_complete(); 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 { draw_mode, mode, score: 0, move_count: 0, elapsed_seconds: 0, seed, is_won: false, is_auto_completable: false, undo_count: 0, recycle_count: 0, take_from_foundation: true, schema_version: GAME_STATE_SCHEMA_VERSION, session: Self::new_session(seed, draw_mode), score_history: Vec::new(), is_recycle_history: Vec::new(), #[cfg(feature = "test-support")] test_pile_state: None, } } 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), undo_penalty: 0, ..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 { cards .into_iter() .map(|mut card| { card.face_up = face_up; card }) .collect() } pub fn stock_cards(&self) -> Vec { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.stock { return cards.clone(); } let state = self.session.state().state().state(); Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false) } pub fn waste_cards(&self) -> Vec { #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.waste { return cards.clone(); } let state = self.session.state().state().state(); 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 { match pile { KlondikePile::Stock => { if let Some(ref cards) = state.waste { return cards.clone(); } } KlondikePile::Foundation(f) => { if let Some(cards) = state.foundation.get(&f) { return cards.clone(); } } 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().map(card_from_kl), true) } KlondikePile::Tableau(tableau) => { let mut cards = Self::cards_with_face( state .tableau_face_down_cards(tableau) .iter() .map(card_from_kl), false, ); cards.extend(Self::cards_with_face( state .tableau_face_up_cards(tableau) .iter() .map(card_from_kl), 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: 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. #[cfg(feature = "test-support")] pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec) { 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. #[cfg(feature = "test-support")] pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec) { match pile { KlondikePile::Stock => { let mut stock = Vec::new(); let mut waste = Vec::new(); for card in cards { if card.face_up { waste.push(card); } else { stock.push(card); } } self.set_test_stock_cards(stock); self.set_test_waste_cards(waste); } 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 will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool { let KlondikePile::Tableau(_) = from else { return false; }; let pile = self.pile(from); if pile.is_empty() { return false; } pile.len() > count && !pile[pile.len() - count - 1].face_up } /// Returns `(score_delta, is_recycle)` for `instruction` given the *current* /// game state. Must be called **before** the instruction is applied to the /// session; the helper reads pre-instruction pile state from `self`. fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) { match instruction { KlondikeInstruction::RotateStock => { let is_recycle = self.stock_cards().is_empty() && !self.waste_cards().is_empty(); if is_recycle { let next_count = self.recycle_count.saturating_add(1); let penalty = KlondikeAdapter::score_for_recycle_with_mode( next_count, self.draw_mode == DrawMode::DrawThree, self.mode, ); (penalty, true) } else { (0, false) } } KlondikeInstruction::DstFoundation(dst_foundation) => { let from = dst_foundation.src; let to = KlondikePile::Foundation(dst_foundation.foundation); let move_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode); // DstFoundation always moves exactly 1 card. let flip_bonus = if self.will_flip_tableau_source(from, 1) { KlondikeAdapter::score_for_flip_with_mode(self.mode) } else { 0 }; (move_delta + flip_bonus, false) } KlondikeInstruction::DstTableau(dst_tableau) => { let (from, count) = match dst_tableau.src { KlondikePileStack::Stock => (KlondikePile::Stock, 1), KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1), KlondikePileStack::Tableau(ts) => { let face_up_count = self .session .state() .state() .state() .tableau_face_up_cards(ts.tableau) .len(); let count = face_up_count.saturating_sub(ts.skip_cards as usize); (KlondikePile::Tableau(ts.tableau), count) } }; let to = KlondikePile::Tableau(dst_tableau.tableau); let move_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode); let flip_bonus = if self.will_flip_tableau_source(from, count) { KlondikeAdapter::score_for_flip_with_mode(self.mode) } else { 0 }; (move_delta + flip_bonus, false) } } } 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, })) } } } fn instruction_to_move( &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); } let (score_delta, is_recycle) = self.pre_instruction_score_delta(KlondikeInstruction::RotateStock); self.score_history.push(self.score); self.is_recycle_history.push(is_recycle); self.session .process_instruction(KlondikeInstruction::RotateStock); if is_recycle { self.recycle_count = self.recycle_count.saturating_add(1); } self.score = (self.score + score_delta).max(0); self.move_count = Self::u32_from_len(self.session.history().len()); 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)?; let config = self.validation_config(); if !self .session .state() .state() .is_instruction_valid(&config, instruction) { return Err(MoveError::RuleViolation("move violates rules".into())); } let (score_delta, _) = self.pre_instruction_score_delta(instruction); self.score_history.push(self.score); self.is_recycle_history.push(false); self.session.process_instruction(instruction); self.score = (self.score + score_delta).max(0); self.move_count = Self::u32_from_len(self.session.history().len()); self.is_won = self.check_win(); self.is_auto_completable = !self.is_won && self.check_auto_complete(); Ok(()) } /// Restore the most recent undo snapshot and apply the undo score penalty (-15). 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); } // Pop the pre-instruction score for the move being undone. Falls back // to self.score (= old behaviour) when score_history is empty, which // happens for moves made before a save/load cycle because undo // penalties aren't reflected in the saved instruction history. let pre_move_score = self.score_history.pop().unwrap_or(self.score); let was_recycle = self.is_recycle_history.pop().unwrap_or(false); self.session.undo(); if was_recycle { self.recycle_count = self.recycle_count.saturating_sub(1); } // Apply the undo penalty to the pre-move score, not the post-move score. // This correctly reverses any recycle or move penalty that was applied // before adding the −15 undo penalty. self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode); self.move_count = Self::u32_from_len(self.session.history().len()); self.is_won = self.check_win(); self.is_auto_completable = !self.is_won && self.check_auto_complete(); self.undo_count = self.undo_count.saturating_add(1); 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 `(from, to, count)` moves. pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> { if self.is_won { return Vec::new(); } let config = self.validation_config(); self.session .state() .state() .get_sorted_moves(&config) .into_iter() .filter_map(|instruction| self.instruction_to_move(instruction)) .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_id`, if any. pub fn pile_containing_card(&self, card_id: u32) -> Option { if self.stock_cards().iter().any(|card| card.id == card_id) || self.waste_cards().iter().any(|card| card.id == card_id) { 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(|card| card.id == card_id) { 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(|card| card.id == card_id) { 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; } self.possible_instructions() .into_iter() .find_map(|(from, to, count)| { if count != 1 { return None; } if matches!(from, KlondikePile::Foundation(_)) { return None; } if matches!(to, KlondikePile::Foundation(_)) { Some((from, to)) } else { None } }) } /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). pub fn compute_time_bonus(&self) -> i32 { scoring_time_bonus(self.elapsed_seconds) } /// 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::*; 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 = game.possible_instructions(); if let Some((from, to, _count)) = moves.iter().copied().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().copied().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_decrements_when_recycle_is_undone() { let mut game = game_at_first_recycle().expect("could not reach recycle"); let count_after_recycle = game.recycle_count; assert_eq!(count_after_recycle, 1, "first recycle should give count=1"); game.undo().expect("undo should succeed"); assert_eq!( game.recycle_count, 0, "recycle_count must decrement back to 0 after undoing the recycle", ); } #[test] fn score_recycle_penalty_is_reversed_on_undo() { // Reach the second recycle (count=2, Draw-1) so there is a −100 penalty. let mut game = game_at_first_recycle().expect("could not reach first recycle"); // Draw until stock is empty again so we can do a second recycle. let mut second_recycle_done = false; for _ in 0..200 { if game.stock_cards().is_empty() && !game.waste_cards().is_empty() { let score_before_second_recycle = game.score; game.draw().expect("second recycle should succeed"); assert_eq!(game.recycle_count, 2); // The second recycle in Draw-1 mode costs −100. let expected_after = (score_before_second_recycle - 100).max(0); assert_eq!( game.score, expected_after, "second Draw-1 recycle must apply −100 penalty", ); // Undo: score should recover to (score_before_second_recycle − 15).max(0), // NOT to (score_after_recycle − 15).max(0). game.undo().expect("undo of second recycle should succeed"); let expected_after_undo = (score_before_second_recycle - 15).max(0); assert_eq!( game.score, expected_after_undo, "undoing a penalised recycle must reverse the recycle penalty \ before applying the −15 undo penalty", ); assert_eq!( game.recycle_count, 1, "recycle_count must also be decremented on undo", ); second_recycle_done = true; break; } let _ = game.draw(); } assert!(second_recycle_done, "could not reach second recycle in test"); } #[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!( game.possible_instructions() .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!( game.possible_instructions() .iter() .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_))) ); assert!(game.move_cards(from, to, 1).is_err()); } }