diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs deleted file mode 100644 index b64a448..0000000 --- a/solitaire_core/src/game_state.rs +++ /dev/null @@ -1,1692 +0,0 @@ -use crate::card::{Card, Rank}; -use crate::error::MoveError; -use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction}; -use crate::pile::{Pile, PileType}; -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 --- - - } diff --git a/solitaire_core/src/game_state/hints.rs b/solitaire_core/src/game_state/hints.rs new file mode 100644 index 0000000..7af4ccc --- /dev/null +++ b/solitaire_core/src/game_state/hints.rs @@ -0,0 +1,141 @@ +use super::GameState; +use card_game::Game; +use crate::card::{Card, Rank}; +use crate::pile::PileType; + +impl GameState { + /// 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)) + } +} diff --git a/solitaire_core/src/game_state/mod.rs b/solitaire_core/src/game_state/mod.rs new file mode 100644 index 0000000..d9f8be6 --- /dev/null +++ b/solitaire_core/src/game_state/mod.rs @@ -0,0 +1,580 @@ +use crate::card::Card; +use crate::error::MoveError; +use crate::klondike_adapter::{ + card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction, +}; +use crate::pile::{Pile, PileType}; +use card_game::{Game, Session, SessionConfig}; +use klondike::{ + DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, + KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +mod hints; +mod serde_impl; + +#[cfg(test)] +mod tests; + +/// 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; + +/// 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), +} + +/// 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 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: false, + schema_version: GAME_STATE_SCHEMA_VERSION, + adapter: KlondikeAdapter::new(draw_mode, false), + 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) + } + + /// 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) + } +} diff --git a/solitaire_core/src/game_state/serde_impl.rs b/solitaire_core/src/game_state/serde_impl.rs new file mode 100644 index 0000000..47c6a7a --- /dev/null +++ b/solitaire_core/src/game_state/serde_impl.rs @@ -0,0 +1,119 @@ +use super::{DrawMode, GameMode, GameState, GAME_STATE_SCHEMA_VERSION}; +use card_game::Game; +use crate::klondike_adapter::{KlondikeAdapter, SavedInstruction}; +use klondike::KlondikeInstruction; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) 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, +} + +fn schema_v1() -> u32 { + 1 +} + +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) + } +} diff --git a/solitaire_core/src/game_state/tests.rs b/solitaire_core/src/game_state/tests.rs new file mode 100644 index 0000000..12acd8d --- /dev/null +++ b/solitaire_core/src/game_state/tests.rs @@ -0,0 +1,866 @@ + 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 --- +