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, 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 (current): session-backed save files store replayable instruction /// history instead of raw piles + undo snapshots. pub const GAME_STATE_SCHEMA_VERSION: u32 = 3; /// 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), } #[derive(Debug, Clone, Serialize, Deserialize)] struct PersistedGameState { 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 recycle_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, #[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 = PersistedGameState::deserialize(deserializer)?; if persisted.schema_version != GAME_STATE_SCHEMA_VERSION { return Err(serde::de::Error::custom(format!( "unsupported GameState schema version {}", persisted.schema_version ))); } 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, recycle_count: persisted.recycle_count, take_from_foundation: persisted.take_from_foundation, schema_version: persisted.schema_version, session: Self::new_session(persisted.seed, persisted.draw_mode), #[cfg(feature = "test-support")] test_pile_state: None, }; let replay_config = Self::replay_config(game.draw_mode); for saved in persisted.saved_moves { let instruction = KlondikeInstruction::try_from(saved).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); } 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), #[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 { KlondikeAdapter::config_for(draw_mode, true) } fn validation_config(&self) -> KlondikeConfig { KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation) } fn saved_moves(&self) -> Vec { self.session .history() .iter() .map(|snapshot| SavedInstruction::from(*snapshot.instruction())) .collect() } /// Returns the deterministic instruction history for the current deal. /// /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this /// sequence is sufficient to replay the game state exactly. pub fn instruction_history(&self) -> Vec { self.saved_moves() } 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) } 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 } 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 recycling = stock_empty && !waste_empty; self.session .process_instruction(KlondikeInstruction::RotateStock); if recycling { self.recycle_count = self.recycle_count.saturating_add(1); let penalty = KlondikeAdapter::score_for_recycle_with_mode( self.recycle_count, self.draw_mode == DrawMode::DrawThree, self.mode, ); self.score = (self.score + penalty).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 = 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 }; self.session.process_instruction(instruction); self.score = (self.score + score_delta + flip_bonus).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); } let snapshot_score = self.score; self.session.undo(); self.score = KlondikeAdapter::apply_undo_score(snapshot_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 valid A→K sequence. pub fn check_win(&self) -> bool { (0..4_u8).all(|slot| self.is_valid_foundation_pile(slot)) } fn is_valid_foundation_pile(&self, slot: u8) -> bool { let Ok(pile) = self.foundation_cards(slot) else { return false; }; if pile.len() != 13 { return false; } let suit = pile[0].suit; pile.iter() .enumerate() .all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1) } /// Returns `true` when stock and waste are empty and all tableau cards are face-up. pub fn check_auto_complete(&self) -> bool { if !self.stock_cards().is_empty() { return false; } if !self.waste_cards().is_empty() { return false; } (0..7).all(|index| { Self::tableau_from_index(index) .ok() .map(|tableau| { self.pile(KlondikePile::Tableau(tableau)) .iter() .all(|card| card.face_up) }) .unwrap_or(false) }) } /// 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) } } #[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 } #[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()); } }