refactor(core): align Spider with upstream card_game idioms
Test / test (pull_request) Successful in 37m4s

Addresses upstream author review of the Spider core (PR #157):

- RNG: drop the hand-rolled SplitMix64 + Fisher-Yates; deals now use
  rand::rngs::StdRng seeded via seed_from_u64 + SliceRandom::shuffle,
  exactly like klondike::with_seed. solitaire_core gains the same
  pinned rand dep klondike uses (0.10.1, std_rng only — already in the
  lock, no new transitive deps). Deals for a given seed change; Spider
  has no persisted games yet, so nothing breaks.
- Enums over integers: pile indices and card counts in the instruction
  type are now SpiderTableau (Tableau1..Tableau10, with an ALL const)
  and RunLength (Run1..Run13); out-of-range piles and zero-card moves
  are unrepresentable. Rank comparisons use Rank::checked_add instead
  of u8 arithmetic.
- Fixed-size containers: build_deck returns Stack<104> instead of
  Vec<Card>; possible_instructions is a const-iterated SpiderIter +
  validity filter (klondike's KlondikeIter pattern) instead of
  collecting into a Vec.
- Upstream naming: SpiderGame -> Spider (matches Klondike),
  tableau_face_up/_down -> tableau_face_up_cards/_down_cards,
  stock_len -> stock() accessor, with_rng added alongside with_seed.
- Solver access: SpiderGameState now exposes session(), the same
  escape hatch GameState has — the wrapper no longer pretends to hide
  solve(), which SessionConfig budgets already gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 17:58:57 -07:00
parent fe3c3aed31
commit 1d2b6dc5de
4 changed files with 405 additions and 189 deletions
Generated
+1
View File
@@ -7331,6 +7331,7 @@ dependencies = [
"card_game", "card_game",
"klondike", "klondike",
"proptest", "proptest",
"rand 0.10.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
+5
View File
@@ -16,6 +16,11 @@ serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
card_game = { 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] [lints]
workspace = true workspace = true
+2 -2
View File
@@ -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 // Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet). // later phase — nothing outside solitaire_core consumes these yet).
pub use spider::{ pub use spider::{
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats, RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderSuits, SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
}; };
/// All four foundation slots, in slot order. /// All four foundation slots, in slot order.
+396 -186
View File
@@ -22,11 +22,10 @@
//! //!
//! ## Determinism //! ## Determinism
//! //!
//! Deals are seeded with an inline SplitMix64 + FisherYates shuffle: //! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
//! `solitaire_core` has no `rand` dependency (and adding one needs //! `rand::rngs::StdRng` alias `klondike` exports, seeded through
//! explicit approval), so Spider's seed space is deliberately //! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
//! self-contained rather than shared with Klondike's `StdRng` seeds. //! shuffle. The same seed + suit count always produces the same deal.
//! The same seed + suit count always produces the same deal.
//! //!
//! ## Card identity caveat (engine integration, later phase) //! ## Card identity caveat (engine integration, later phase)
//! //!
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
use crate::error::MoveError; 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. /// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10; pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks). /// 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<Self> {
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<Self> {
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<Self> {
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). /// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction { pub enum SpiderInstruction {
/// Move a run between tableau piles.
Move(SpiderMove),
/// Deal one card from the stock onto every tableau pile. /// Deal one card from the stock onto every tableau pile.
Deal, 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 { impl SpiderInstruction {
/// Source pile index. const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
from: u8, const fn next(self) -> Option<Self> {
/// Destination pile index. Some(match self {
to: u8, Self::Move(spider_move) => match spider_move.next() {
/// Number of cards in the moved run (≥ 1). Some(spider_move) => Self::Move(spider_move),
count: u8, None => Self::Deal,
}, },
Self::Deal => return None,
})
}
}
/// Exhaustive walk of the Spider instruction space, mirroring
/// `klondike::KlondikeIter`.
pub struct SpiderIter {
instruction: Option<SpiderInstruction>,
}
impl SpiderIter {
const fn new() -> Self {
Self {
instruction: Some(SpiderInstruction::ITER_BEGIN),
}
}
}
impl Iterator for SpiderIter {
type Item = SpiderInstruction;
fn next(&mut self) -> Option<Self::Item> {
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 /// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`]. /// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame { pub struct Spider {
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS], tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
stock: Stack<STOCK_SIZE>, stock: Stack<STOCK_SIZE>,
completed_runs: u8, 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 FisherYates 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. /// Builds the 104-card Spider deck for a suit-count difficulty.
/// ///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but /// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values — /// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs. /// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> { fn build_deck(suits: SpiderSuits) -> Stack<SPIDER_DECK_SIZE> {
let suit_set = suits.suits(); let suit_set = suits.suits();
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN); let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4]; 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 copy in 0..copies {
for &suit in suit_set { for &suit in suit_set {
for rank in Rank::RANKS { for rank in Rank::RANKS {
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
cards cards
} }
impl SpiderGame { impl Spider {
/// Deals a new seeded game at the given suit difficulty. /// Deals a new seeded game at the given suit difficulty.
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self { 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); let mut deck = build_deck(suits);
shuffle(&mut deck, seed); use rand::seq::SliceRandom;
deck.shuffle(rng);
let mut cards = deck.into_iter(); let mut cards = deck.into_iter();
let tableaus = core::array::from_fn(|index| { let tableaus = core::array::from_fn(|index| {
// Piles 03 open with 5 face-down cards, piles 49 with 4; // Piles 14 open with 5 face-down cards, piles 510 with 4;
// one face-up card lands on each afterwards. // one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 }; let down_count = if index < 4 { 5 } else { 4 };
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect(); let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
@@ -250,22 +427,24 @@ impl SpiderGame {
} }
} }
/// Face-up cards of pile `index` (bottom → top). Empty slice for /// Face-up cards of a pile (bottom → top).
/// out-of-range indices. pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
pub fn tableau_face_up(&self, index: usize) -> &[Card] { self.tableaus[tableau.index()].face_up()
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
} }
/// Face-down cards of pile `index` (bottom → top). /// Face-down cards of a pile (bottom → top).
pub fn tableau_face_down(&self, index: usize) -> &[Card] { pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus self.tableaus[tableau.index()].face_down()
.get(index)
.map_or(&[], |pile| pile.face_down())
} }
/// Cards remaining in the stock. /// Topmost face-up card of a pile.
pub fn stock_len(&self) -> usize { pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
self.stock.len() self.tableaus[tableau.index()].face_up().last()
}
/// The stock (deals come off the top).
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
&self.stock
} }
/// Completed K→A runs removed from play so far. /// Completed K→A runs removed from play so far.
@@ -273,19 +452,16 @@ impl SpiderGame {
self.completed_runs self.completed_runs
} }
/// Length of the longest movable run on top of pile `index`: the /// Length of the longest movable run on top of a pile: the maximal
/// maximal same-suit, strictly-descending face-up suffix. /// same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, index: usize) -> usize { fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
let Some(pile) = self.tableaus.get(index) else { let up = self.tableau_face_up_cards(tableau);
return 0;
};
let up = pile.face_up();
let mut len = usize::from(!up.is_empty()); let mut len = usize::from(!up.is_empty());
while len < up.len() { while len < up.len() {
let above = &up[up.len() - len]; let above = &up[up.len() - len];
let below = &up[up.len() - len - 1]; let below = &up[up.len() - len - 1];
let descends = 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 { if !descends {
break; break;
} }
@@ -294,27 +470,27 @@ impl SpiderGame {
len len
} }
/// Whether `Move { from, to, count }` is legal in this position. /// Whether a run move is legal in this position.
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool { fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
let (from, to, count) = (from as usize, to as usize, count as usize); let SpiderMove { from, run, to } = spider_move;
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 { if from == to {
return false; return false;
} }
if count > self.movable_run_len(from) { if run.len() > self.movable_run_len(from) {
return false; return false;
} }
let src_up = self.tableaus[from].face_up(); let src_up = self.tableau_face_up_cards(from);
// Bottom card of the moved run; `count <= movable_run_len <= // Bottom card of the moved run; `run.len() <= movable_run_len
// src_up.len()` guarantees the index is in range. // <= src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - count) else { let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
return false; return false;
}; };
match self.tableaus[to].face_up().last() { match self.tableau_top_card(to) {
// Build down regardless of suit. // 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 // Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly). // 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()) !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 /// Removes a completed K→A same-suit run from the top of a pile,
/// `index`, if one is present. Returns `true` when a run was /// if one is present. Returns `true` when a run was removed (and
/// removed (and the next face-down card, if any, was flipped). /// the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, index: usize) -> bool { fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
let Some(pile) = self.tableaus.get_mut(index) else { let pile = &mut self.tableaus[tableau.index()];
return false;
};
let up = pile.face_up(); let up = pile.face_up();
if up.len() < RUN_LEN { if up.len() < RUN_LEN {
return false; return false;
} }
let run = &up[up.len() - RUN_LEN..]; let run = &up[up.len() - RUN_LEN..];
let suit = run[0].suit(); let suit = run[0].suit();
let is_complete = run.iter().enumerate().all(|(offset, card)| { // The run sits bottom-first (K → A); reversed, it must read
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8 // 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 { if !is_complete {
return false; return false;
} }
@@ -348,7 +526,7 @@ impl SpiderGame {
} }
} }
impl Game for SpiderGame { impl Game for Spider {
type Score = i32; type Score = i32;
type Stats = SpiderStats; type Stats = SpiderStats;
type Config = SpiderConfig; type Config = SpiderConfig;
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
&self, &self,
config: &Self::Config, config: &Self::Config,
) -> impl Iterator<Item = Self::Instruction> + use<> { ) -> impl Iterator<Item = Self::Instruction> + use<> {
let mut out = Vec::new(); let state = self.clone();
if self.is_deal_valid() { let config = config.clone();
out.push(SpiderInstruction::Deal); SpiderIter::new()
} .filter(move |&instruction| state.is_instruction_valid(&config, instruction))
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 { fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
match instruction { match instruction {
SpiderInstruction::Deal => self.is_deal_valid(), 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 { match instruction {
SpiderInstruction::Deal => { SpiderInstruction::Deal => {
stats.deals += 1; stats.deals += 1;
for index in 0..SPIDER_TABLEAUS { for tableau in SpiderTableau::ALL {
match self.stock.pop() { match self.stock.pop() {
Some(card) => self.tableaus[index].push(card), Some(card) => self.tableaus[tableau.index()].push(card),
None => break, None => break,
} }
} }
for index in 0..SPIDER_TABLEAUS { for tableau in SpiderTableau::ALL {
if self.sweep_completed_run(index) { if self.sweep_completed_run(tableau) {
self.completed_runs += 1; self.completed_runs += 1;
stats.runs_completed += 1; stats.runs_completed += 1;
} }
} }
} }
SpiderInstruction::Move { from, to, count } => { SpiderInstruction::Move(SpiderMove { from, run, to }) => {
let (from, to, count) = (from as usize, to as usize, count as usize); let src_len = self.tableaus[from.index()].face_up().len();
let src_len = self.tableaus[from].face_up().len(); let (cards, _flipped) =
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..); self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
self.tableaus[to].extend(cards); self.tableaus[to.index()].extend(cards);
if self.sweep_completed_run(to) { if self.sweep_completed_run(to) {
self.completed_runs += 1; self.completed_runs += 1;
stats.runs_completed += 1; stats.runs_completed += 1;
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState { pub struct SpiderGameState {
seed: u64, seed: u64,
session: Session<SpiderGame>, session: Session<Spider>,
} }
impl SpiderGameState { impl SpiderGameState {
@@ -478,7 +638,7 @@ impl SpiderGameState {
}; };
Self { Self {
seed, 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). /// Current position (read-only).
pub fn game(&self) -> &SpiderGame { pub fn game(&self) -> &Spider {
self.session.state().state() 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<Spider> {
&self.session
}
/// In-play score, clamped at 0 like the Klondike wrapper. /// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score(&self) -> i32 { pub fn score(&self) -> i32 {
self.session self.session
@@ -563,7 +731,7 @@ impl SpiderGameState {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
impl SpiderGame { impl Spider {
/// Builds an arbitrary position for tests: per-pile /// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run /// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked /// count. No card-count invariants are enforced — stacked
@@ -592,7 +760,7 @@ impl SpiderGame {
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
impl SpiderGameState { impl SpiderGameState {
/// Wraps an arbitrary position in a fresh session (empty history). /// 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 { let config = SessionConfig {
inner: SpiderConfig { inner: SpiderConfig {
suits, suits,
@@ -614,6 +782,8 @@ impl SpiderGameState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::RunLength::*;
use super::SpiderTableau::*;
use super::*; use super::*;
/// `Card::new` shorthand for stacked positions. /// `Card::new` shorthand for stacked positions.
@@ -621,6 +791,11 @@ mod tests {
Card::new(Deck::Deck1, suit, rank) 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 /// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile. /// completed run occupies on a pile.
fn full_run(suit: Suit) -> Vec<Card> { fn full_run(suit: Suit) -> Vec<Card> {
@@ -643,21 +818,25 @@ mod tests {
#[test] #[test]
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() { fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
let game = SpiderGame::with_seed(42, SpiderSuits::Four); let game = Spider::with_seed(42, SpiderSuits::Four);
for index in 0..SPIDER_TABLEAUS { for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
let expected_down = if index < 4 { 5 } else { 4 }; let expected_down = if index < 4 { 5 } else { 4 };
assert_eq!(game.tableau_face_down(index).len(), expected_down); assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up"); 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); assert_eq!(game.completed_runs(), 0);
} }
#[test] #[test]
fn same_seed_same_deal_different_seed_different_deal() { fn same_seed_same_deal_different_seed_different_deal() {
let a = SpiderGame::with_seed(7, SpiderSuits::Four); let a = Spider::with_seed(7, SpiderSuits::Four);
let b = SpiderGame::with_seed(7, SpiderSuits::Four); let b = Spider::with_seed(7, SpiderSuits::Four);
let c = SpiderGame::with_seed(8, SpiderSuits::Four); let c = Spider::with_seed(8, SpiderSuits::Four);
assert_eq!(a, b); assert_eq!(a, b);
assert_ne!(a, c); assert_ne!(a, c);
} }
@@ -691,15 +870,21 @@ mod tests {
let mut layout = empty_layout(); let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)]; layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
layout[1].1 = vec![card(Suit::Spades, Rank::Six)]; layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal"); assert!(
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not"); 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] #[test]
fn only_same_suit_runs_are_movable_as_a_group() { fn only_same_suit_runs_are_movable_as_a_group() {
let mut layout = empty_layout(); 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![ layout[0].1 = vec![
card(Suit::Spades, Rank::Seven), card(Suit::Spades, Rank::Seven),
card(Suit::Spades, Rank::Six), card(Suit::Spades, Rank::Six),
@@ -711,10 +896,19 @@ mod tests {
// Destinations: 8♣ (for the pair), 7♦ (for a lone six). // Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)]; layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)]; layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves"); assert!(
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not"); game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
assert!(game.is_move_valid(1, 3, 1), "its top card alone does"); "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] #[test]
@@ -724,27 +918,40 @@ mod tests {
card(Suit::Spades, Rank::Nine), card(Suit::Spades, Rank::Nine),
card(Suit::Spades, Rank::Eight), card(Suit::Spades, Rank::Eight),
]; ];
// Pile 1 deliberately left empty. // Pile 2 deliberately left empty.
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)]; layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::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!( 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)" "9♠8♠ cannot land on 9♥ (needs a 10)"
); );
} }
#[test] #[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(); let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Spades, Rank::Five)]; layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(!game.is_move_valid(0, 0, 1), "self-move"); assert!(
assert!(!game.is_move_valid(0, 1, 0), "zero count"); !game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
assert!(!game.is_move_valid(0, 10, 1), "destination out of range"); "self-move"
assert!(!game.is_move_valid(10, 0, 1), "source out of range"); );
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run"); assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"count exceeds run"
);
} }
// -- dealing from stock ------------------------------------------------- // -- dealing from stock -------------------------------------------------
@@ -752,31 +959,35 @@ mod tests {
#[test] #[test]
fn deal_requires_stock_and_no_empty_pile() { fn deal_requires_stock_and_no_empty_pile() {
let stock = vec![card(Suit::Spades, Rank::Ace); 10]; 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()); assert!(game.is_deal_valid());
let mut with_gap = junk_layout(); let mut with_gap = junk_layout();
with_gap[3].1.clear(); 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"); 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"); assert!(!game.is_deal_valid(), "empty stock blocks the deal");
} }
#[test] #[test]
fn deal_puts_one_card_on_every_pile() { fn deal_puts_one_card_on_every_pile() {
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two); let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
let before: Vec<usize> = (0..SPIDER_TABLEAUS) let before: Vec<usize> = SpiderTableau::ALL
.map(|i| state.game().tableau_face_up(i).len()) .into_iter()
.map(|tableau| state.game().tableau_face_up_cards(tableau).len())
.collect(); .collect();
state state
.apply_instruction(SpiderInstruction::Deal) .apply_instruction(SpiderInstruction::Deal)
.expect("deal is legal on a fresh game"); .expect("deal is legal on a fresh game");
for (index, previous) in before.iter().enumerate() { for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1); 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] #[test]
@@ -787,7 +998,7 @@ mod tests {
.apply_instruction(SpiderInstruction::Deal) .apply_instruction(SpiderInstruction::Deal)
.expect("five deals must all be legal on untouched piles"); .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!( assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal), state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::RuleViolation(_)) Err(MoveError::RuleViolation(_))
@@ -799,30 +1010,26 @@ mod tests {
#[test] #[test]
fn completing_a_run_removes_it_and_flips_the_card_beneath() { fn completing_a_run_removes_it_and_flips_the_card_beneath() {
let mut layout = empty_layout(); let mut layout = empty_layout();
// Pile 0: one face-down card under K..2 of spades; the ace // Pile 1: one face-down card under K..2 of spades; the ace
// arrives from pile 1. // arrives from pile 2.
let mut run = full_run(Suit::Spades); let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run); layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
layout[1].1 = vec![ace]; 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); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
state state
.apply_instruction(SpiderInstruction::Move { .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
from: 1,
to: 0,
count: 1,
})
.expect("ace onto two completes the run"); .expect("ace onto two completes the run");
assert_eq!(state.game().completed_runs(), 1); assert_eq!(state.game().completed_runs(), 1);
assert_eq!( assert_eq!(
state.game().tableau_face_up(0), state.game().tableau_face_up_cards(Tableau1),
&[card(Suit::Hearts, Rank::Nine)], &[card(Suit::Hearts, Rank::Nine)],
"run removed and the buried card flipped face-up" "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] #[test]
@@ -832,16 +1039,12 @@ mod tests {
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0].1 = run; layout[0].1 = run;
layout[1].1 = vec![ace]; 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); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
assert!(!state.is_won()); assert!(!state.is_won());
state state
.apply_instruction(SpiderInstruction::Move { .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
from: 1,
to: 0,
count: 1,
})
.expect("winning move is legal"); .expect("winning move is legal");
assert!(state.is_won()); assert!(state.is_won());
assert!(matches!( assert!(matches!(
@@ -882,11 +1085,7 @@ mod tests {
#[test] #[test]
fn rule_violation_surfaces_move_error() { fn rule_violation_surfaces_move_error() {
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One); let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
let result = state.apply_instruction(SpiderInstruction::Move { let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
from: 0,
to: 0,
count: 1,
});
assert!(matches!(result, Err(MoveError::RuleViolation(_)))); assert!(matches!(result, Err(MoveError::RuleViolation(_))));
} }
@@ -905,6 +1104,13 @@ mod tests {
assert!(state.game().is_instruction_valid(&config, instruction)); 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)] #[cfg(test)]
@@ -915,11 +1121,15 @@ mod proptests {
/// Total cards across tableaus + stock + removed runs must always /// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for /// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves. /// any seed, difficulty, and random walk through legal moves.
fn card_conservation(game: &SpiderGame) -> usize { fn card_conservation(game: &Spider) -> usize {
let on_piles: usize = (0..SPIDER_TABLEAUS) let on_piles: usize = SpiderTableau::ALL
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len()) .into_iter()
.map(|tableau| {
game.tableau_face_up_cards(tableau).len()
+ game.tableau_face_down_cards(tableau).len()
})
.sum(); .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! { proptest! {