@@ -0,0 +1,952 @@
//! Spider solitaire rules — a second [`card_game::Game`] implementation
//! alongside the upstream Klondike.
//!
//! Built directly on the upstream `card_game` containers: two decks
//! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten
//! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the
//! `Stack`/`Pile` public API that `klondike` uses internally.
//!
//! ## Rules implemented
//!
//! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive
//! 5 (top card face-up) — 54 dealt, 50 in stock.
//! - Build down regardless of suit; only same-suit descending runs may
//! be picked up and moved.
//! - Empty piles accept any card or movable run.
//! - The stock deals one card to every pile (10 total), only while no
//! pile is empty.
//! - A completed K→A same-suit run is removed automatically; the game
//! is won when all 8 runs are removed.
//! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits
//! spread over the same 104 cards.
//!
//! ## Determinism
//!
//! Deals are seeded with an inline SplitMix64 + Fisher– Yates shuffle:
//! `solitaire_core` has no `rand` dependency (and adding one needs
//! explicit approval), so Spider's seed space is deliberately
//! self-contained rather than shared with Klondike's `StdRng` seeds.
//! The same seed + suit count always produces the same deal.
//!
//! ## Card identity caveat (engine integration, later phase)
//!
//! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit
//! games need more than four copies of a suit, so *identical* `Card`
//! values legitimately coexist (e.g. eight `♠A` in a 1-suit game,
//! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so
//! core is unaffected, but the engine's `Card → Entity` mapping is
//! keyed by card value and will need positional keys before a Spider
//! UI lands.
use card_game ::{ Card , Deck , Game , Pile , Rank , Session , SessionConfig , Stack , Suit } ;
use serde ::{ Deserialize , Serialize } ;
use crate ::error ::MoveError ;
/// Number of tableau piles.
pub const SPIDER_TABLEAUS : usize = 10 ;
/// Total cards in play (two decks).
pub const SPIDER_DECK_SIZE : usize = 104 ;
/// Cards left in the stock after the opening deal (5 deals × 10).
const STOCK_SIZE : usize = 50 ;
/// Cards in one completed run (K → A).
const RUN_LEN : usize = 13 ;
/// Runs required to win.
const TOTAL_RUNS : u8 = 8 ;
/// Face-down cards in the deepest opening pile.
const MAX_FACE_DOWN : usize = 5 ;
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Spider difficulty: how many distinct suits the 104 cards span.
#[ derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default) ]
pub enum SpiderSuits {
/// All spades — the beginner layout (default).
#[ default ]
One ,
/// Spades + hearts.
Two ,
/// Full two-deck spread, the classic four-suit game.
Four ,
}
impl SpiderSuits {
/// Suits used at this difficulty.
fn suits ( self ) -> & 'static [ Suit ] {
match self {
Self ::One = > & [ Suit ::Spades ] ,
Self ::Two = > & [ Suit ::Spades , Suit ::Hearts ] ,
Self ::Four = > & [ Suit ::Spades , Suit ::Hearts , Suit ::Clubs , Suit ::Diamonds ] ,
}
}
}
/// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`.
///
/// Defaults follow the familiar Microsoft formula: start at 500, − 1
/// per move, +100 per completed run (the upstream session adds
/// `undos × undo_penalty` on top).
#[ derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize) ]
pub struct SpiderScoring {
/// Score a fresh deal starts from.
pub base : i32 ,
/// Added per move (conventionally negative).
pub move_penalty : i32 ,
/// Added per completed K→A run.
pub run_bonus : i32 ,
}
impl Default for SpiderScoring {
fn default ( ) -> Self {
Self {
base : 500 ,
move_penalty : - 1 ,
run_bonus : 100 ,
}
}
}
/// Full Spider rules configuration (the `Game::Config` type).
#[ derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default) ]
pub struct SpiderConfig {
/// Suit-count difficulty.
pub suits : SpiderSuits ,
/// Scoring knobs.
pub scoring : SpiderScoring ,
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
/// Per-game counters (the `Game::Stats` type). Cumulative — they are
/// not rolled back by undo, matching upstream `KlondikeStats`.
#[ derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default) ]
pub struct SpiderStats {
moves : u32 ,
deals : u32 ,
runs_completed : u32 ,
}
impl SpiderStats {
/// Total instructions processed (moves + deals).
pub const fn moves ( & self ) -> u32 {
self . moves
}
/// Stock deals performed.
pub const fn deals ( & self ) -> u32 {
self . deals
}
/// K→A runs completed over the whole game (not undone-adjusted).
pub const fn runs_completed ( & self ) -> u32 {
self . runs_completed
}
}
// ---------------------------------------------------------------------------
// Instruction
// ---------------------------------------------------------------------------
/// One atomic Spider action (the `Game::Instruction` type).
#[ derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize) ]
pub enum SpiderInstruction {
/// Deal one card from the stock onto every tableau pile.
Deal ,
/// Move the top `count` face-up cards (a same-suit descending run)
/// from pile `from` to pile `to`. Pile indices are `0..10`.
Move {
/// Source pile index.
from : u8 ,
/// Destination pile index.
to : u8 ,
/// Number of cards in the moved run (≥ 1).
count : u8 ,
} ,
}
// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
/// Pure Spider game position: ten tableaus, the stock, and the count
/// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`].
#[ derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize) ]
pub struct SpiderGame {
tableaus : [ Pile < MAX_FACE_DOWN , SPIDER_DECK_SIZE > ; SPIDER_TABLEAUS ] ,
stock : Stack < STOCK_SIZE > ,
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 < Card > {
let suit_set = suits . suits ( ) ;
let copies = SPIDER_DECK_SIZE / ( suit_set . len ( ) * RUN_LEN ) ;
let decks = [ Deck ::Deck1 , Deck ::Deck2 , Deck ::Deck3 , Deck ::Deck4 ] ;
let mut cards = Vec ::with_capacity ( SPIDER_DECK_SIZE ) ;
for copy in 0 .. copies {
for & suit in suit_set {
for rank in Rank ::RANKS {
cards . push ( Card ::new ( decks [ copy % decks . len ( ) ] , suit , rank ) ) ;
}
}
}
cards
}
impl SpiderGame {
/// Deals a new seeded game at the given suit difficulty.
pub fn with_seed ( seed : u64 , suits : SpiderSuits ) -> Self {
let mut deck = build_deck ( suits ) ;
shuffle ( & mut deck , seed ) ;
let mut cards = deck . into_iter ( ) ;
let tableaus = core ::array ::from_fn ( | index | {
// Piles 0– 3 open with 5 face-down cards, piles 4– 9 with 4;
// one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 } ;
let stack : Stack < 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 pile `index` (bottom → top). Empty slice for
/// out-of-range indices.
pub fn tableau_face_up ( & self , index : usize ) -> & [ Card ] {
self . tableaus . get ( index ) . map_or ( & [ ] , | pile | pile . face_up ( ) )
}
/// Face-down cards of pile `index` (bottom → top).
pub fn tableau_face_down ( & self , index : usize ) -> & [ Card ] {
self . tableaus
. get ( index )
. map_or ( & [ ] , | pile | pile . face_down ( ) )
}
/// Cards remaining in the stock.
pub fn stock_len ( & self ) -> usize {
self . stock . len ( )
}
/// Completed K→A runs removed from play so far.
pub const fn completed_runs ( & self ) -> u8 {
self . completed_runs
}
/// Length of the longest movable run on top of pile `index`: the
/// maximal same-suit, strictly-descending face-up suffix.
fn movable_run_len ( & self , index : usize ) -> usize {
let Some ( pile ) = self . tableaus . get ( index ) else {
return 0 ;
} ;
let up = pile . face_up ( ) ;
let mut len = usize ::from ( ! up . is_empty ( ) ) ;
while len < up . len ( ) {
let above = & up [ up . len ( ) - len ] ;
let below = & up [ up . len ( ) - len - 1 ] ;
let descends =
below . suit ( ) = = above . suit ( ) & & below . rank ( ) as u8 = = above . rank ( ) as u8 + 1 ;
if ! descends {
break ;
}
len + = 1 ;
}
len
}
/// Whether `Move { from, to, count }` is legal in this position.
fn is_move_valid ( & self , from : u8 , to : u8 , count : u8 ) -> bool {
let ( from , to , count ) = ( from as usize , to as usize , count as usize ) ;
if from = = to | | from > = SPIDER_TABLEAUS | | to > = SPIDER_TABLEAUS | | count = = 0 {
return false ;
}
if count > self . movable_run_len ( from ) {
return false ;
}
let src_up = self . tableaus [ from ] . face_up ( ) ;
// Bottom card of the moved run; `count <= movable_run_len <=
// src_up.len()` guarantees the index is in range.
let Some ( moved_bottom ) = src_up . get ( src_up . len ( ) - count ) else {
return false ;
} ;
match self . tableaus [ to ] . face_up ( ) . last ( ) {
// Build down regardless of suit.
Some ( dest_top ) = > dest_top . rank ( ) as u8 = = moved_bottom . rank ( ) as u8 + 1 ,
// Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly).
None = > self . tableaus [ to ] . is_empty ( ) ,
}
}
/// Whether the stock may deal: cards remain and no pile is empty.
fn is_deal_valid ( & self ) -> bool {
! self . stock . is_empty ( ) & & self . tableaus . iter ( ) . all ( | pile | ! pile . is_empty ( ) )
}
/// Removes a completed K→A same-suit run from the top of pile
/// `index`, if one is present. Returns `true` when a run was
/// removed (and the next face-down card, if any, was flipped).
fn sweep_completed_run ( & mut self , index : usize ) -> bool {
let Some ( pile ) = self . tableaus . get_mut ( index ) else {
return false ;
} ;
let up = pile . face_up ( ) ;
if up . len ( ) < RUN_LEN {
return false ;
}
let run = & up [ up . len ( ) - RUN_LEN .. ] ;
let suit = run [ 0 ] . suit ( ) ;
let is_complete = run . iter ( ) . enumerate ( ) . all ( | ( offset , card ) | {
card . suit ( ) = = suit & & card . rank ( ) as u8 = = ( RUN_LEN - offset ) as u8
} ) ;
if ! is_complete {
return false ;
}
let start = up . len ( ) - RUN_LEN ;
let ( _removed , _flipped ) = pile . take_range_flip_up ( start .. ) ;
true
}
}
impl Game for SpiderGame {
type Score = i32 ;
type Stats = SpiderStats ;
type Config = SpiderConfig ;
type Instruction = SpiderInstruction ;
fn score ( & self , stats : & Self ::Stats , config : & Self ::Config ) -> Self ::Score {
let scoring = & config . scoring ;
let moves = i32 ::try_from ( stats . moves ) . unwrap_or ( i32 ::MAX ) ;
let runs = i32 ::from ( self . completed_runs ) ;
scoring . base + moves . saturating_mul ( scoring . move_penalty ) + runs * scoring . run_bonus
}
fn possible_instructions (
& self ,
config : & Self ::Config ,
) -> impl Iterator < Item = Self ::Instruction > + use < > {
let mut out = Vec ::new ( ) ;
if self . is_deal_valid ( ) {
out . push ( SpiderInstruction ::Deal ) ;
}
for from in 0 .. SPIDER_TABLEAUS {
let max_run = self . movable_run_len ( from ) ;
for count in 1 ..= max_run {
for to in 0 .. SPIDER_TABLEAUS {
#[ allow(clippy::cast_possible_truncation) ]
let instruction = SpiderInstruction ::Move {
from : from as u8 ,
to : to as u8 ,
count : count as u8 ,
} ;
if self . is_move_valid ( from as u8 , to as u8 , count as u8 ) {
out . push ( instruction ) ;
}
}
}
}
let _ = config ;
out . into_iter ( )
}
fn is_instruction_valid ( & self , _config : & Self ::Config , instruction : Self ::Instruction ) -> bool {
match instruction {
SpiderInstruction ::Deal = > self . is_deal_valid ( ) ,
SpiderInstruction ::Move { from , to , count } = > self . is_move_valid ( from , to , count ) ,
}
}
fn process_instruction (
& mut self ,
stats : & mut Self ::Stats ,
config : & Self ::Config ,
instruction : Self ::Instruction ,
) {
// The trait offers no error channel; an invalid instruction is
// a no-op rather than a panic (error policy: no panics in game
// logic). Callers route through `SpiderGameState`, which
// validates first and surfaces `MoveError`.
if ! self . is_instruction_valid ( config , instruction ) {
return ;
}
stats . moves + = 1 ;
match instruction {
SpiderInstruction ::Deal = > {
stats . deals + = 1 ;
for index in 0 .. SPIDER_TABLEAUS {
match self . stock . pop ( ) {
Some ( card ) = > self . tableaus [ index ] . push ( card ) ,
None = > break ,
}
}
for index in 0 .. SPIDER_TABLEAUS {
if self . sweep_completed_run ( index ) {
self . completed_runs + = 1 ;
stats . runs_completed + = 1 ;
}
}
}
SpiderInstruction ::Move { from , to , count } = > {
let ( from , to , count ) = ( from as usize , to as usize , count as usize ) ;
let src_len = self . tableaus [ from ] . face_up ( ) . len ( ) ;
let ( cards , _flipped ) = self . tableaus [ from ] . take_range_flip_up ( src_len - count .. ) ;
self . tableaus [ to ] . extend ( cards ) ;
if self . sweep_completed_run ( to ) {
self . completed_runs + = 1 ;
stats . runs_completed + = 1 ;
}
}
}
}
fn is_win ( & self ) -> bool {
self . completed_runs > = TOTAL_RUNS
}
}
// ---------------------------------------------------------------------------
// Session wrapper
// ---------------------------------------------------------------------------
/// Spider counterpart of [`crate::game_state::GameState`]: owns the
/// upstream [`Session`] (undo history + score/undo bookkeeping) and
/// exposes `Result<_, MoveError>` mutations, mirroring the Klondike
/// wrapper's conventions.
#[ derive(Debug, Clone, Serialize, Deserialize) ]
pub struct SpiderGameState {
seed : u64 ,
session : Session < SpiderGame > ,
}
impl SpiderGameState {
/// New seeded game with the default (1-suit) difficulty.
pub fn new ( seed : u64 ) -> Self {
Self ::new_with_suits ( seed , SpiderSuits ::default ( ) )
}
/// New seeded game at an explicit suit difficulty.
pub fn new_with_suits ( seed : u64 , suits : SpiderSuits ) -> Self {
let config = SessionConfig {
inner : SpiderConfig {
suits ,
scoring : SpiderScoring ::default ( ) ,
} ,
// Undoing costs one move, matching the familiar Spider
// scoring; the upstream formula applies `undos × penalty`.
undo_penalty : - 1 ,
.. SessionConfig ::default ( )
} ;
Self {
seed ,
session : Session ::new ( SpiderGame ::with_seed ( seed , suits ) , config ) ,
}
}
/// The deal seed this game was created from.
pub const fn seed ( & self ) -> u64 {
self . seed
}
/// Suit difficulty of this game.
pub fn suits ( & self ) -> SpiderSuits {
self . session . config ( ) . inner . suits
}
/// Current position (read-only).
pub fn game ( & self ) -> & SpiderGame {
self . session . state ( ) . state ( )
}
/// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score ( & self ) -> i32 {
self . session
. state ( )
. score ( self . session . stats ( ) , self . session . config ( ) )
. max ( 0 )
}
/// Whether all 8 runs are complete.
pub fn is_won ( & self ) -> bool {
self . session . is_win ( )
}
/// Total instructions applied (deals + moves), from history.
pub fn move_count ( & self ) -> u32 {
u32 ::try_from ( self . session . history ( ) . len ( ) ) . unwrap_or ( u32 ::MAX )
}
/// Successful undos this session.
pub fn undo_count ( & self ) -> u32 {
self . session . stats ( ) . undos ( )
}
/// Legal instructions in the current position.
pub fn possible_instructions ( & self ) -> Vec < 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 SpiderGame {
/// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked
/// positions for rule tests routinely use partial layouts.
pub fn from_test_layout (
piles : [ ( Vec < 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 : SpiderGame , suits : SpiderSuits ) -> Self {
let config = SessionConfig {
inner : SpiderConfig {
suits ,
scoring : SpiderScoring ::default ( ) ,
} ,
undo_penalty : - 1 ,
.. SessionConfig ::default ( )
} ;
Self {
seed : 0 ,
session : Session ::new ( game , config ) ,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[ cfg(test) ]
mod tests {
use super ::* ;
/// `Card::new` shorthand for stacked positions.
fn card ( suit : Suit , rank : Rank ) -> Card {
Card ::new ( Deck ::Deck1 , suit , rank )
}
/// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile.
fn full_run ( suit : Suit ) -> Vec < 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 = SpiderGame ::with_seed ( 42 , SpiderSuits ::Four ) ;
for index in 0 .. SPIDER_TABLEAUS {
let expected_down = if index < 4 { 5 } else { 4 } ;
assert_eq! ( game . tableau_face_down ( index ) . len ( ) , expected_down ) ;
assert_eq! ( game . tableau_face_up ( index ) . len ( ) , 1 , " one card face-up " ) ;
}
assert_eq! ( game . stock_len ( ) , STOCK_SIZE ) ;
assert_eq! ( game . completed_runs ( ) , 0 ) ;
}
#[ test ]
fn same_seed_same_deal_different_seed_different_deal ( ) {
let a = SpiderGame ::with_seed ( 7 , SpiderSuits ::Four ) ;
let b = SpiderGame ::with_seed ( 7 , SpiderSuits ::Four ) ;
let c = SpiderGame ::with_seed ( 8 , SpiderSuits ::Four ) ;
assert_eq! ( a , b ) ;
assert_ne! ( a , c ) ;
}
#[ test ]
fn suit_difficulty_controls_suit_spread ( ) {
let one = build_deck ( SpiderSuits ::One ) ;
let two = build_deck ( SpiderSuits ::Two ) ;
let four = build_deck ( SpiderSuits ::Four ) ;
for deck in [ & one , & two , & four ] {
assert_eq! ( deck . len ( ) , SPIDER_DECK_SIZE ) ;
}
assert! ( one . iter ( ) . all ( | c | c . suit ( ) = = Suit ::Spades ) ) ;
assert! (
two . iter ( )
. all ( | c | matches! ( c . suit ( ) , Suit ::Spades | Suit ::Hearts ) )
) ;
for suit in Suit ::SUITS {
assert_eq! (
four . iter ( ) . filter ( | c | c . suit ( ) = = suit ) . count ( ) ,
2 * RUN_LEN ,
" four-suit deck has exactly two copies per suit "
) ;
}
}
// -- move rules -------------------------------------------------------
#[ test ]
fn single_card_builds_down_regardless_of_suit ( ) {
let mut layout = empty_layout ( ) ;
layout [ 0 ] . 1 = vec! [ card ( Suit ::Hearts , Rank ::Five ) ] ;
layout [ 1 ] . 1 = vec! [ card ( Suit ::Spades , Rank ::Six ) ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , 0 ) ;
assert! ( game . is_move_valid ( 0 , 1 , 1 ) , " 5♥ onto 6♠ is legal " ) ;
assert! ( ! game . is_move_valid ( 1 , 0 , 1 ) , " 6♠ onto 5♥ is not " ) ;
}
#[ test ]
fn only_same_suit_runs_are_movable_as_a_group ( ) {
let mut layout = empty_layout ( ) ;
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
layout [ 0 ] . 1 = vec! [
card ( Suit ::Spades , Rank ::Seven ) ,
card ( Suit ::Spades , Rank ::Six ) ,
] ;
layout [ 1 ] . 1 = vec! [
card ( Suit ::Hearts , Rank ::Seven ) ,
card ( Suit ::Spades , Rank ::Six ) ,
] ;
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout [ 2 ] . 1 = vec! [ card ( Suit ::Clubs , Rank ::Eight ) ] ;
layout [ 3 ] . 1 = vec! [ card ( Suit ::Diamonds , Rank ::Seven ) ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , 0 ) ;
assert! ( game . is_move_valid ( 0 , 2 , 2 ) , " same-suit pair moves " ) ;
assert! ( ! game . is_move_valid ( 1 , 2 , 2 ) , " mixed-suit pair does not " ) ;
assert! ( game . is_move_valid ( 1 , 3 , 1 ) , " its top card alone does " ) ;
}
#[ test ]
fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold ( ) {
let mut layout = empty_layout ( ) ;
layout [ 0 ] . 1 = vec! [
card ( Suit ::Spades , Rank ::Nine ) ,
card ( Suit ::Spades , Rank ::Eight ) ,
] ;
// Pile 1 deliberately left empty.
layout [ 2 ] . 1 = vec! [ card ( Suit ::Hearts , Rank ::Nine ) ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , 0 ) ;
assert! ( game . is_move_valid ( 0 , 1 , 2 ) , " run onto empty pile " ) ;
assert! ( game . is_move_valid ( 2 , 1 , 1 ) , " single onto empty pile " ) ;
assert! (
! game . is_move_valid ( 0 , 2 , 2 ) ,
" 9♠8♠ cannot land on 9♥ (needs a 10) "
) ;
}
#[ test ]
fn invalid_moves_rejected_out_of_range_zero_count_self_move ( ) {
let mut layout = empty_layout ( ) ;
layout [ 0 ] . 1 = vec! [ card ( Suit ::Spades , Rank ::Five ) ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , 0 ) ;
assert! ( ! game . is_move_valid ( 0 , 0 , 1 ) , " self-move " ) ;
assert! ( ! game . is_move_valid ( 0 , 1 , 0 ) , " zero count " ) ;
assert! ( ! game . is_move_valid ( 0 , 10 , 1 ) , " destination out of range " ) ;
assert! ( ! game . is_move_valid ( 10 , 0 , 1 ) , " source out of range " ) ;
assert! ( ! game . is_move_valid ( 0 , 1 , 2 ) , " count exceeds run " ) ;
}
// -- dealing from stock -------------------------------------------------
#[ test ]
fn deal_requires_stock_and_no_empty_pile ( ) {
let stock = vec! [ card ( Suit ::Spades , Rank ::Ace ) ; 10 ] ;
let game = SpiderGame ::from_test_layout ( junk_layout ( ) , stock . clone ( ) , 0 ) ;
assert! ( game . is_deal_valid ( ) ) ;
let mut with_gap = junk_layout ( ) ;
with_gap [ 3 ] . 1. clear ( ) ;
let game = SpiderGame ::from_test_layout ( with_gap , stock , 0 ) ;
assert! ( ! game . is_deal_valid ( ) , " empty pile blocks the deal " ) ;
let game = SpiderGame ::from_test_layout ( junk_layout ( ) , Vec ::new ( ) , 0 ) ;
assert! ( ! game . is_deal_valid ( ) , " empty stock blocks the deal " ) ;
}
#[ test ]
fn deal_puts_one_card_on_every_pile ( ) {
let mut state = SpiderGameState ::new_with_suits ( 3 , SpiderSuits ::Two ) ;
let before : Vec < usize > = ( 0 .. SPIDER_TABLEAUS )
. map ( | i | state . game ( ) . tableau_face_up ( i ) . len ( ) )
. collect ( ) ;
state
. apply_instruction ( SpiderInstruction ::Deal )
. expect ( " deal is legal on a fresh game " ) ;
for ( index , previous ) in before . iter ( ) . enumerate ( ) {
assert_eq! ( state . game ( ) . tableau_face_up ( index ) . len ( ) , previous + 1 ) ;
}
assert_eq! ( state . game ( ) . stock_len ( ) , STOCK_SIZE - SPIDER_TABLEAUS ) ;
}
#[ test ]
fn stock_supports_exactly_five_deals ( ) {
let mut state = SpiderGameState ::new_with_suits ( 11 , SpiderSuits ::One ) ;
for _ in 0 .. 5 {
state
. apply_instruction ( SpiderInstruction ::Deal )
. expect ( " five deals must all be legal on untouched piles " ) ;
}
assert_eq! ( state . game ( ) . stock_len ( ) , 0 ) ;
assert! ( matches! (
state . apply_instruction ( SpiderInstruction ::Deal ) ,
Err ( MoveError ::RuleViolation ( _ ) )
) ) ;
}
// -- run completion & win ----------------------------------------------
#[ test ]
fn completing_a_run_removes_it_and_flips_the_card_beneath ( ) {
let mut layout = empty_layout ( ) ;
// Pile 0: one face-down card under K..2 of spades; the ace
// arrives from pile 1.
let mut run = full_run ( Suit ::Spades ) ;
let ace = run . pop ( ) . unwrap_or ( card ( Suit ::Spades , Rank ::Ace ) ) ;
layout [ 0 ] = ( vec! [ card ( Suit ::Hearts , Rank ::Nine ) ] , run ) ;
layout [ 1 ] . 1 = vec! [ ace ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , 0 ) ;
let mut state = SpiderGameState ::from_test_game ( game , SpiderSuits ::One ) ;
state
. apply_instruction ( SpiderInstruction ::Move {
from : 1 ,
to : 0 ,
count : 1 ,
} )
. expect ( " ace onto two completes the run " ) ;
assert_eq! ( state . game ( ) . completed_runs ( ) , 1 ) ;
assert_eq! (
state . game ( ) . tableau_face_up ( 0 ) ,
& [ card ( Suit ::Hearts , Rank ::Nine ) ] ,
" run removed and the buried card flipped face-up "
) ;
assert! ( state . game ( ) . tableau_face_up ( 1 ) . is_empty ( ) ) ;
}
#[ test ]
fn eighth_run_wins_the_game ( ) {
let mut layout = empty_layout ( ) ;
let mut run = full_run ( Suit ::Spades ) ;
let ace = run . pop ( ) . unwrap_or ( card ( Suit ::Spades , Rank ::Ace ) ) ;
layout [ 0 ] . 1 = run ;
layout [ 1 ] . 1 = vec! [ ace ] ;
let game = SpiderGame ::from_test_layout ( layout , Vec ::new ( ) , TOTAL_RUNS - 1 ) ;
let mut state = SpiderGameState ::from_test_game ( game , SpiderSuits ::One ) ;
assert! ( ! state . is_won ( ) ) ;
state
. apply_instruction ( SpiderInstruction ::Move {
from : 1 ,
to : 0 ,
count : 1 ,
} )
. expect ( " winning move is legal " ) ;
assert! ( state . is_won ( ) ) ;
assert! ( matches! (
state . apply_instruction ( SpiderInstruction ::Deal ) ,
Err ( MoveError ::GameAlreadyWon )
) ) ;
}
// -- session wrapper -----------------------------------------------------
#[ test ]
fn undo_restores_position_and_counts ( ) {
let mut state = SpiderGameState ::new_with_suits ( 21 , SpiderSuits ::Two ) ;
let fresh = state . game ( ) . clone ( ) ;
assert! ( matches! ( state . undo ( ) , Err ( MoveError ::UndoStackEmpty ) ) ) ;
state
. apply_instruction ( SpiderInstruction ::Deal )
. expect ( " deal " ) ;
assert_ne! ( * state . game ( ) , fresh ) ;
state . undo ( ) . expect ( " one snapshot to restore " ) ;
assert_eq! ( * state . game ( ) , fresh ) ;
assert_eq! ( state . undo_count ( ) , 1 ) ;
assert_eq! ( state . move_count ( ) , 0 , " undo pops the history entry " ) ;
}
#[ test ]
fn score_follows_base_move_penalty_and_run_bonus ( ) {
let mut state = SpiderGameState ::new_with_suits ( 5 , SpiderSuits ::One ) ;
assert_eq! ( state . score ( ) , 500 , " fresh game starts at base " ) ;
state
. apply_instruction ( SpiderInstruction ::Deal )
. expect ( " deal " ) ;
assert_eq! ( state . score ( ) , 499 , " one move costs one point " ) ;
}
#[ test ]
fn rule_violation_surfaces_move_error ( ) {
let mut state = SpiderGameState ::new_with_suits ( 9 , SpiderSuits ::One ) ;
let result = state . apply_instruction ( SpiderInstruction ::Move {
from : 0 ,
to : 0 ,
count : 1 ,
} ) ;
assert! ( matches! ( result , Err ( MoveError ::RuleViolation ( _ ) ) ) ) ;
}
// -- generated instructions ----------------------------------------------
#[ test ]
fn possible_instructions_are_all_valid_and_include_deal ( ) {
let state = SpiderGameState ::new_with_suits ( 13 , SpiderSuits ::Four ) ;
let config = SpiderConfig ::default ( ) ;
let instructions = state . possible_instructions ( ) ;
assert! (
instructions . contains ( & SpiderInstruction ::Deal ) ,
" fresh game has no empty pile, so Deal must be offered "
) ;
for instruction in instructions {
assert! ( state . game ( ) . is_instruction_valid ( & config , instruction ) ) ;
}
}
}
#[ cfg(test) ]
mod proptests {
use super ::* ;
use proptest ::prelude ::* ;
/// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves.
fn card_conservation ( game : & SpiderGame ) -> usize {
let on_piles : usize = ( 0 .. SPIDER_TABLEAUS )
. map ( | i | game . tableau_face_up ( i ) . len ( ) + game . tableau_face_down ( i ) . len ( ) )
. sum ( ) ;
on_piles + game . stock_len ( ) + usize ::from ( game . completed_runs ( ) ) * RUN_LEN
}
proptest! {
#[ test ]
fn random_walks_conserve_cards_and_stay_valid (
seed in any ::< u64 > ( ) ,
suit_pick in 0 u8 .. 3 ,
steps in 0 usize .. 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 ) ;
}
}
}
}