//! Spider solitaire rules — a second [`card_game::Game`] implementation //! alongside the upstream Klondike. //! //! Built directly on the upstream `card_game` containers: two decks //! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten //! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the //! `Stack`/`Pile` public API that `klondike` uses internally. //! //! ## Rules implemented //! //! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive //! 5 (top card face-up) — 54 dealt, 50 in stock. //! - Build down regardless of suit; only same-suit descending runs may //! be picked up and moved. //! - Empty piles accept any card or movable run. //! - The stock deals one card to every pile (10 total), only while no //! pile is empty. //! - A completed K→A same-suit run is removed automatically; the game //! is won when all 8 runs are removed. //! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits //! spread over the same 104 cards. //! //! ## Determinism //! //! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same //! `rand::rngs::StdRng` alias `klondike` exports, seeded through //! `SeedableRng::seed_from_u64` and applied with a `SliceRandom` //! shuffle. The same seed + suit count always produces the same deal. //! //! ## Card identity caveat (engine integration, later phase) //! //! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit //! games need more than four copies of a suit, so *identical* `Card` //! values legitimately coexist (e.g. eight `♠A` in a 1-suit game, //! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so //! core is unaffected, but the engine's `Card → Entity` mapping is //! keyed by card value and will need positional keys before a Spider //! UI lands. use card_game::{Card, Deck, Game, Pile, Rank, Session, SessionConfig, Stack, Suit}; use serde::{Deserialize, Serialize}; use crate::error::MoveError; /// The RNG Spider deals with — the same alias upstream `klondike` /// exports, so both games share one shuffle stack. pub type Rng = rand::rngs::StdRng; /// Number of tableau piles. pub const SPIDER_TABLEAUS: usize = 10; /// Total cards in play (two decks). pub const SPIDER_DECK_SIZE: usize = 104; /// Cards left in the stock after the opening deal (5 deals × 10). const STOCK_SIZE: usize = 50; /// Cards in one completed run (K → A). const RUN_LEN: usize = 13; /// Runs required to win. const TOTAL_RUNS: u8 = 8; /// Face-down cards in the deepest opening pile. const MAX_FACE_DOWN: usize = 5; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- /// Spider difficulty: how many distinct suits the 104 cards span. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] pub enum SpiderSuits { /// All spades — the beginner layout (default). #[default] One, /// Spades + hearts. Two, /// Full two-deck spread, the classic four-suit game. Four, } impl SpiderSuits { /// Suits used at this difficulty. fn suits(self) -> &'static [Suit] { match self { Self::One => &[Suit::Spades], Self::Two => &[Suit::Spades, Suit::Hearts], Self::Four => &[Suit::Spades, Suit::Hearts, Suit::Clubs, Suit::Diamonds], } } } /// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`. /// /// Defaults follow the familiar Microsoft formula: start at 500, −1 /// per move, +100 per completed run (the upstream session adds /// `undos × undo_penalty` on top). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SpiderScoring { /// Score a fresh deal starts from. pub base: i32, /// Added per move (conventionally negative). pub move_penalty: i32, /// Added per completed K→A run. pub run_bonus: i32, } impl Default for SpiderScoring { fn default() -> Self { Self { base: 500, move_penalty: -1, run_bonus: 100, } } } /// Full Spider rules configuration (the `Game::Config` type). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct SpiderConfig { /// Suit-count difficulty. pub suits: SpiderSuits, /// Scoring knobs. pub scoring: SpiderScoring, } // --------------------------------------------------------------------------- // Stats // --------------------------------------------------------------------------- /// Per-game counters (the `Game::Stats` type). Cumulative — they are /// not rolled back by undo, matching upstream `KlondikeStats`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct SpiderStats { moves: u32, deals: u32, runs_completed: u32, } impl SpiderStats { /// Total instructions processed (moves + deals). pub const fn moves(&self) -> u32 { self.moves } /// Stock deals performed. pub const fn deals(&self) -> u32 { self.deals } /// K→A runs completed over the whole game (not undone-adjusted). pub const fn runs_completed(&self) -> u32 { self.runs_completed } } // --------------------------------------------------------------------------- // Piles, run lengths, and instructions // --------------------------------------------------------------------------- /// One of the ten Spider tableau piles, in layout order. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SpiderTableau { /// Leftmost pile (opens with 6 cards). Tableau1, /// Second pile (opens with 6 cards). Tableau2, /// Third pile (opens with 6 cards). Tableau3, /// Fourth pile (opens with 6 cards). Tableau4, /// Fifth pile (opens with 5 cards). Tableau5, /// Sixth pile (opens with 5 cards). Tableau6, /// Seventh pile (opens with 5 cards). Tableau7, /// Eighth pile (opens with 5 cards). Tableau8, /// Ninth pile (opens with 5 cards). Tableau9, /// Rightmost pile (opens with 5 cards). Tableau10, } impl SpiderTableau { /// All ten piles, in layout order — the canonical iteration source, /// following `Suit::SUITS` / `Rank::RANKS` upstream. pub const ALL: [Self; SPIDER_TABLEAUS] = [ Self::Tableau1, Self::Tableau2, Self::Tableau3, Self::Tableau4, Self::Tableau5, Self::Tableau6, Self::Tableau7, Self::Tableau8, Self::Tableau9, Self::Tableau10, ]; const ITER_BEGIN: Self = Self::Tableau1; const fn next(self) -> Option { use SpiderTableau::*; Some(match self { Tableau1 => Tableau2, Tableau2 => Tableau3, Tableau3 => Tableau4, Tableau4 => Tableau5, Tableau5 => Tableau6, Tableau6 => Tableau7, Tableau7 => Tableau8, Tableau8 => Tableau9, Tableau9 => Tableau10, Tableau10 => return None, }) } /// Zero-based position in layout order. const fn index(self) -> usize { self as usize } } /// How many cards a [`SpiderMove`] picks up — at most 13, since a /// movable run is same-suit strictly-descending. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum RunLength { /// A single card. Run1 = 1, /// The top 2 cards. Run2 = 2, /// The top 3 cards. Run3 = 3, /// The top 4 cards. Run4 = 4, /// The top 5 cards. Run5 = 5, /// The top 6 cards. Run6 = 6, /// The top 7 cards. Run7 = 7, /// The top 8 cards. Run8 = 8, /// The top 9 cards. Run9 = 9, /// The top 10 cards. Run10 = 10, /// The top 11 cards. Run11 = 11, /// The top 12 cards. Run12 = 12, /// A full K→A run. Run13 = 13, } impl RunLength { const ITER_BEGIN: Self = Self::Run1; const fn next(self) -> Option { use RunLength::*; Some(match self { Run1 => Run2, Run2 => Run3, Run3 => Run4, Run4 => Run5, Run5 => Run6, Run6 => Run7, Run7 => Run8, Run8 => Run9, Run9 => Run10, Run10 => Run11, Run11 => Run12, Run12 => Run13, Run13 => return None, }) } /// Number of cards in the run. pub const fn len(self) -> usize { self as usize } /// True only for [`Self::Run1`]; provided because clippy expects an /// `is_empty` alongside `len`, and a run is never actually empty. pub const fn is_empty(self) -> bool { false } } /// Move the top [`RunLength`] cards (a same-suit descending run) from /// one pile to another. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct SpiderMove { /// Source pile. pub from: SpiderTableau, /// Number of cards picked up from the top of `from`. pub run: RunLength, /// Destination pile. pub to: SpiderTableau, } impl SpiderMove { const ITER_BEGIN: Self = Self { from: SpiderTableau::ITER_BEGIN, run: RunLength::ITER_BEGIN, to: SpiderTableau::ITER_BEGIN, }; const fn next(self) -> Option { let Self { from, run, to } = self; if let Some(to) = to.next() { return Some(Self { from, run, to }); } let to = SpiderTableau::ITER_BEGIN; if let Some(run) = run.next() { return Some(Self { from, run, to }); } let run = RunLength::ITER_BEGIN; if let Some(from) = from.next() { return Some(Self { from, run, to }); } None } } /// One atomic Spider action (the `Game::Instruction` type). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SpiderInstruction { /// Move a run between tableau piles. Move(SpiderMove), /// Deal one card from the stock onto every tableau pile. Deal, } impl SpiderInstruction { const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN); const fn next(self) -> Option { Some(match self { Self::Move(spider_move) => match spider_move.next() { Some(spider_move) => Self::Move(spider_move), None => Self::Deal, }, Self::Deal => return None, }) } } /// Exhaustive walk of the Spider instruction space, mirroring /// `klondike::KlondikeIter`. pub struct SpiderIter { instruction: Option, } impl SpiderIter { const fn new() -> Self { Self { instruction: Some(SpiderInstruction::ITER_BEGIN), } } } impl Iterator for SpiderIter { type Item = SpiderInstruction; fn next(&mut self) -> Option { let instruction = self.instruction; self.instruction = instruction?.next(); instruction } } // --------------------------------------------------------------------------- // Game state // --------------------------------------------------------------------------- /// Pure Spider game position: ten tableaus, the stock, and the count /// of completed runs. Everything else (undo, score bookkeeping) lives /// in the wrapping [`Session`]. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Spider { tableaus: [Pile; SPIDER_TABLEAUS], stock: Stack, completed_runs: u8, } /// Builds the 104-card Spider deck for a suit-count difficulty. /// /// Deck ids spread copies apart where possible (`Deck1..Deck4`), but /// 1- and 2-suit games necessarily contain identical `Card` values — /// see the module docs. fn build_deck(suits: SpiderSuits) -> Stack { let suit_set = suits.suits(); let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN); let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4]; let mut cards = Stack::new(); for copy in 0..copies { for &suit in suit_set { for rank in Rank::RANKS { cards.push(Card::new(decks[copy % decks.len()], suit, rank)); } } } cards } impl Spider { /// Deals a new seeded game at the given suit difficulty. pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self { use rand::SeedableRng; let mut rng = Rng::seed_from_u64(seed); Self::with_rng(&mut rng, suits) } /// Deals a new game from a caller-supplied RNG. pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self { // shuffle a new two-deck spread let mut deck = build_deck(suits); use rand::seq::SliceRandom; deck.shuffle(rng); let mut cards = deck.into_iter(); let tableaus = core::array::from_fn(|index| { // Piles 1–4 open with 5 face-down cards, piles 5–10 with 4; // one face-up card lands on each afterwards. let down_count = if index < 4 { 5 } else { 4 }; let stack: Stack = cards.by_ref().take(down_count).collect(); let mut pile = Pile::new_face_down(stack); if let Some(card) = cards.next() { pile.push(card); } pile }); let stock: Stack = cards.collect(); Self { tableaus, stock, completed_runs: 0, } } /// Face-up cards of a pile (bottom → top). pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] { self.tableaus[tableau.index()].face_up() } /// Face-down cards of a pile (bottom → top). pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] { self.tableaus[tableau.index()].face_down() } /// Topmost face-up card of a pile. pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> { self.tableaus[tableau.index()].face_up().last() } /// The stock (deals come off the top). pub const fn stock(&self) -> &Stack { &self.stock } /// Completed K→A runs removed from play so far. pub const fn completed_runs(&self) -> u8 { self.completed_runs } /// Length of the longest movable run on top of a pile: the maximal /// same-suit, strictly-descending face-up suffix. fn movable_run_len(&self, tableau: SpiderTableau) -> usize { let up = self.tableau_face_up_cards(tableau); let mut len = usize::from(!up.is_empty()); while len < up.len() { let above = &up[up.len() - len]; let below = &up[up.len() - len - 1]; let descends = below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank()); if !descends { break; } len += 1; } len } /// Whether a run move is legal in this position. fn is_move_valid(&self, spider_move: SpiderMove) -> bool { let SpiderMove { from, run, to } = spider_move; if from == to { return false; } if run.len() > self.movable_run_len(from) { return false; } let src_up = self.tableau_face_up_cards(from); // Bottom card of the moved run; `run.len() <= movable_run_len // <= src_up.len()` guarantees the index is in range. let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else { return false; }; match self.tableau_top_card(to) { // Build down regardless of suit. Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()), // Empty pile accepts anything (face-down remnant can't // exist without a face-up card — Pile flips eagerly). None => self.tableaus[to.index()].is_empty(), } } /// Whether the stock may deal: cards remain and no pile is empty. fn is_deal_valid(&self) -> bool { !self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty()) } /// Removes a completed K→A same-suit run from the top of a pile, /// if one is present. Returns `true` when a run was removed (and /// the next face-down card, if any, was flipped). fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool { let pile = &mut self.tableaus[tableau.index()]; let up = pile.face_up(); if up.len() < RUN_LEN { return false; } let run = &up[up.len() - RUN_LEN..]; let suit = run[0].suit(); // The run sits bottom-first (K → A); reversed, it must read // exactly Ace → King in one suit. let is_complete = run .iter() .rev() .zip(Rank::RANKS) .all(|(card, rank)| card.suit() == suit && card.rank() == rank); if !is_complete { return false; } let start = up.len() - RUN_LEN; let (_removed, _flipped) = pile.take_range_flip_up(start..); true } } impl Game for Spider { type Score = i32; type Stats = SpiderStats; type Config = SpiderConfig; type Instruction = SpiderInstruction; fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score { let scoring = &config.scoring; let moves = i32::try_from(stats.moves).unwrap_or(i32::MAX); let runs = i32::from(self.completed_runs); scoring.base + moves.saturating_mul(scoring.move_penalty) + runs * scoring.run_bonus } fn possible_instructions( &self, config: &Self::Config, ) -> impl Iterator + use<> { let state = self.clone(); let config = config.clone(); SpiderIter::new() .filter(move |&instruction| state.is_instruction_valid(&config, instruction)) } fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool { match instruction { SpiderInstruction::Deal => self.is_deal_valid(), SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move), } } fn process_instruction( &mut self, stats: &mut Self::Stats, config: &Self::Config, instruction: Self::Instruction, ) { // The trait offers no error channel; an invalid instruction is // a no-op rather than a panic (error policy: no panics in game // logic). Callers route through `SpiderGameState`, which // validates first and surfaces `MoveError`. if !self.is_instruction_valid(config, instruction) { return; } stats.moves += 1; match instruction { SpiderInstruction::Deal => { stats.deals += 1; for tableau in SpiderTableau::ALL { match self.stock.pop() { Some(card) => self.tableaus[tableau.index()].push(card), None => break, } } for tableau in SpiderTableau::ALL { if self.sweep_completed_run(tableau) { self.completed_runs += 1; stats.runs_completed += 1; } } } SpiderInstruction::Move(SpiderMove { from, run, to }) => { let src_len = self.tableaus[from.index()].face_up().len(); let (cards, _flipped) = self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..); self.tableaus[to.index()].extend(cards); if self.sweep_completed_run(to) { self.completed_runs += 1; stats.runs_completed += 1; } } } } fn is_win(&self) -> bool { self.completed_runs >= TOTAL_RUNS } } // --------------------------------------------------------------------------- // Session wrapper // --------------------------------------------------------------------------- /// Spider counterpart of [`crate::game_state::GameState`]: owns the /// upstream [`Session`] (undo history + score/undo bookkeeping) and /// exposes `Result<_, MoveError>` mutations, mirroring the Klondike /// wrapper's conventions. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpiderGameState { seed: u64, session: Session, } impl SpiderGameState { /// New seeded game with the default (1-suit) difficulty. pub fn new(seed: u64) -> Self { Self::new_with_suits(seed, SpiderSuits::default()) } /// New seeded game at an explicit suit difficulty. pub fn new_with_suits(seed: u64, suits: SpiderSuits) -> Self { let config = SessionConfig { inner: SpiderConfig { suits, scoring: SpiderScoring::default(), }, // Undoing costs one move, matching the familiar Spider // scoring; the upstream formula applies `undos × penalty`. undo_penalty: -1, ..SessionConfig::default() }; Self { seed, session: Session::new(Spider::with_seed(seed, suits), config), } } /// The deal seed this game was created from. pub const fn seed(&self) -> u64 { self.seed } /// Suit difficulty of this game. pub fn suits(&self) -> SpiderSuits { self.session.config().inner.suits } /// Current position (read-only). pub fn game(&self) -> &Spider { self.session.state().state() } /// The underlying upstream session, for solver access and replay — /// the same escape hatch [`crate::game_state::GameState::session`] /// provides (`session().solve()` honours the budgets configured in /// [`SessionConfig`]). pub const fn session(&self) -> &Session { &self.session } /// In-play score, clamped at 0 like the Klondike wrapper. pub fn score(&self) -> i32 { self.session .state() .score(self.session.stats(), self.session.config()) .max(0) } /// Whether all 8 runs are complete. pub fn is_won(&self) -> bool { self.session.is_win() } /// Total instructions applied (deals + moves), from history. pub fn move_count(&self) -> u32 { u32::try_from(self.session.history().len()).unwrap_or(u32::MAX) } /// Successful undos this session. pub fn undo_count(&self) -> u32 { self.session.stats().undos() } /// Legal instructions in the current position. pub fn possible_instructions(&self) -> Vec { self.session.possible_instructions().collect() } /// Applies one instruction. `Err` on illegal instructions and /// finished games; the session records the undo snapshot. pub fn apply_instruction(&mut self, instruction: SpiderInstruction) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } let config = &self.session.config().inner; if !self .session .state() .state() .is_instruction_valid(config, instruction) { return Err(MoveError::RuleViolation("move violates rules".into())); } self.session.process_instruction(instruction); Ok(()) } /// Restores the previous snapshot. Mirrors the Klondike wrapper's /// guards; the upstream session counts the undo for scoring. pub fn undo(&mut self) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } if self.session.history().is_empty() { return Err(MoveError::UndoStackEmpty); } self.session.undo(); Ok(()) } } // --------------------------------------------------------------------------- // Test support // --------------------------------------------------------------------------- #[cfg(any(test, feature = "test-support"))] impl Spider { /// Builds an arbitrary position for tests: per-pile /// `(face_down, face_up)` card lists plus stock and completed-run /// count. No card-count invariants are enforced — stacked /// positions for rule tests routinely use partial layouts. pub fn from_test_layout( piles: [(Vec, Vec); SPIDER_TABLEAUS], stock: Vec, completed_runs: u8, ) -> Self { let mut iter = piles.into_iter(); let tableaus = core::array::from_fn(|_| { // `piles` has exactly SPIDER_TABLEAUS entries. let (down, up) = iter.next().unwrap_or_default(); let mut pile = Pile::new_face_down(down.into_iter().collect()); pile.extend(up); pile }); Self { tableaus, stock: stock.into_iter().collect(), completed_runs, } } } #[cfg(any(test, feature = "test-support"))] impl SpiderGameState { /// Wraps an arbitrary position in a fresh session (empty history). pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self { let config = SessionConfig { inner: SpiderConfig { suits, scoring: SpiderScoring::default(), }, undo_penalty: -1, ..SessionConfig::default() }; Self { seed: 0, session: Session::new(game, config), } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::RunLength::*; use super::SpiderTableau::*; use super::*; /// `Card::new` shorthand for stacked positions. fn card(suit: Suit, rank: Rank) -> Card { Card::new(Deck::Deck1, suit, rank) } /// `SpiderMove` shorthand for rule tests. const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove { SpiderMove { from, run, to } } /// K→A same-suit run, bottom (King) first — the face-up order a /// completed run occupies on a pile. fn full_run(suit: Suit) -> Vec { let mut ranks: Vec = Rank::RANKS.to_vec(); ranks.reverse(); ranks.into_iter().map(|rank| card(suit, rank)).collect() } fn empty_layout() -> [(Vec, Vec); SPIDER_TABLEAUS] { core::array::from_fn(|_| (Vec::new(), Vec::new())) } /// A layout where every pile is non-empty (single junk card), so /// deal-validity tests can toggle exactly one condition. fn junk_layout() -> [(Vec, Vec); SPIDER_TABLEAUS] { core::array::from_fn(|_| (Vec::new(), vec![card(Suit::Clubs, Rank::King)])) } // -- dealing ---------------------------------------------------------- #[test] fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() { let game = Spider::with_seed(42, SpiderSuits::Four); for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() { let expected_down = if index < 4 { 5 } else { 4 }; assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down); assert_eq!( game.tableau_face_up_cards(tableau).len(), 1, "one card face-up" ); } assert_eq!(game.stock().len(), STOCK_SIZE); assert_eq!(game.completed_runs(), 0); } #[test] fn same_seed_same_deal_different_seed_different_deal() { let a = Spider::with_seed(7, SpiderSuits::Four); let b = Spider::with_seed(7, SpiderSuits::Four); let c = Spider::with_seed(8, SpiderSuits::Four); assert_eq!(a, b); assert_ne!(a, c); } #[test] fn suit_difficulty_controls_suit_spread() { let one = build_deck(SpiderSuits::One); let two = build_deck(SpiderSuits::Two); let four = build_deck(SpiderSuits::Four); for deck in [&one, &two, &four] { assert_eq!(deck.len(), SPIDER_DECK_SIZE); } assert!(one.iter().all(|c| c.suit() == Suit::Spades)); assert!( two.iter() .all(|c| matches!(c.suit(), Suit::Spades | Suit::Hearts)) ); for suit in Suit::SUITS { assert_eq!( four.iter().filter(|c| c.suit() == suit).count(), 2 * RUN_LEN, "four-suit deck has exactly two copies per suit" ); } } // -- move rules ------------------------------------------------------- #[test] fn single_card_builds_down_regardless_of_suit() { let mut layout = empty_layout(); layout[0].1 = vec![card(Suit::Hearts, Rank::Five)]; layout[1].1 = vec![card(Suit::Spades, Rank::Six)]; let game = Spider::from_test_layout(layout, Vec::new(), 0); assert!( game.is_move_valid(mv(Tableau1, Run1, Tableau2)), "5♥ onto 6♠ is legal" ); assert!( !game.is_move_valid(mv(Tableau2, Run1, Tableau1)), "6♠ onto 5♥ is not" ); } #[test] fn only_same_suit_runs_are_movable_as_a_group() { let mut layout = empty_layout(); // Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed). layout[0].1 = vec![ card(Suit::Spades, Rank::Seven), card(Suit::Spades, Rank::Six), ]; layout[1].1 = vec![ card(Suit::Hearts, Rank::Seven), card(Suit::Spades, Rank::Six), ]; // Destinations: 8♣ (for the pair), 7♦ (for a lone six). layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)]; layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)]; let game = Spider::from_test_layout(layout, Vec::new(), 0); assert!( game.is_move_valid(mv(Tableau1, Run2, Tableau3)), "same-suit pair moves" ); assert!( !game.is_move_valid(mv(Tableau2, Run2, Tableau3)), "mixed-suit pair does not" ); assert!( game.is_move_valid(mv(Tableau2, Run1, Tableau4)), "its top card alone does" ); } #[test] fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold() { let mut layout = empty_layout(); layout[0].1 = vec![ card(Suit::Spades, Rank::Nine), card(Suit::Spades, Rank::Eight), ]; // Pile 2 deliberately left empty. layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)]; let game = Spider::from_test_layout(layout, Vec::new(), 0); assert!( game.is_move_valid(mv(Tableau1, Run2, Tableau2)), "run onto empty pile" ); assert!( game.is_move_valid(mv(Tableau3, Run1, Tableau2)), "single onto empty pile" ); assert!( !game.is_move_valid(mv(Tableau1, Run2, Tableau3)), "9♠8♠ cannot land on 9♥ (needs a 10)" ); } #[test] fn invalid_moves_rejected_self_move_and_overlong_run() { // Out-of-range piles and zero-card runs are unrepresentable in // `SpiderMove` — the enums only span legal values — so the only // structurally invalid shapes left are self-moves and runs // longer than the movable suffix. let mut layout = empty_layout(); layout[0].1 = vec![card(Suit::Spades, Rank::Five)]; let game = Spider::from_test_layout(layout, Vec::new(), 0); assert!( !game.is_move_valid(mv(Tableau1, Run1, Tableau1)), "self-move" ); assert!( !game.is_move_valid(mv(Tableau1, Run2, Tableau2)), "count exceeds run" ); } // -- dealing from stock ------------------------------------------------- #[test] fn deal_requires_stock_and_no_empty_pile() { let stock = vec![card(Suit::Spades, Rank::Ace); 10]; let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0); assert!(game.is_deal_valid()); let mut with_gap = junk_layout(); with_gap[3].1.clear(); let game = Spider::from_test_layout(with_gap, stock, 0); assert!(!game.is_deal_valid(), "empty pile blocks the deal"); let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0); assert!(!game.is_deal_valid(), "empty stock blocks the deal"); } #[test] fn deal_puts_one_card_on_every_pile() { let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two); let before: Vec = SpiderTableau::ALL .into_iter() .map(|tableau| state.game().tableau_face_up_cards(tableau).len()) .collect(); state .apply_instruction(SpiderInstruction::Deal) .expect("deal is legal on a fresh game"); for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) { assert_eq!( state.game().tableau_face_up_cards(tableau).len(), previous + 1 ); } assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS); } #[test] fn stock_supports_exactly_five_deals() { let mut state = SpiderGameState::new_with_suits(11, SpiderSuits::One); for _ in 0..5 { state .apply_instruction(SpiderInstruction::Deal) .expect("five deals must all be legal on untouched piles"); } assert_eq!(state.game().stock().len(), 0); assert!(matches!( state.apply_instruction(SpiderInstruction::Deal), Err(MoveError::RuleViolation(_)) )); } // -- run completion & win ---------------------------------------------- #[test] fn completing_a_run_removes_it_and_flips_the_card_beneath() { let mut layout = empty_layout(); // Pile 1: one face-down card under K..2 of spades; the ace // arrives from pile 2. let mut run = full_run(Suit::Spades); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run); layout[1].1 = vec![ace]; let game = Spider::from_test_layout(layout, Vec::new(), 0); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One); state .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1))) .expect("ace onto two completes the run"); assert_eq!(state.game().completed_runs(), 1); assert_eq!( state.game().tableau_face_up_cards(Tableau1), &[card(Suit::Hearts, Rank::Nine)], "run removed and the buried card flipped face-up" ); assert!(state.game().tableau_face_up_cards(Tableau2).is_empty()); } #[test] fn eighth_run_wins_the_game() { let mut layout = empty_layout(); let mut run = full_run(Suit::Spades); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); layout[0].1 = run; layout[1].1 = vec![ace]; let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One); assert!(!state.is_won()); state .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1))) .expect("winning move is legal"); assert!(state.is_won()); assert!(matches!( state.apply_instruction(SpiderInstruction::Deal), Err(MoveError::GameAlreadyWon) )); } // -- session wrapper ----------------------------------------------------- #[test] fn undo_restores_position_and_counts() { let mut state = SpiderGameState::new_with_suits(21, SpiderSuits::Two); let fresh = state.game().clone(); assert!(matches!(state.undo(), Err(MoveError::UndoStackEmpty))); state .apply_instruction(SpiderInstruction::Deal) .expect("deal"); assert_ne!(*state.game(), fresh); state.undo().expect("one snapshot to restore"); assert_eq!(*state.game(), fresh); assert_eq!(state.undo_count(), 1); assert_eq!(state.move_count(), 0, "undo pops the history entry"); } #[test] fn score_follows_base_move_penalty_and_run_bonus() { let mut state = SpiderGameState::new_with_suits(5, SpiderSuits::One); assert_eq!(state.score(), 500, "fresh game starts at base"); state .apply_instruction(SpiderInstruction::Deal) .expect("deal"); assert_eq!(state.score(), 499, "one move costs one point"); } #[test] fn rule_violation_surfaces_move_error() { let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One); let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1))); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } // -- generated instructions ---------------------------------------------- #[test] fn possible_instructions_are_all_valid_and_include_deal() { let state = SpiderGameState::new_with_suits(13, SpiderSuits::Four); let config = SpiderConfig::default(); let instructions = state.possible_instructions(); assert!( instructions.contains(&SpiderInstruction::Deal), "fresh game has no empty pile, so Deal must be offered" ); for instruction in instructions { assert!(state.game().is_instruction_valid(&config, instruction)); } } #[test] fn instruction_iter_walks_the_full_space_once() { // 10 sources × 13 run lengths × 10 destinations, plus Deal. let count = SpiderIter::new().count(); assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1); } } #[cfg(test)] mod proptests { use super::*; use proptest::prelude::*; /// Total cards across tableaus + stock + removed runs must always /// equal 104, and every generated instruction must validate — for /// any seed, difficulty, and random walk through legal moves. fn card_conservation(game: &Spider) -> usize { let on_piles: usize = SpiderTableau::ALL .into_iter() .map(|tableau| { game.tableau_face_up_cards(tableau).len() + game.tableau_face_down_cards(tableau).len() }) .sum(); on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN } proptest! { #[test] fn random_walks_conserve_cards_and_stay_valid( seed in any::(), suit_pick in 0u8..3, steps in 0usize..40, choices in proptest::collection::vec(any::(), 40), ) { let suits = match suit_pick { 0 => SpiderSuits::One, 1 => SpiderSuits::Two, _ => SpiderSuits::Four, }; let mut state = SpiderGameState::new_with_suits(seed, suits); prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE); for choice in choices.iter().take(steps) { let legal = state.possible_instructions(); if legal.is_empty() { break; } let instruction = legal[*choice as usize % legal.len()]; prop_assert!(state.apply_instruction(instruction).is_ok()); prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE); } } } }