diff --git a/Cargo.lock b/Cargo.lock index a3d27f4..c66cfd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7331,6 +7331,7 @@ dependencies = [ "card_game", "klondike", "proptest", + "rand 0.10.1", "serde", "thiserror 2.0.18", ] diff --git a/solitaire_core/Cargo.toml b/solitaire_core/Cargo.toml index 35a7cb3..0ab1be2 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -16,6 +16,11 @@ serde = { workspace = true } thiserror = { workspace = true } klondike = { workspace = true } card_game = { workspace = true } +# Deliberately NOT the workspace rand (0.9): this pins the exact dep the +# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve +# against the same crate version as `klondike::Rng` and Spider deals go +# through the identical shuffle stack as Klondike deals. +rand = { version = "0.10.1", default-features = false, features = ["std_rng"] } [lints] workspace = true diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index c3c36c8..ec35564 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -25,8 +25,8 @@ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, So // 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, + RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove, + SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau, }; /// All four foundation slots, in slot order. diff --git a/solitaire_core/src/spider.rs b/solitaire_core/src/spider.rs index 1ee34f8..e4b4bb4 100644 --- a/solitaire_core/src/spider.rs +++ b/solitaire_core/src/spider.rs @@ -22,11 +22,10 @@ //! //! ## 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. +//! 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) //! @@ -43,6 +42,10 @@ 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). @@ -146,24 +149,212 @@ impl SpiderStats { } // --------------------------------------------------------------------------- -// Instruction +// 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, - /// 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, - }, +} + +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 + } } // --------------------------------------------------------------------------- @@ -174,45 +365,22 @@ pub enum SpiderInstruction { /// of completed runs. Everything else (undo, score bookkeeping) lives /// in the wrapping [`Session`]. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SpiderGame { +pub struct Spider { 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 { +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 = Vec::with_capacity(SPIDER_DECK_SIZE); + let mut cards = Stack::new(); for copy in 0..copies { for &suit in suit_set { for rank in Rank::RANKS { @@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec { cards } -impl SpiderGame { +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); - shuffle(&mut deck, seed); + use rand::seq::SliceRandom; + deck.shuffle(rng); 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; + // 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(); @@ -250,22 +427,24 @@ impl SpiderGame { } } - /// 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-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 pile `index` (bottom → top). - pub fn tableau_face_down(&self, index: usize) -> &[Card] { - self.tableaus - .get(index) - .map_or(&[], |pile| pile.face_down()) + /// Face-down cards of a pile (bottom → top). + pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] { + self.tableaus[tableau.index()].face_down() } - /// Cards remaining in the stock. - pub fn stock_len(&self) -> usize { - self.stock.len() + /// 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. @@ -273,19 +452,16 @@ impl SpiderGame { 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(); + /// 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() && below.rank() as u8 == above.rank() as u8 + 1; + below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank()); if !descends { break; } @@ -294,27 +470,27 @@ impl SpiderGame { 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 { + /// 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 count > self.movable_run_len(from) { + if run.len() > 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 { + 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.tableaus[to].face_up().last() { + match self.tableau_top_card(to) { // Build down regardless of suit. - Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1, + 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].is_empty(), + None => self.tableaus[to.index()].is_empty(), } } @@ -323,22 +499,24 @@ impl SpiderGame { !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; - }; + /// 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(); - let is_complete = run.iter().enumerate().all(|(offset, card)| { - card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8 - }); + // 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; } @@ -348,7 +526,7 @@ impl SpiderGame { } } -impl Game for SpiderGame { +impl Game for Spider { type Score = i32; type Stats = SpiderStats; type Config = SpiderConfig; @@ -365,34 +543,16 @@ impl Game for SpiderGame { &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() + 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 { from, to, count } => self.is_move_valid(from, to, count), + SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move), } } @@ -413,24 +573,24 @@ impl Game for SpiderGame { match instruction { SpiderInstruction::Deal => { stats.deals += 1; - for index in 0..SPIDER_TABLEAUS { + for tableau in SpiderTableau::ALL { match self.stock.pop() { - Some(card) => self.tableaus[index].push(card), + Some(card) => self.tableaus[tableau.index()].push(card), None => break, } } - for index in 0..SPIDER_TABLEAUS { - if self.sweep_completed_run(index) { + for tableau in SpiderTableau::ALL { + if self.sweep_completed_run(tableau) { 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); + 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; @@ -455,7 +615,7 @@ impl Game for SpiderGame { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpiderGameState { seed: u64, - session: Session, + session: Session, } impl SpiderGameState { @@ -478,7 +638,7 @@ impl SpiderGameState { }; Self { seed, - session: Session::new(SpiderGame::with_seed(seed, suits), config), + session: Session::new(Spider::with_seed(seed, suits), config), } } @@ -493,10 +653,18 @@ impl SpiderGameState { } /// Current position (read-only). - pub fn game(&self) -> &SpiderGame { + 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 @@ -563,7 +731,7 @@ impl SpiderGameState { // --------------------------------------------------------------------------- #[cfg(any(test, feature = "test-support"))] -impl SpiderGame { +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 @@ -592,7 +760,7 @@ impl SpiderGame { #[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 { + pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self { let config = SessionConfig { inner: SpiderConfig { suits, @@ -614,6 +782,8 @@ impl SpiderGameState { #[cfg(test)] mod tests { + use super::RunLength::*; + use super::SpiderTableau::*; use super::*; /// `Card::new` shorthand for stacked positions. @@ -621,6 +791,11 @@ mod tests { 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 { @@ -643,21 +818,25 @@ mod tests { #[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 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(index).len(), expected_down); - assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up"); + 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.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); + 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); } @@ -691,15 +870,21 @@ mod tests { 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"); + 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 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed). + // 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), @@ -711,10 +896,19 @@ mod tests { // 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"); + 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] @@ -724,27 +918,40 @@ mod tests { card(Suit::Spades, Rank::Nine), card(Suit::Spades, Rank::Eight), ]; - // Pile 1 deliberately left empty. + // Pile 2 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"); + let game = Spider::from_test_layout(layout, Vec::new(), 0); assert!( - !game.is_move_valid(0, 2, 2), + 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_out_of_range_zero_count_self_move() { + 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 = 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"); + 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 ------------------------------------------------- @@ -752,31 +959,35 @@ mod tests { #[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); + 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 = SpiderGame::from_test_layout(with_gap, stock, 0); + let game = Spider::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); + 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 = (0..SPIDER_TABLEAUS) - .map(|i| state.game().tableau_face_up(i).len()) + 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 (index, previous) in before.iter().enumerate() { - assert_eq!(state.game().tableau_face_up(index).len(), previous + 1); + 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); + assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS); } #[test] @@ -787,7 +998,7 @@ mod tests { .apply_instruction(SpiderInstruction::Deal) .expect("five deals must all be legal on untouched piles"); } - assert_eq!(state.game().stock_len(), 0); + assert_eq!(state.game().stock().len(), 0); assert!(matches!( state.apply_instruction(SpiderInstruction::Deal), Err(MoveError::RuleViolation(_)) @@ -799,30 +1010,26 @@ mod tests { #[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. + // 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 = SpiderGame::from_test_layout(layout, Vec::new(), 0); + 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 { - from: 1, - to: 0, - count: 1, - }) + .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(0), + 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(1).is_empty()); + assert!(state.game().tableau_face_up_cards(Tableau2).is_empty()); } #[test] @@ -832,16 +1039,12 @@ mod tests { 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 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 { - from: 1, - to: 0, - count: 1, - }) + .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1))) .expect("winning move is legal"); assert!(state.is_won()); assert!(matches!( @@ -882,11 +1085,7 @@ mod tests { #[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, - }); + let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1))); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } @@ -905,6 +1104,13 @@ mod tests { 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)] @@ -915,11 +1121,15 @@ mod proptests { /// 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()) + 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 + on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN } proptest! {