Files
Ferrous-Solitaire/solitaire_core/src/spider.rs
T
funman300 1d2b6dc5de
Test / test (pull_request) Successful in 37m4s
refactor(core): align Spider with upstream card_game idioms
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>
2026-07-07 17:58:57 -07:00

1163 lines
39 KiB
Rust
Raw Blame History

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