use crate::card::{Card, Rank}; use crate::error::MoveError; use crate::klondike_adapter::{card_from_kl, KlondikeAdapter, SavedInstruction}; use crate::pile::{Pile, PileType}; use crate::scoring::compute_time_bonus as scoring_time_bonus; 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}; use std::collections::HashMap; /// 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, } /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone)] pub struct GameState { /// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles. pub piles: HashMap, /// 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 adapter: KlondikeAdapter, pub(crate) session: Session, } impl PartialEq for GameState { fn eq(&self, other: &Self) -> bool { self.piles == other.piles && 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 } } 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 { piles: HashMap::new(), 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, adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation), session: Self::new_session(persisted.seed, persisted.draw_mode), }; 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.sync_piles_from_session(); 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 { let mut game = Self { piles: HashMap::new(), 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, adapter: KlondikeAdapter::new(draw_mode, true), session: Self::new_session(seed, draw_mode), }; game.sync_piles_from_session(); game } 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::new(draw_mode, true) .klondike_config() .clone() } fn validation_config(&self) -> KlondikeConfig { KlondikeAdapter::new(self.draw_mode, self.take_from_foundation) .klondike_config() .clone() } fn saved_moves(&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() } pub(crate) fn session(&self) -> &Session { &self.session } pub(crate) fn sync_piles_from_session(&mut self) { fn push_cards( pile: &mut Pile, cards: impl IntoIterator, face_up: bool, ) { for mut card in cards { card.face_up = face_up; pile.cards.push(card); } } let state = self.session.state().state().state(); let mut piles = HashMap::new(); let mut stock = Pile::new(PileType::Stock); push_cards( &mut stock, state.stock().face_down().iter().map(card_from_kl), false, ); piles.insert(PileType::Stock, stock); let mut waste = Pile::new(PileType::Waste); push_cards( &mut waste, state.stock().face_up().iter().map(card_from_kl), true, ); piles.insert(PileType::Waste, waste); for (slot, cards) in [ (0_u8, state.foundation1()), (1_u8, state.foundation2()), (2_u8, state.foundation3()), (3_u8, state.foundation4()), ] { let mut foundation = Pile::new(PileType::Foundation(slot)); push_cards(&mut foundation, cards.iter().map(card_from_kl), true); piles.insert(PileType::Foundation(slot), foundation); } for (index, tableau) in [ Tableau::Tableau1, Tableau::Tableau2, Tableau::Tableau3, Tableau::Tableau4, Tableau::Tableau5, Tableau::Tableau6, Tableau::Tableau7, ] .into_iter() .enumerate() { let mut pile = Pile::new(PileType::Tableau(index)); push_cards( &mut pile, state .tableau_face_down_cards(tableau) .iter() .map(card_from_kl), false, ); push_cards( &mut pile, state.tableau_face_up_cards(tableau).iter().map(card_from_kl), true, ); piles.insert(PileType::Tableau(index), pile); } self.piles = piles; } fn tableau_from_index(index: usize) -> Result { match index { 0 => Ok(Tableau::Tableau1), 1 => Ok(Tableau::Tableau2), 2 => Ok(Tableau::Tableau3), 3 => Ok(Tableau::Tableau4), 4 => Ok(Tableau::Tableau5), 5 => Ok(Tableau::Tableau6), 6 => Ok(Tableau::Tableau7), _ => Err(MoveError::InvalidSource), } } fn foundation_from_slot(slot: u8) -> Result { match slot { 0 => Ok(Foundation::Foundation1), 1 => Ok(Foundation::Foundation2), 2 => Ok(Foundation::Foundation3), 3 => Ok(Foundation::Foundation4), _ => Err(MoveError::InvalidDestination), } } fn skip_cards_from_usize(skip: usize) -> Result { match skip { 0 => Ok(SkipCards::Skip0), 1 => Ok(SkipCards::Skip1), 2 => Ok(SkipCards::Skip2), 3 => Ok(SkipCards::Skip3), 4 => Ok(SkipCards::Skip4), 5 => Ok(SkipCards::Skip5), 6 => Ok(SkipCards::Skip6), 7 => Ok(SkipCards::Skip7), 8 => Ok(SkipCards::Skip8), 9 => Ok(SkipCards::Skip9), 10 => Ok(SkipCards::Skip10), 11 => Ok(SkipCards::Skip11), 12 => Ok(SkipCards::Skip12), _ => Err(MoveError::RuleViolation("invalid tableau card count".into())), } } fn will_flip_tableau_source(&self, from: PileType, count: usize) -> bool { let PileType::Tableau(_) = from else { return false; }; let Some(pile) = self.piles.get(&from) else { return false; }; pile.cards.len() > count && !pile.cards[pile.cards.len() - count - 1].face_up } fn instruction_for_move( &self, from: PileType, to: PileType, count: usize, ) -> Result { match (from, to) { (_, PileType::Stock | PileType::Waste) => Err(MoveError::InvalidDestination), (PileType::Stock, _) => Err(MoveError::InvalidSource), (PileType::Waste, PileType::Foundation(slot)) => { 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: Self::foundation_from_slot(slot)?, })) } (PileType::Tableau(src), PileType::Foundation(slot)) => { 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(Self::tableau_from_index(src)?), foundation: Self::foundation_from_slot(slot)?, })) } (PileType::Foundation(_), PileType::Foundation(_)) => Err(MoveError::RuleViolation( "cannot move between foundation slots".into(), )), (PileType::Waste, PileType::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: Self::tableau_from_index(dst)?, })) } (PileType::Foundation(slot), PileType::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(Self::foundation_from_slot(slot)?), tableau: Self::tableau_from_index(dst)?, })) } (PileType::Tableau(src), PileType::Tableau(dst)) => { let src_tableau = Self::tableau_from_index(src)?; let face_up_count = self .session .state() .state() .state() .tableau_face_up_cards(src_tableau) .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_tableau, skip_cards, }), tableau: Self::tableau_from_index(dst)?, })) } } } fn instruction_to_move( &self, instruction: KlondikeInstruction, ) -> Option<(PileType, PileType, usize)> { let state = self.session.state().state().state(); match instruction { KlondikeInstruction::RotateStock => None, KlondikeInstruction::DstFoundation(dst_foundation) => { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { return None; } let source = match dst_foundation.src { KlondikePile::Tableau(tableau) => PileType::Tableau(tableau as usize), KlondikePile::Stock => PileType::Waste, KlondikePile::Foundation(_) => return None, }; Some(( source, PileType::Foundation(dst_foundation.foundation as u8), 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; } (PileType::Tableau(tableau_stack.tableau as usize), count) } KlondikePileStack::Stock => (PileType::Waste, 1), KlondikePileStack::Foundation(foundation) => { (PileType::Foundation(foundation as u8), 1) } }; Some((source, PileType::Tableau(dst_tableau.tableau as usize), 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 .piles .get(&PileType::Stock) .is_none_or(|pile| pile.cards.is_empty()); let waste_empty = self .piles .get(&PileType::Waste) .is_none_or(|pile| pile.cards.is_empty()); if stock_empty && waste_empty { return Err(MoveError::StockEmpty); } let recycling = stock_empty && !waste_empty; self.session.process_instruction(KlondikeInstruction::RotateStock); self.sync_piles_from_session(); 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`. pub fn move_cards( &mut self, from: PileType, to: PileType, 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.piles.get(&from).ok_or(MoveError::InvalidSource)?; if from_pile.cards.is_empty() { return Err(MoveError::EmptySource); } if count == 0 || count > from_pile.cards.len() { return Err(MoveError::RuleViolation("invalid card count".into())); } let instruction = self.instruction_for_move(from.clone(), to.clone(), 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.adapter.score_for_move_with_mode(&from, &to, self.mode); let flip_bonus = if self.will_flip_tableau_source(from, count) { self.adapter.score_for_flip_with_mode(self.mode) } else { 0 }; self.session.process_instruction(instruction); self.sync_piles_from_session(); 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.sync_piles_from_session(); 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 Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { return false; }; if pile.cards.len() != 13 { return false; } let suit = pile.cards[0].suit; pile.cards .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 .piles .get(&PileType::Stock) .is_none_or(|pile| !pile.cards.is_empty()) { return false; } if self .piles .get(&PileType::Waste) .is_none_or(|pile| !pile.cards.is_empty()) { return false; } (0..7).all(|index| { self.piles .get(&PileType::Tableau(index)) .is_some_and(|pile| pile.cards.iter().all(|card| card.face_up)) }) } /// Returns all currently valid `move_cards` calls as `(from, to, count)` triples. pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> { if self.is_won { return Vec::new(); } let config = self.validation_config(); self.session .state() .state() .possible_instructions(&config) .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: &PileType, to: &PileType, count: usize) -> bool { if self.is_won || from == to { return false; } let Some(from_pile) = self.piles.get(from) else { return false; }; if count == 0 || count > from_pile.cards.len() { return false; } let Ok(instruction) = self.instruction_for_move(from.clone(), to.clone(), 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 { self.piles.iter().find_map(|(pile_type, pile)| { pile.cards .iter() .any(|card| card.id == card_id) .then(|| pile_type.clone()) }) } /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent. pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { if !self.is_auto_completable || self.is_won { return None; } let waste = PileType::Waste; if let Some(slot) = self .piles .get(&waste) .and_then(|pile| pile.cards.last()) .and_then(|card| self.foundation_slot_for(card)) { return Some((waste, PileType::Foundation(slot))); } for index in 0..7 { let tableau = PileType::Tableau(index); if let Some(slot) = self .piles .get(&tableau) .and_then(|pile| pile.cards.last()) .and_then(|card| self.foundation_slot_for(card)) { return Some((tableau, PileType::Foundation(slot))); } } None } fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool { let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { return false; }; match pile.cards.last() { Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank), None => card.rank == Rank::Ace, } } fn foundation_slot_for(&self, card: &Card) -> Option { let mut candidate = None; let mut empty_slot = None; for slot in 0..4_u8 { let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue; }; if pile.cards.is_empty() { if empty_slot.is_none() { empty_slot = Some(slot); } } else if pile.claimed_suit() == Some(card.suit) { candidate = Some(slot); break; } } let target = candidate.or_else(|| { if card.rank == Rank::Ace { empty_slot } else { None } }); target.filter(|&slot| self.can_place_on_foundation_slot(card, slot)) } /// 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::*; use crate::card::{Card, Rank, Suit}; use crate::klondike_adapter::KlondikeAdapter; fn new_game() -> GameState { GameState::new(42, DrawMode::DrawOne) } // --- Initial state --- #[test] fn new_game_has_correct_tableau_sizes() { let g = new_game(); let total: usize = (0..7) .map(|i| g.piles[&PileType::Tableau(i)].cards.len()) .sum(); assert_eq!(total, 28); for i in 0..7 { assert_eq!(g.piles[&PileType::Tableau(i)].cards.len(), i + 1); } } #[test] fn new_game_stock_has_24_cards() { assert_eq!(new_game().piles[&PileType::Stock].cards.len(), 24); } #[test] fn new_game_waste_is_empty() { assert!(new_game().piles[&PileType::Waste].cards.is_empty()); } #[test] fn new_game_foundations_are_empty() { let g = new_game(); for slot in 0..4_u8 { assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty()); } } #[test] fn new_game_is_not_won() { assert!(!new_game().is_won); } // --- Seeded reproducibility --- #[test] fn same_seed_produces_identical_layout() { let g1 = GameState::new(12345, DrawMode::DrawOne); let g2 = GameState::new(12345, DrawMode::DrawOne); for i in 0..7 { assert_eq!( g1.piles[&PileType::Tableau(i)].cards, g2.piles[&PileType::Tableau(i)].cards ); } assert_eq!( g1.piles[&PileType::Stock].cards, g2.piles[&PileType::Stock].cards ); } #[test] fn different_seeds_produce_different_layouts() { let g1 = GameState::new(1, DrawMode::DrawOne); let g2 = GameState::new(2, DrawMode::DrawOne); let t1: Vec = g1.piles[&PileType::Tableau(0)] .cards .iter() .map(|c| c.id) .collect(); let t2: Vec = g2.piles[&PileType::Tableau(0)] .cards .iter() .map(|c| c.id) .collect(); assert_ne!(t1, t2); } // --- Draw --- #[test] fn draw_one_moves_one_card_to_waste() { let mut g = new_game(); let stock_before = g.piles[&PileType::Stock].cards.len(); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1); assert_eq!(g.piles[&PileType::Waste].cards.len(), 1); } #[test] fn drawn_card_is_face_up() { let mut g = new_game(); g.draw().unwrap(); assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up); } #[test] fn draw_three_moves_up_to_three_cards() { let mut g = GameState::new(42, DrawMode::DrawThree); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); assert_eq!(g.piles[&PileType::Stock].cards.len(), 21); } #[test] fn draw_three_all_drawn_cards_are_face_up() { let mut g = GameState::new(42, DrawMode::DrawThree); g.draw().unwrap(); assert!( g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up), "all drawn cards must be face-up in waste" ); } #[test] fn draw_three_undo_returns_all_cards_to_stock() { let mut g = GameState::new(42, DrawMode::DrawThree); let stock_before = g.piles[&PileType::Stock].cards.len(); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); g.undo().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); assert!(g.piles[&PileType::Waste].cards.is_empty()); } #[test] fn draw_three_recycle_restores_waste_to_stock_face_down() { let mut g = GameState::new(42, DrawMode::DrawThree); // Drain all 24 stock cards into waste via repeated draws. while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } let waste_count = g.piles[&PileType::Waste].cards.len(); assert!(waste_count > 0); // Recycle: drawing when stock is empty returns all waste cards to stock. g.draw().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); assert!(g.piles[&PileType::Waste].cards.is_empty()); assert!( g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up), "recycled cards must be face-down" ); } #[test] fn draw_from_empty_stock_recycles_waste() { let mut g = new_game(); while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } let waste_count = g.piles[&PileType::Waste].cards.len(); assert!(waste_count > 0); g.draw().unwrap(); // recycle assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); assert!(g.piles[&PileType::Waste].cards.is_empty()); } #[test] fn recycle_count_increments_on_each_waste_recycle() { let mut g = new_game(); assert_eq!(g.recycle_count, 0); // Drain entire stock to waste. while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // first recycle assert_eq!(g.recycle_count, 1); // Drain again and recycle a second time. while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // second recycle assert_eq!(g.recycle_count, 2); } #[test] fn move_count_increments_on_recycle() { let mut g = new_game(); // Drain stock to waste, recording how many draws it took. let mut draws: u32 = 0; while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); draws += 1; } let before = g.move_count; g.draw().unwrap(); // recycle assert_eq!( g.move_count, before + 1, "recycling waste back to stock must increment move_count (was {before}, draws={draws})" ); } #[test] fn draw_from_empty_stock_and_waste_returns_error() { // The only stop condition for draw() is: both stock AND waste are // simultaneously empty. Manually empty both, then verify the error. let mut g = new_game(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); assert_eq!(g.draw(), Err(MoveError::StockEmpty)); } // --- Move validation --- #[test] fn move_zero_cards_returns_rule_violation() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } #[test] fn move_to_stock_returns_invalid_destination() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1); assert_eq!(result, Err(MoveError::InvalidDestination)); } #[test] fn move_to_waste_returns_invalid_destination() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1); assert_eq!(result, Err(MoveError::InvalidDestination)); } #[test] fn move_same_source_and_dest_returns_rule_violation() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(0), 1); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } #[test] fn move_face_down_card_returns_rule_violation() { let mut g = new_game(); // Tableau(6) has 7 cards; card 0 is always face-down. // Attempt to move 7 cards (the whole pile including face-down ones). let result = g.move_cards(PileType::Tableau(6), PileType::Tableau(5), 7); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } #[test] fn move_multiple_cards_to_foundation_returns_rule_violation() { let mut g = new_game(); // Inject two face-up cards into tableau(0) so count=2 is a valid count. g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }, Card { id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true, }, ]; let result = g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 2); assert!( matches!(result, Err(MoveError::RuleViolation(_))), "moving 2 cards to foundation must be rejected" ); } #[test] fn move_count_exceeding_pile_size_returns_rule_violation() { let mut g = new_game(); // Tableau(0) has exactly 1 card; asking for 2 should fail. let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 2); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } // --- Win detection --- #[test] fn win_detection_all_foundations_complete() { let mut g = new_game(); let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for (slot, suit) in suits.into_iter().enumerate() { let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap(); f.cards.clear(); for rank in [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ] { f.cards.push(Card { id: 0, suit, rank, face_up: true, }); } } assert!(g.check_win()); } #[test] fn win_detection_incomplete_is_false() { assert!(!new_game().check_win()); } // --- Undo --- #[test] fn undo_empty_stack_returns_error() { let mut g = new_game(); assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty)); } #[test] fn undo_after_draw_restores_pile_sizes() { let mut g = new_game(); let stock_before = g.piles[&PileType::Stock].cards.len(); let waste_before = g.piles[&PileType::Waste].cards.len(); g.draw().unwrap(); g.undo().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before); } #[test] fn undo_applies_score_penalty() { let mut g = new_game(); let score_before = g.score; g.draw().unwrap(); g.undo().unwrap(); let expected = (score_before + KlondikeAdapter::score_for_undo()).max(0); assert_eq!(g.score, expected); } #[test] fn undo_stack_len_matches_session_history() { let mut g = new_game(); for _ in 0..70 { let _ = g.draw(); } assert_eq!(g.undo_stack_len(), g.move_count as usize); } #[test] fn undo_count_increments_on_each_undo() { let mut g = new_game(); g.draw().unwrap(); assert_eq!(g.undo_count, 0, "undo_count unchanged before calling undo"); g.undo().unwrap(); assert_eq!(g.undo_count, 1); g.draw().unwrap(); g.undo().unwrap(); assert_eq!(g.undo_count, 2); } #[test] fn undo_count_saturates_at_max() { let mut g = new_game(); g.undo_count = u32::MAX; g.draw().unwrap(); g.undo().unwrap(); assert_eq!( g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX" ); } // --- Fields excluded from undo snapshot --- #[test] fn undo_does_not_roll_back_elapsed_seconds() { // elapsed_seconds tracks wall time and must be monotonic; undo must never // reduce it, otherwise the time-bonus calculation would be gamed. let mut g = new_game(); g.elapsed_seconds = 120; g.draw().unwrap(); g.undo().unwrap(); assert_eq!( g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged" ); } #[test] fn undo_does_not_roll_back_recycle_count() { // recycle_count is a lifetime counter used for the 'comeback' achievement; // rolling it back on undo would make the condition unachievable after recycling. let mut g = new_game(); // Drain stock and recycle to increment recycle_count. while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // recycle assert_eq!(g.recycle_count, 1); // Now draw one more card and undo it. g.draw().unwrap(); g.undo().unwrap(); assert_eq!( g.recycle_count, 1, "undo must leave recycle_count unchanged" ); } #[test] fn undo_after_win_returns_game_already_won() { let mut g = new_game(); g.is_won = true; assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon)); } // --- Scoring --- #[test] fn score_never_goes_below_zero() { let mut g = new_game(); for _ in 0..5 { g.draw().unwrap(); g.undo().unwrap(); } assert!(g.score >= 0); } // --- GameMode: Zen --- #[test] fn zen_mode_score_stays_zero_after_undo() { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); g.draw().unwrap(); g.undo().unwrap(); assert_eq!(g.score, 0); } #[test] fn zen_mode_field_persists_through_construction() { let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen); assert_eq!(g.mode, GameMode::Zen); assert_eq!(g.draw_mode, DrawMode::DrawThree); } // --- GameMode: Challenge --- #[test] fn challenge_mode_disables_undo() { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); g.draw().unwrap(); let result = g.undo(); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } #[test] fn challenge_mode_still_allows_normal_moves() { let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); // Just verify the game initialises cleanly with Challenge mode. assert_eq!(g.mode, GameMode::Challenge); assert_eq!(g.score, 0); } #[test] fn challenge_mode_scoring_applies_normally() { // Challenge uses Classic scoring; only undo is disabled. let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); assert_eq!(g.score, 0); // Note: Verifying score increases on actual moves would require // hand-crafting a legal move from the dealt state. We rely on the // fact that move_cards' score path is identical to Classic. } // --- GameMode: TimeAttack --- #[test] fn time_attack_mode_field_persists() { let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack); assert_eq!(g.mode, GameMode::TimeAttack); } #[test] fn time_attack_allows_undo() { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); g.draw().unwrap(); // TimeAttack does not disable undo — only Challenge does. assert!( g.undo().is_ok(), "undo must be permitted in TimeAttack mode" ); } #[test] fn time_attack_draw_three_combination() { // TimeAttack + DrawThree is a valid combination; verify construction. let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack); assert_eq!(g.mode, GameMode::TimeAttack); assert_eq!(g.draw_mode, DrawMode::DrawThree); assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); } // --- Auto-complete --- #[test] fn auto_complete_false_when_stock_not_empty() { assert!(!new_game().check_auto_complete()); } #[test] fn auto_complete_false_when_face_down_cards_remain() { let mut g = new_game(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Tableau(1) has a face-down card at index 0 assert!(!g.check_auto_complete()); } #[test] fn auto_complete_blocked_when_waste_has_cards() { // Waste must also be empty for auto-complete to engage. A non-empty // waste pile — even with all tableau cards face-up and stock empty — // must return false to prevent a deadlock where the waste top cannot // reach a foundation directly. let mut g = new_game(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { id: 99, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); for i in 0..7 { for c in g .piles .get_mut(&PileType::Tableau(i)) .unwrap() .cards .iter_mut() { c.face_up = true; } } assert!(!g.check_auto_complete()); } #[test] fn auto_complete_true_when_all_prerequisites_met() { let mut g = new_game(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Clear all tableau and put a single face-up card — all face-up guard passes. for i in 0..7 { g.piles .get_mut(&PileType::Tableau(i)) .unwrap() .cards .clear(); } g.piles .get_mut(&PileType::Tableau(0)) .unwrap() .cards .push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); assert!(g.check_auto_complete()); } // --- Time bonus --- #[test] fn time_bonus_zero_when_elapsed_is_zero() { let mut g = new_game(); g.elapsed_seconds = 0; assert_eq!(g.compute_time_bonus(), 0); } #[test] fn time_bonus_at_100_seconds() { let mut g = new_game(); g.elapsed_seconds = 100; assert_eq!(g.compute_time_bonus(), 7000); } // --- EmptySource error path --- #[test] fn move_from_empty_pile_returns_empty_source() { // Build a game state, clear a tableau pile entirely, then attempt to // move from it. The source pile exists in `piles` (key is present) but // contains no cards — exactly the code path that returns EmptySource. let mut g = new_game(); // Tableau(0) starts with exactly 1 card; clear it to make the pile empty. g.piles .get_mut(&PileType::Tableau(0)) .unwrap() .cards .clear(); let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1); assert_eq!( result, Err(MoveError::EmptySource), "moving from an empty pile must return EmptySource" ); } // --- next_auto_complete_move --- #[test] fn next_auto_complete_move_returns_none_on_fresh_game() { // A fresh game has stock and face-down cards — not auto-completable. assert!(new_game().next_auto_complete_move().is_none()); } #[test] fn next_auto_complete_move_finds_ace_on_auto_completable_board() { use crate::card::{Card, Rank}; let mut g = new_game(); // Clear stock and waste to satisfy auto-complete precondition. g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Clear all tableau piles and put a single face-up Ace of Clubs // into Tableau(0); all other piles empty. for i in 0..7 { g.piles .get_mut(&PileType::Tableau(i)) .unwrap() .cards .clear(); } g.piles .get_mut(&PileType::Tableau(0)) .unwrap() .cards .push(Card { id: 99, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); g.is_auto_completable = true; let mv = g.next_auto_complete_move().expect("should find a move"); assert_eq!(mv.0, PileType::Tableau(0)); // Slot 0 is the first empty foundation; the Ace lands there. assert_eq!(mv.1, PileType::Foundation(0)); } #[test] fn next_auto_complete_move_returns_none_when_already_won() { let mut g = new_game(); g.is_auto_completable = true; g.is_won = true; assert!(g.next_auto_complete_move().is_none()); } // --- Slot-based foundation behaviour (refactor coverage) --- /// Aces land in the first empty slot regardless of suit, and successive /// Aces fan out across slots 0, 1, 2, 3 in deterministic order. /// `Pile::claimed_suit` reads the bottom card's suit on a populated /// foundation slot, regardless of which slot index the pile occupies. /// Undoing the only card from a foundation slot drops the claimed suit; /// the slot then accepts a different Ace. /// Successive Aces from the waste pile distribute across slots 0..=3 in /// order — the player picks the slot, but `move_cards` accepts any /// empty-slot placement for an Ace. /// Auto-complete prefers the foundation slot whose claimed suit matches /// the candidate card's suit, even if an empty slot exists at a lower /// index. // --- possible_instructions --- #[test] fn possible_instructions_empty_when_won() { let mut g = new_game(); g.is_won = true; assert!(g.possible_instructions().is_empty()); } #[test] fn possible_instructions_all_valid_on_fresh_game() { // Every triple returned must actually succeed when applied to a clone of the state. let g = new_game(); for (from, to, count) in g.possible_instructions() { let mut clone = g.clone(); assert!( clone.move_cards(from.clone(), to.clone(), count).is_ok(), "instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed" ); } } #[test] fn possible_instructions_no_face_down_sources() { let g = new_game(); for (from, _, count) in g.possible_instructions() { if let PileType::Tableau(i) = from { let pile = &g.piles[&PileType::Tableau(i)]; let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count(); assert!( count <= run_len, "count {count} exceeds face-up run {run_len} for Tableau({i})" ); } } } // --- Flip bonus (+5) --- // --- Recycle penalty --- #[test] fn recycle_penalty_draw1_first_pass_free() { let mut g = new_game(); // DrawOne g.score = 200; while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // first recycle — free assert_eq!(g.recycle_count, 1); assert_eq!(g.score, 200, "first recycle in Draw-1 must be free"); } #[test] fn recycle_penalty_draw1_second_pass_costs_100() { let mut g = new_game(); // DrawOne g.score = 200; // First recycle (free) while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // Second recycle (-100) while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); assert_eq!(g.recycle_count, 2); assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100"); } #[test] fn recycle_penalty_draw3_three_passes_free() { let mut g = GameState::new(42, DrawMode::DrawThree); g.score = 200; for _ in 0..3 { while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); } assert_eq!(g.recycle_count, 3); assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free"); } #[test] fn recycle_penalty_draw3_fourth_pass_costs_20() { let mut g = GameState::new(42, DrawMode::DrawThree); g.score = 200; for _ in 0..3 { while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); } // Fourth recycle (-20) while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); assert_eq!(g.recycle_count, 4); assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20"); } #[test] fn recycle_penalty_suppressed_in_zen_mode() { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); // Two recycles — second would normally cost -100 in classic mode for _ in 0..2 { while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); } assert_eq!(g.recycle_count, 2); assert_eq!(g.score, 0, "zen mode must suppress recycle penalty"); } // --- P2: waste multi-card move must be rejected --- #[test] fn waste_multi_card_move_returns_rule_violation() { let mut g = new_game(); g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![ Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, }, Card { id: 2, suit: Suit::Spades, rank: Rank::King, face_up: true, }, ]; for i in 0..7 { g.piles .get_mut(&PileType::Tableau(i)) .unwrap() .cards .clear(); } let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2); assert!( matches!(result, Err(MoveError::RuleViolation(_))), "moving 2 cards from waste must be rejected" ); } // --- P3: foundation-to-foundation move must be rejected --- #[test] fn foundation_to_foundation_move_returns_rule_violation() { let mut g = new_game(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); for i in 0..7 { g.piles .get_mut(&PileType::Tableau(i)) .unwrap() .cards .clear(); } // Place Ace of Clubs on Foundation(0), leave Foundation(1) empty. g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }]; // Attempting to move Ace from Foundation(0) to Foundation(1) must fail. let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1); assert!( matches!(result, Err(MoveError::RuleViolation(_))), "moving between foundation slots must be rejected" ); } // --- P4: undo must not retain points from the undone move --- }