From 2c2f0b592a2ff3f3e5e9bad81387c9552ece01b8 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 16:20:51 -0700 Subject: [PATCH] feat(core): Spider rules as a second card_game::Game implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-deck (104-card) Spider on the upstream Stack/Pile containers, exercising the multi-deck Card encoding for the first time: - SpiderGame: 10-pile deal (4x6 + 6x5), build-down-any-suit, same-suit-run pickup, deal-10 gated on no empty pile, automatic K->A run removal, win at 8 runs - SpiderSuits difficulty (1/2/4 suits over the same 104 cards); 1- and 2-suit games contain identical Card values by construction (documented — engine entity mapping will need positional keys) - Seeded deals via inline SplitMix64 + Fisher-Yates (core has no rand dep; Spider's seed space is deliberately self-contained) - SpiderGameState session wrapper mirroring GameState conventions: Result<_, MoveError> mutations, upstream undo/score bookkeeping, Microsoft-style scoring (500 base, -1/move, +100/run, -1/undo) - 17 unit tests + card-conservation/validity proptest over random legal walks; stacked-deal win-path test included Co-Authored-By: Claude Fable 5 --- solitaire_core/src/lib.rs | 8 + solitaire_core/src/spider.rs | 952 +++++++++++++++++++++++++++++++++++ 2 files changed, 960 insertions(+) create mode 100644 solitaire_core/src/spider.rs diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 9e8ff74..c3c36c8 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; pub mod game_state; pub mod klondike_adapter; pub mod scoring; +pub mod spider; // Re-export the upstream types that cross the solitaire_core API boundary so // downstream crates (engine, wasm) can import from one place without a direct @@ -21,6 +22,13 @@ pub use klondike::{ // former `solitaire_data::solver` wrapper module. pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; +// Spider rules (second `card_game::Game` implementation; engine UI is a +// later phase — nothing outside solitaire_core consumes these yet). +pub use spider::{ + SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats, + SpiderSuits, +}; + /// All four foundation slots, in slot order. /// /// Canonical iteration source for `Foundation` — upstream `klondike` has no diff --git a/solitaire_core/src/spider.rs b/solitaire_core/src/spider.rs new file mode 100644 index 0000000..1ee34f8 --- /dev/null +++ b/solitaire_core/src/spider.rs @@ -0,0 +1,952 @@ +//! 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 with an inline SplitMix64 + Fisher–Yates shuffle: +//! `solitaire_core` has no `rand` dependency (and adding one needs +//! explicit approval), so Spider's seed space is deliberately +//! self-contained rather than shared with Klondike's `StdRng` seeds. +//! 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; + +/// 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 + } +} + +// --------------------------------------------------------------------------- +// Instruction +// --------------------------------------------------------------------------- + +/// One atomic Spider action (the `Game::Instruction` type). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SpiderInstruction { + /// Deal one card from the stock onto every tableau pile. + Deal, + /// Move the top `count` face-up cards (a same-suit descending run) + /// from pile `from` to pile `to`. Pile indices are `0..10`. + Move { + /// Source pile index. + from: u8, + /// Destination pile index. + to: u8, + /// Number of cards in the moved run (≥ 1). + count: u8, + }, +} + +// --------------------------------------------------------------------------- +// 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 SpiderGame { + tableaus: [Pile; SPIDER_TABLEAUS], + stock: Stack, + completed_runs: u8, +} + +/// SplitMix64 step — small, well-known, and dependency-free. Spider +/// only needs a deterministic shuffle, not cryptographic quality. +const fn splitmix64(state: u64) -> (u64, u64) { + let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + (state, z ^ (z >> 31)) +} + +/// In-place Fisher–Yates driven by SplitMix64. The modulo bias is +/// astronomically small for n ≤ 104 and irrelevant for gameplay. +fn shuffle(cards: &mut [Card], seed: u64) { + let mut state = seed; + for i in (1..cards.len()).rev() { + let (next_state, r) = splitmix64(state); + state = next_state; + #[allow(clippy::cast_possible_truncation)] + let j = (r % (i as u64 + 1)) as usize; + cards.swap(i, j); + } +} + +/// 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) -> Vec { + 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 = Vec::with_capacity(SPIDER_DECK_SIZE); + 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 SpiderGame { + /// Deals a new seeded game at the given suit difficulty. + pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self { + let mut deck = build_deck(suits); + shuffle(&mut deck, seed); + let mut cards = deck.into_iter(); + + let tableaus = core::array::from_fn(|index| { + // Piles 0–3 open with 5 face-down cards, piles 4–9 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 pile `index` (bottom → top). Empty slice for + /// out-of-range indices. + pub fn tableau_face_up(&self, index: usize) -> &[Card] { + self.tableaus.get(index).map_or(&[], |pile| pile.face_up()) + } + + /// Face-down cards of pile `index` (bottom → top). + pub fn tableau_face_down(&self, index: usize) -> &[Card] { + self.tableaus + .get(index) + .map_or(&[], |pile| pile.face_down()) + } + + /// Cards remaining in the stock. + pub fn stock_len(&self) -> usize { + self.stock.len() + } + + /// 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 pile `index`: the + /// maximal same-suit, strictly-descending face-up suffix. + fn movable_run_len(&self, index: usize) -> usize { + let Some(pile) = self.tableaus.get(index) else { + return 0; + }; + let up = pile.face_up(); + 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() && below.rank() as u8 == above.rank() as u8 + 1; + if !descends { + break; + } + len += 1; + } + len + } + + /// Whether `Move { from, to, count }` is legal in this position. + fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool { + let (from, to, count) = (from as usize, to as usize, count as usize); + if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 { + return false; + } + if count > self.movable_run_len(from) { + return false; + } + let src_up = self.tableaus[from].face_up(); + // Bottom card of the moved run; `count <= movable_run_len <= + // src_up.len()` guarantees the index is in range. + let Some(moved_bottom) = src_up.get(src_up.len() - count) else { + return false; + }; + match self.tableaus[to].face_up().last() { + // Build down regardless of suit. + Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1, + // Empty pile accepts anything (face-down remnant can't + // exist without a face-up card — Pile flips eagerly). + None => self.tableaus[to].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 pile + /// `index`, 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, index: usize) -> bool { + let Some(pile) = self.tableaus.get_mut(index) else { + return false; + }; + let up = pile.face_up(); + if up.len() < RUN_LEN { + return false; + } + let run = &up[up.len() - RUN_LEN..]; + let suit = run[0].suit(); + let is_complete = run.iter().enumerate().all(|(offset, card)| { + card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8 + }); + if !is_complete { + return false; + } + let start = up.len() - RUN_LEN; + let (_removed, _flipped) = pile.take_range_flip_up(start..); + true + } +} + +impl Game for SpiderGame { + 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 mut out = Vec::new(); + if self.is_deal_valid() { + out.push(SpiderInstruction::Deal); + } + for from in 0..SPIDER_TABLEAUS { + let max_run = self.movable_run_len(from); + for count in 1..=max_run { + for to in 0..SPIDER_TABLEAUS { + #[allow(clippy::cast_possible_truncation)] + let instruction = SpiderInstruction::Move { + from: from as u8, + to: to as u8, + count: count as u8, + }; + if self.is_move_valid(from as u8, to as u8, count as u8) { + out.push(instruction); + } + } + } + } + let _ = config; + out.into_iter() + } + + fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool { + match instruction { + SpiderInstruction::Deal => self.is_deal_valid(), + SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count), + } + } + + 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 index in 0..SPIDER_TABLEAUS { + match self.stock.pop() { + Some(card) => self.tableaus[index].push(card), + None => break, + } + } + for index in 0..SPIDER_TABLEAUS { + if self.sweep_completed_run(index) { + self.completed_runs += 1; + stats.runs_completed += 1; + } + } + } + SpiderInstruction::Move { from, to, count } => { + let (from, to, count) = (from as usize, to as usize, count as usize); + let src_len = self.tableaus[from].face_up().len(); + let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..); + self.tableaus[to].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(SpiderGame::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) -> &SpiderGame { + self.session.state().state() + } + + /// 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 SpiderGame { + /// 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: SpiderGame, 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::*; + + /// `Card::new` shorthand for stacked positions. + fn card(suit: Suit, rank: Rank) -> Card { + Card::new(Deck::Deck1, suit, rank) + } + + /// 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 = SpiderGame::with_seed(42, SpiderSuits::Four); + for index in 0..SPIDER_TABLEAUS { + let expected_down = if index < 4 { 5 } else { 4 }; + assert_eq!(game.tableau_face_down(index).len(), expected_down); + assert_eq!(game.tableau_face_up(index).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 = SpiderGame::with_seed(7, SpiderSuits::Four); + let b = SpiderGame::with_seed(7, SpiderSuits::Four); + let c = SpiderGame::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 = SpiderGame::from_test_layout(layout, Vec::new(), 0); + assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal"); + assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not"); + } + + #[test] + fn only_same_suit_runs_are_movable_as_a_group() { + let mut layout = empty_layout(); + // Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 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 = SpiderGame::from_test_layout(layout, Vec::new(), 0); + assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves"); + assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not"); + assert!(game.is_move_valid(1, 3, 1), "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 1 deliberately left empty. + layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)]; + let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); + assert!(game.is_move_valid(0, 1, 2), "run onto empty pile"); + assert!(game.is_move_valid(2, 1, 1), "single onto empty pile"); + assert!( + !game.is_move_valid(0, 2, 2), + "9♠8♠ cannot land on 9♥ (needs a 10)" + ); + } + + #[test] + fn invalid_moves_rejected_out_of_range_zero_count_self_move() { + let mut layout = empty_layout(); + layout[0].1 = vec![card(Suit::Spades, Rank::Five)]; + let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); + assert!(!game.is_move_valid(0, 0, 1), "self-move"); + assert!(!game.is_move_valid(0, 1, 0), "zero count"); + assert!(!game.is_move_valid(0, 10, 1), "destination out of range"); + assert!(!game.is_move_valid(10, 0, 1), "source out of range"); + assert!(!game.is_move_valid(0, 1, 2), "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 = SpiderGame::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 = SpiderGame::from_test_layout(with_gap, stock, 0); + assert!(!game.is_deal_valid(), "empty pile blocks the deal"); + + let game = SpiderGame::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 = (0..SPIDER_TABLEAUS) + .map(|i| state.game().tableau_face_up(i).len()) + .collect(); + state + .apply_instruction(SpiderInstruction::Deal) + .expect("deal is legal on a fresh game"); + for (index, previous) in before.iter().enumerate() { + assert_eq!(state.game().tableau_face_up(index).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 0: one face-down card under K..2 of spades; the ace + // arrives from pile 1. + 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 = SpiderGame::from_test_layout(layout, Vec::new(), 0); + let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One); + + state + .apply_instruction(SpiderInstruction::Move { + from: 1, + to: 0, + count: 1, + }) + .expect("ace onto two completes the run"); + + assert_eq!(state.game().completed_runs(), 1); + assert_eq!( + state.game().tableau_face_up(0), + &[card(Suit::Hearts, Rank::Nine)], + "run removed and the buried card flipped face-up" + ); + assert!(state.game().tableau_face_up(1).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 = SpiderGame::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 { + from: 1, + to: 0, + count: 1, + }) + .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 { + from: 0, + to: 0, + count: 1, + }); + 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)); + } + } +} + +#[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: &SpiderGame) -> usize { + let on_piles: usize = (0..SPIDER_TABLEAUS) + .map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).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); + } + } + } +} -- 2.47.3