use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; use crate::card::Card; use crate::deck::{deal_klondike, Deck}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo}; const MAX_UNDO_STACK: usize = 64; /// 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 (current): `Foundation(u8)` slot keys; claimed suit derived from the /// bottom card of the pile. pub const GAME_STATE_SCHEMA_VERSION: u32 = 2; /// Default value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. fn schema_v1() -> u32 { 1 } /// Serialize `HashMap` as a `Vec` of `(key, value)` pairs so /// that JSON (which requires string map keys) round-trips correctly. mod pile_map_serde { use std::collections::HashMap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::pile::{Pile, PileType}; pub fn serialize(map: &HashMap, s: S) -> Result { let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect(); entries.sort_by_key(|(k, _)| *k); entries.serialize(s) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?; Ok(entries.into_iter().collect()) } } /// 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. /// /// - `Classic`: standard Klondike scoring, undo allowed. /// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play. /// - `Challenge`: standard scoring, **undo disabled** (returns /// `MoveError::RuleViolation`). /// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute /// countdown around the session and auto-deals a fresh game on every win /// (see `solitaire_engine::TimeAttackPlugin`). /// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog /// (or system-time for `Random`). Rules identical to Classic. #[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), } /// Snapshot of game state used for undo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct StateSnapshot { #[serde(with = "pile_map_serde")] piles: HashMap, score: i32, move_count: u32, } /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GameState { /// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles. #[serde(with = "pile_map_serde")] 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). Defaults to Classic for backwards /// compatibility with older save files via `#[serde(default)]`. #[serde(default)] 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 (all remaining cards are face-up and in order). pub is_auto_completable: bool, /// Number of times `undo()` has been successfully invoked this game. /// Used by achievement conditions like `no_undo`. pub undo_count: u32, /// Number of times the waste pile has been recycled back to stock this game. /// Used by the `comeback` achievement condition. #[serde(default)] pub recycle_count: u32, /// When `true`, the player may move the top card of a foundation pile back /// onto a compatible tableau column. Off by default — non-standard house rule. #[serde(default)] pub take_from_foundation: bool, /// Save-file schema version. Defaults to `1` for older files that pre-date /// the field. The loader refuses any value other than /// [`GAME_STATE_SCHEMA_VERSION`]. #[serde(default = "schema_v1")] pub schema_version: u32, #[serde(skip)] undo_stack: VecDeque, } 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 deck = Deck::new(); deck.shuffle(seed); let (tableau, stock) = deal_klondike(deck); let mut piles: HashMap = HashMap::new(); piles.insert(PileType::Stock, stock); piles.insert(PileType::Waste, Pile::new(PileType::Waste)); for slot in 0..4_u8 { piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot))); } for (i, pile) in tableau.into_iter().enumerate() { piles.insert(PileType::Tableau(i), pile); } Self { piles, 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, undo_stack: VecDeque::new(), } } /// Number of snapshots currently on the undo stack. pub fn undo_stack_len(&self) -> usize { self.undo_stack.len() } fn take_snapshot(&self) -> StateSnapshot { StateSnapshot { piles: self.piles.clone(), score: self.score, move_count: self.move_count, } } fn push_snapshot(&mut self) { if self.undo_stack.len() >= MAX_UNDO_STACK { self.undo_stack.pop_front(); // O(1) } self.undo_stack.push_back(self.take_snapshot()); } /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock. /// Recycling is unlimited: `StockEmpty` is only returned when both stock and waste are empty. pub fn draw(&mut self) -> Result<(), MoveError> { if self.is_won { return Err(MoveError::GameAlreadyWon); } let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len(); if stock_len == 0 { let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len(); if waste_len == 0 { return Err(MoveError::StockEmpty); } // Recycle: snapshot so undo can reverse it, then move waste back to stock face-down self.push_snapshot(); let waste_cards: Vec = self.piles .get_mut(&PileType::Waste) .ok_or(MoveError::InvalidSource)? .cards .drain(..) .collect(); let stock = self.piles.get_mut(&PileType::Stock).ok_or(MoveError::InvalidDestination)?; for mut card in waste_cards.into_iter().rev() { card.face_up = false; stock.cards.push(card); } self.recycle_count = self.recycle_count.saturating_add(1); self.move_count = self.move_count.saturating_add(1); return Ok(()); } self.push_snapshot(); let draw_count = match self.draw_mode { DrawMode::DrawOne => 1, DrawMode::DrawThree => 3, }; let available = stock_len.min(draw_count); let drain_start = stock_len - available; let drawn: Vec = self.piles .get_mut(&PileType::Stock) .ok_or(MoveError::InvalidSource)? .cards .drain(drain_start..) .collect(); let waste = self.piles.get_mut(&PileType::Waste).ok_or(MoveError::InvalidDestination)?; for mut card in drawn { card.face_up = true; waste.cards.push(card); } self.move_count = self.move_count.saturating_add(1); Ok(()) } /// Move `count` cards from pile `from` to pile `to`. /// /// Returns `Err(MoveError)` if the move is illegal. On success, updates score, /// flips the newly exposed source card if needed, and checks win/auto-complete. 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())); } // Validate via scoped immutable borrows let move_start = { 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 start = from_pile.cards.len() - count; for card in &from_pile.cards[start..] { if !card.face_up { return Err(MoveError::RuleViolation("cannot move face-down card".into())); } } let bottom_card = from_pile.cards[start].clone(); match &to { PileType::Foundation(_) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; if !can_place_on_foundation(&bottom_card, dest) { return Err(MoveError::RuleViolation("invalid foundation placement".into())); } } PileType::Tableau(_) => { if matches!(&from, PileType::Foundation(_)) { if !self.take_from_foundation { return Err(MoveError::RuleViolation( "take-from-foundation rule is disabled".into(), )); } if count != 1 { return Err(MoveError::RuleViolation( "only one card can return from foundation at a time".into(), )); } } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; if !can_place_on_tableau(&bottom_card, dest) { return Err(MoveError::RuleViolation("invalid tableau placement".into())); } // The previous check only validates that the *bottom* of the // moved stack lands on the destination's top card. Without // this guard, a player could lift an arbitrary multi-card // selection from one column and drop it onto another whenever // the bottom card happens to match — even if the cards // above the bottom don't form a legal descending // alternating-colour run. if !is_valid_tableau_sequence(&from_pile.cards[start..]) { return Err(MoveError::RuleViolation( "moved cards must form a valid tableau run".into(), )); } } _ => return Err(MoveError::InvalidDestination), } start }; let score_delta = if self.mode == GameMode::Zen { 0 } else { score_move(&from, &to) }; self.push_snapshot(); // Execute move let mut moved: Vec = self.piles .get_mut(&from) .ok_or(MoveError::InvalidSource)? .cards .split_off(move_start); // Flip the newly exposed top card of the source pile if let Some(top) = self.piles .get_mut(&from) .ok_or(MoveError::InvalidSource)? .cards .last_mut() && !top.face_up { top.face_up = true; } self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved); self.score = (self.score + score_delta).max(0); self.move_count = self.move_count.saturating_add(1); self.is_won = self.check_win(); if !self.is_won { self.is_auto_completable = self.check_auto_complete(); } Ok(()) } /// Restore the most recent undo snapshot and apply the undo score penalty (-15). /// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`. 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(), )); } let snapshot = self.undo_stack.pop_back().ok_or(MoveError::UndoStackEmpty)?; self.piles = snapshot.piles; self.score = if self.mode == GameMode::Zen { 0 } else { (self.score + scoring_undo()).max(0) }; self.move_count = snapshot.move_count; self.is_won = false; self.is_auto_completable = false; self.undo_count = self.undo_count.saturating_add(1); Ok(()) } /// Returns `true` when all four foundation slots each contain a valid A→K /// sequence of a single suit. /// /// Counting 13 cards is not sufficient — a corrupt save could produce 13 /// arbitrary cards per pile and permanently lock the game via `GameAlreadyWon`. 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. /// At that point the game can be completed without further player input. pub fn check_auto_complete(&self) -> bool { // Stock must be empty; waste may still have cards (they are resolved // by draw() calls inside next_auto_complete_move / auto_complete_step). if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) { return false; } (0..7).all(|i| { self.piles .get(&PileType::Tableau(i)) .is_some_and(|p| p.cards.iter().all(|c| c.face_up)) }) } /// Returns all currently valid `move_cards` calls as `(from, to, count)` triples. /// /// Does not include stock draws — callers check `piles[&PileType::Stock]` directly. /// Every returned triple is guaranteed to succeed when passed to `move_cards`. pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> { if self.is_won { return Vec::new(); } let mut moves = Vec::new(); // Waste top card → foundation or tableau if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) { for slot in 0..4_u8 { if let Some(f) = self.piles.get(&PileType::Foundation(slot)) && can_place_on_foundation(waste_top, f) { moves.push((PileType::Waste, PileType::Foundation(slot), 1)); } } for dst in 0..7_usize { if let Some(t) = self.piles.get(&PileType::Tableau(dst)) && can_place_on_tableau(waste_top, t) { moves.push((PileType::Waste, PileType::Tableau(dst), 1)); } } } // Tableau sources for src in 0..7_usize { let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue }; if src_pile.cards.is_empty() { continue; } let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count(); if run_len == 0 { continue; } for count in 1..=run_len { let seq_start = src_pile.cards.len() - count; if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) { continue; } let bottom = &src_pile.cards[seq_start]; if count == 1 { for slot in 0..4_u8 { if let Some(f) = self.piles.get(&PileType::Foundation(slot)) && can_place_on_foundation(bottom, f) { moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1)); } } } for dst in 0..7_usize { if dst == src { continue; } if let Some(t) = self.piles.get(&PileType::Tableau(dst)) && can_place_on_tableau(bottom, t) { moves.push((PileType::Tableau(src), PileType::Tableau(dst), count)); } } } } // Foundation top → tableau (only when house rule is enabled) if self.take_from_foundation { for slot in 0..4_u8 { let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue }; let Some(top) = f.cards.last() else { continue }; for dst in 0..7_usize { if let Some(t) = self.piles.get(&PileType::Tableau(dst)) && can_place_on_tableau(top, t) { moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1)); } } } } moves } /// Returns the next `(from, to)` move that advances auto-complete, or /// `None` if no such move exists (or `is_auto_completable` is not set). /// /// Scans tableau piles 0–6 in order, returning the first top card that /// can be placed on any foundation pile. The scan order ensures Aces are /// resolved before higher ranks that depend on them. /// /// # Precondition /// /// This function is only called when `is_auto_completable` is `true`. /// Auto-completability requires the waste pile to be empty, as enforced by /// [`check_auto_complete`](Self::check_auto_complete) — it returns `false` /// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile /// in this scan is intentional and correct: by the time this function is /// reached, there are guaranteed to be no cards there to move. pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { if !self.is_auto_completable || self.is_won { return None; } // Check waste top first — when stock is exhausted the waste may still // contain cards that can go directly to a foundation. let waste = PileType::Waste; if let Some((card, slot)) = self.piles.get(&waste) .and_then(|p| p.cards.last()) .and_then(|c| self.foundation_slot_for(c).map(|s| (c, s))) { let _ = card; // borrow ends here return Some((waste, PileType::Foundation(slot))); } for i in 0..7 { let tableau = PileType::Tableau(i); if let Some(slot) = self.piles.get(&tableau) .and_then(|p| p.cards.last()) .and_then(|c| self.foundation_slot_for(c)) { return Some((tableau, PileType::Foundation(slot))); } } None } /// Return the foundation slot index that `card` can legally move to, or /// `None` if no such slot exists. /// /// Prefers the slot already claiming this card's suit so Aces always land /// in a consistent column. Falls back to an empty slot only for Aces. fn foundation_slot_for(&self, card: &crate::card::Card) -> Option { let mut candidate: Option = None; let mut empty_slot: Option = 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.value() == 1 { empty_slot } else { None } }); target.filter(|&slot| { self.piles.get(&PileType::Foundation(slot)) .is_some_and(|p| can_place_on_foundation(card, p)) }) } /// 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}; 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_partial_draw_when_fewer_than_three_remain() { let mut g = GameState::new(42, DrawMode::DrawThree); // Replace the stock with exactly 2 cards so the draw is a partial batch. let two_cards: Vec = g.piles[&PileType::Stock].cards[..2].to_vec(); g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards; g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2"); assert!(g.piles[&PileType::Stock].cards.is_empty()); } #[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(_)))); } #[test] fn move_multi_card_sequence_tableau_to_tableau_succeeds() { let mut g = new_game(); // Clear both piles and construct a known valid sequence. let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap(); t0.cards = vec![ Card { id: 10, suit: Suit::Spades, rank: Rank::King, face_up: true }, Card { id: 11, suit: Suit::Hearts, rank: Rank::Queen, face_up: true }, Card { id: 12, suit: Suit::Spades, rank: Rank::Jack, face_up: true }, ]; // Tableau(1) needs an Ace so we can check empty pile correctly — use a red King target. let t1 = g.piles.get_mut(&PileType::Tableau(1)).unwrap(); t1.cards.clear(); // empty accepts a King // Move the whole 3-card sequence to the empty pile. let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 3); assert!(result.is_ok(), "valid multi-card move must succeed: {result:?}"); assert!(g.piles[&PileType::Tableau(0)].cards.is_empty()); assert_eq!(g.piles[&PileType::Tableau(1)].cards.len(), 3); assert_eq!(g.move_count, 1); } // --- 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 + scoring_undo()).max(0); assert_eq!(g.score, expected); } #[test] fn undo_stack_capped_at_64() { let mut g = new_game(); for _ in 0..70 { let _ = g.draw(); } assert!(g.undo_stack_len() <= 64); } #[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_true_when_stock_empty_waste_has_cards() { // Waste no longer blocks auto-complete — draw() drains it during // auto-complete steps. Only stock-not-empty and face-down tableau // cards block the flag. 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. #[test] fn any_ace_lands_in_first_empty_foundation() { let mut g = new_game(); // Clear stock/waste/tableau so we can hand-construct moves directly. 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 an Ace of Clubs on tableau 0; move it to slot 0. g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); // Now place an Ace of Spades on tableau 0 and move it to slot 1. g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true, }); g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap(); assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs)); assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades)); } /// `Pile::claimed_suit` reads the bottom card's suit on a populated /// foundation slot, regardless of which slot index the pile occupies. #[test] fn claimed_suit_is_derived_from_bottom_card() { 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(); } g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, }); g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap(); assert_eq!( g.piles[&PileType::Foundation(2)].claimed_suit(), Some(Suit::Hearts) ); } /// Undoing the only card from a foundation slot drops the claimed suit; /// the slot then accepts a different Ace. #[test] fn foundation_claim_drops_when_emptied_via_undo() { 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(); } g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, }); g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts)); g.undo().unwrap(); assert!(g.piles[&PileType::Foundation(0)].cards.is_empty()); assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none()); // A different Ace can now claim slot 0. let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap(); t0.cards.clear(); t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades)); } /// 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. #[test] fn multiple_aces_distribute_across_slots() { 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(); } let aces = [ (Suit::Clubs, 10), (Suit::Diamonds, 11), (Suit::Hearts, 12), (Suit::Spades, 13), ]; for (slot, (suit, id)) in aces.iter().enumerate() { g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { id: *id, suit: *suit, rank: Rank::Ace, face_up: true, }); g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap(); } for (slot, (suit, _)) in aces.iter().enumerate() { assert_eq!( g.piles[&PileType::Foundation(slot as u8)].claimed_suit(), Some(*suit), "slot {slot} should claim {suit:?}", ); } } /// 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. #[test] fn next_auto_complete_move_picks_slot_with_matching_claim() { 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(); } // Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts. g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, }); // Tableau 0 holds the 2 of Hearts to play. g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true, }); g.is_auto_completable = true; let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1"); assert_eq!(mv.0, PileType::Tableau(0)); assert_eq!( mv.1, PileType::Foundation(1), "must target the Hearts-claimed slot, not the empty slot 0", ); } fn setup_take_from_foundation_game() -> GameState { let mut g = new_game(); // Clear the board so we control the layout exactly. 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(); } // Foundation slot 0: A♠, 2♠ (top = 2♠) let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap(); f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true }); // Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1) g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true, }); g } #[test] fn take_from_foundation_enabled_by_default() { let g = setup_take_from_foundation_game(); assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)"); } #[test] fn take_from_foundation_blocked_when_disabled() { let mut g = setup_take_from_foundation_game(); g.take_from_foundation = false; let err = g .move_cards(PileType::Foundation(0), PileType::Tableau(0), 1) .unwrap_err(); assert!( matches!(err, MoveError::RuleViolation(_)), "expected RuleViolation, got {err:?}", ); } #[test] fn take_from_foundation_allowed_when_enabled() { let mut g = setup_take_from_foundation_game(); // already true by default; explicit set confirms the behaviour holds g.take_from_foundation = true; g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap(); // Foundation slot 0 should now hold only the Ace. assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1); assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace); // The 2♠ should be on top of tableau 0 above the 3♥. let t0 = &g.piles[&PileType::Tableau(0)].cards; assert_eq!(t0.len(), 2); assert_eq!(t0[1].rank, Rank::Two); } #[test] fn take_from_foundation_rejects_illegal_tableau_placement() { let mut g = setup_take_from_foundation_game(); g.take_from_foundation = true; // Tableau 1 is empty — only a King can go there; 2♠ is not a King. let err = g .move_cards(PileType::Foundation(0), PileType::Tableau(1), 1) .unwrap_err(); assert!(matches!(err, MoveError::RuleViolation(_))); } #[test] fn take_from_foundation_rejects_count_gt_1() { let mut g = setup_take_from_foundation_game(); g.take_from_foundation = true; let err = g .move_cards(PileType::Foundation(0), PileType::Tableau(0), 2) .unwrap_err(); assert!(matches!(err, MoveError::RuleViolation(_))); } // --- 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_includes_ace_to_foundation() { 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(); } g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); let moves = g.possible_instructions(); assert!( moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)), "Ace must be moveable to empty foundation slot 0; got {moves:?}" ); } #[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})" ); } } } #[test] fn possible_instructions_waste_top_included() { let mut g = new_game(); // Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear. g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); } g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true, }); let moves = g.possible_instructions(); // King goes on any of the 7 empty tableau piles assert!( (0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))), "King on waste must be moveable to an empty tableau column" ); } }