1d2b6dc5de
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>
1163 lines
39 KiB
Rust
1163 lines
39 KiB
Rust
//! 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 1–4 open with 5 face-down cards, piles 5–10 with 4;
|
||
// one face-up card lands on each afterwards.
|
||
let down_count = if index < 4 { 5 } else { 4 };
|
||
let stack: Stack<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);
|
||
}
|
||
}
|
||
}
|
||
}
|