Merge pull request 'refactor(core): align Spider with upstream card_game idioms' (#162) from refactor/spider-upstream-idioms into master
This commit was merged in pull request #162.
This commit is contained in:
Generated
+1
@@ -7331,6 +7331,7 @@ dependencies = [
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,11 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||
# through the identical shuffle stack as Klondike deals.
|
||||
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -25,8 +25,8 @@ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, So
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
pub use spider::{
|
||||
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
|
||||
SpiderSuits,
|
||||
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
|
||||
+397
-187
@@ -22,11 +22,10 @@
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! Deals are seeded with an inline SplitMix64 + Fisher–Yates shuffle:
|
||||
//! `solitaire_core` has no `rand` dependency (and adding one needs
|
||||
//! explicit approval), so Spider's seed space is deliberately
|
||||
//! self-contained rather than shared with Klondike's `StdRng` seeds.
|
||||
//! The same seed + suit count always produces the same deal.
|
||||
//! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
|
||||
//! `rand::rngs::StdRng` alias `klondike` exports, seeded through
|
||||
//! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
|
||||
//! shuffle. The same seed + suit count always produces the same deal.
|
||||
//!
|
||||
//! ## Card identity caveat (engine integration, later phase)
|
||||
//!
|
||||
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::MoveError;
|
||||
|
||||
/// The RNG Spider deals with — the same alias upstream `klondike`
|
||||
/// exports, so both games share one shuffle stack.
|
||||
pub type Rng = rand::rngs::StdRng;
|
||||
|
||||
/// Number of tableau piles.
|
||||
pub const SPIDER_TABLEAUS: usize = 10;
|
||||
/// Total cards in play (two decks).
|
||||
@@ -146,24 +149,212 @@ impl SpiderStats {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instruction
|
||||
// Piles, run lengths, and instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One of the ten Spider tableau piles, in layout order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SpiderTableau {
|
||||
/// Leftmost pile (opens with 6 cards).
|
||||
Tableau1,
|
||||
/// Second pile (opens with 6 cards).
|
||||
Tableau2,
|
||||
/// Third pile (opens with 6 cards).
|
||||
Tableau3,
|
||||
/// Fourth pile (opens with 6 cards).
|
||||
Tableau4,
|
||||
/// Fifth pile (opens with 5 cards).
|
||||
Tableau5,
|
||||
/// Sixth pile (opens with 5 cards).
|
||||
Tableau6,
|
||||
/// Seventh pile (opens with 5 cards).
|
||||
Tableau7,
|
||||
/// Eighth pile (opens with 5 cards).
|
||||
Tableau8,
|
||||
/// Ninth pile (opens with 5 cards).
|
||||
Tableau9,
|
||||
/// Rightmost pile (opens with 5 cards).
|
||||
Tableau10,
|
||||
}
|
||||
|
||||
impl SpiderTableau {
|
||||
/// All ten piles, in layout order — the canonical iteration source,
|
||||
/// following `Suit::SUITS` / `Rank::RANKS` upstream.
|
||||
pub const ALL: [Self; SPIDER_TABLEAUS] = [
|
||||
Self::Tableau1,
|
||||
Self::Tableau2,
|
||||
Self::Tableau3,
|
||||
Self::Tableau4,
|
||||
Self::Tableau5,
|
||||
Self::Tableau6,
|
||||
Self::Tableau7,
|
||||
Self::Tableau8,
|
||||
Self::Tableau9,
|
||||
Self::Tableau10,
|
||||
];
|
||||
const ITER_BEGIN: Self = Self::Tableau1;
|
||||
const fn next(self) -> Option<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,
|
||||
/// Move the top `count` face-up cards (a same-suit descending run)
|
||||
/// from pile `from` to pile `to`. Pile indices are `0..10`.
|
||||
Move {
|
||||
/// Source pile index.
|
||||
from: u8,
|
||||
/// Destination pile index.
|
||||
to: u8,
|
||||
/// Number of cards in the moved run (≥ 1).
|
||||
count: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl SpiderInstruction {
|
||||
const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
|
||||
const fn next(self) -> Option<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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,45 +365,22 @@ pub enum SpiderInstruction {
|
||||
/// of completed runs. Everything else (undo, score bookkeeping) lives
|
||||
/// in the wrapping [`Session`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SpiderGame {
|
||||
pub struct Spider {
|
||||
tableaus: [Pile<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> {
|
||||
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 = Vec::with_capacity(SPIDER_DECK_SIZE);
|
||||
let mut cards = Stack::new();
|
||||
for copy in 0..copies {
|
||||
for &suit in suit_set {
|
||||
for rank in Rank::RANKS {
|
||||
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
|
||||
cards
|
||||
}
|
||||
|
||||
impl SpiderGame {
|
||||
impl Spider {
|
||||
/// Deals a new seeded game at the given suit difficulty.
|
||||
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
|
||||
use rand::SeedableRng;
|
||||
let mut rng = Rng::seed_from_u64(seed);
|
||||
Self::with_rng(&mut rng, suits)
|
||||
}
|
||||
|
||||
/// Deals a new game from a caller-supplied RNG.
|
||||
pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self {
|
||||
// shuffle a new two-deck spread
|
||||
let mut deck = build_deck(suits);
|
||||
shuffle(&mut deck, seed);
|
||||
use rand::seq::SliceRandom;
|
||||
deck.shuffle(rng);
|
||||
let mut cards = deck.into_iter();
|
||||
|
||||
let tableaus = core::array::from_fn(|index| {
|
||||
// Piles 0–3 open with 5 face-down cards, piles 4–9 with 4;
|
||||
// Piles 1–4 open with 5 face-down cards, piles 5–10 with 4;
|
||||
// one face-up card lands on each afterwards.
|
||||
let down_count = if index < 4 { 5 } else { 4 };
|
||||
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
|
||||
@@ -250,22 +427,24 @@ impl SpiderGame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Face-up cards of pile `index` (bottom → top). Empty slice for
|
||||
/// out-of-range indices.
|
||||
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
|
||||
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
|
||||
/// Face-up cards of a pile (bottom → top).
|
||||
pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
|
||||
self.tableaus[tableau.index()].face_up()
|
||||
}
|
||||
|
||||
/// Face-down cards of pile `index` (bottom → top).
|
||||
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
|
||||
self.tableaus
|
||||
.get(index)
|
||||
.map_or(&[], |pile| pile.face_down())
|
||||
/// Face-down cards of a pile (bottom → top).
|
||||
pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
|
||||
self.tableaus[tableau.index()].face_down()
|
||||
}
|
||||
|
||||
/// Cards remaining in the stock.
|
||||
pub fn stock_len(&self) -> usize {
|
||||
self.stock.len()
|
||||
/// Topmost face-up card of a pile.
|
||||
pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
|
||||
self.tableaus[tableau.index()].face_up().last()
|
||||
}
|
||||
|
||||
/// The stock (deals come off the top).
|
||||
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
|
||||
&self.stock
|
||||
}
|
||||
|
||||
/// Completed K→A runs removed from play so far.
|
||||
@@ -273,19 +452,16 @@ impl SpiderGame {
|
||||
self.completed_runs
|
||||
}
|
||||
|
||||
/// Length of the longest movable run on top of pile `index`: the
|
||||
/// maximal same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, index: usize) -> usize {
|
||||
let Some(pile) = self.tableaus.get(index) else {
|
||||
return 0;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
/// Length of the longest movable run on top of a pile: the maximal
|
||||
/// same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
|
||||
let up = self.tableau_face_up_cards(tableau);
|
||||
let mut len = usize::from(!up.is_empty());
|
||||
while len < up.len() {
|
||||
let above = &up[up.len() - len];
|
||||
let below = &up[up.len() - len - 1];
|
||||
let descends =
|
||||
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
|
||||
below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank());
|
||||
if !descends {
|
||||
break;
|
||||
}
|
||||
@@ -294,27 +470,27 @@ impl SpiderGame {
|
||||
len
|
||||
}
|
||||
|
||||
/// Whether `Move { from, to, count }` is legal in this position.
|
||||
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
|
||||
/// Whether a run move is legal in this position.
|
||||
fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
|
||||
let SpiderMove { from, run, to } = spider_move;
|
||||
if from == to {
|
||||
return false;
|
||||
}
|
||||
if count > self.movable_run_len(from) {
|
||||
if run.len() > self.movable_run_len(from) {
|
||||
return false;
|
||||
}
|
||||
let src_up = self.tableaus[from].face_up();
|
||||
// Bottom card of the moved run; `count <= movable_run_len <=
|
||||
// src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
|
||||
let src_up = self.tableau_face_up_cards(from);
|
||||
// Bottom card of the moved run; `run.len() <= movable_run_len
|
||||
// <= src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
|
||||
return false;
|
||||
};
|
||||
match self.tableaus[to].face_up().last() {
|
||||
match self.tableau_top_card(to) {
|
||||
// Build down regardless of suit.
|
||||
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
|
||||
Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()),
|
||||
// Empty pile accepts anything (face-down remnant can't
|
||||
// exist without a face-up card — Pile flips eagerly).
|
||||
None => self.tableaus[to].is_empty(),
|
||||
None => self.tableaus[to.index()].is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,22 +499,24 @@ impl SpiderGame {
|
||||
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
|
||||
}
|
||||
|
||||
/// Removes a completed K→A same-suit run from the top of pile
|
||||
/// `index`, if one is present. Returns `true` when a run was
|
||||
/// removed (and the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, index: usize) -> bool {
|
||||
let Some(pile) = self.tableaus.get_mut(index) else {
|
||||
return false;
|
||||
};
|
||||
/// Removes a completed K→A same-suit run from the top of a pile,
|
||||
/// if one is present. Returns `true` when a run was removed (and
|
||||
/// the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
|
||||
let pile = &mut self.tableaus[tableau.index()];
|
||||
let up = pile.face_up();
|
||||
if up.len() < RUN_LEN {
|
||||
return false;
|
||||
}
|
||||
let run = &up[up.len() - RUN_LEN..];
|
||||
let suit = run[0].suit();
|
||||
let is_complete = run.iter().enumerate().all(|(offset, card)| {
|
||||
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
|
||||
});
|
||||
// The run sits bottom-first (K → A); reversed, it must read
|
||||
// exactly Ace → King in one suit.
|
||||
let is_complete = run
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(Rank::RANKS)
|
||||
.all(|(card, rank)| card.suit() == suit && card.rank() == rank);
|
||||
if !is_complete {
|
||||
return false;
|
||||
}
|
||||
@@ -348,7 +526,7 @@ impl SpiderGame {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game for SpiderGame {
|
||||
impl Game for Spider {
|
||||
type Score = i32;
|
||||
type Stats = SpiderStats;
|
||||
type Config = SpiderConfig;
|
||||
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
|
||||
&self,
|
||||
config: &Self::Config,
|
||||
) -> impl Iterator<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()
|
||||
let state = self.clone();
|
||||
let config = config.clone();
|
||||
SpiderIter::new()
|
||||
.filter(move |&instruction| state.is_instruction_valid(&config, instruction))
|
||||
}
|
||||
|
||||
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => self.is_deal_valid(),
|
||||
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
|
||||
SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,24 +573,24 @@ impl Game for SpiderGame {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => {
|
||||
stats.deals += 1;
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
for tableau in SpiderTableau::ALL {
|
||||
match self.stock.pop() {
|
||||
Some(card) => self.tableaus[index].push(card),
|
||||
Some(card) => self.tableaus[tableau.index()].push(card),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
if self.sweep_completed_run(index) {
|
||||
for tableau in SpiderTableau::ALL {
|
||||
if self.sweep_completed_run(tableau) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SpiderInstruction::Move { from, to, count } => {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
let src_len = self.tableaus[from].face_up().len();
|
||||
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
|
||||
self.tableaus[to].extend(cards);
|
||||
SpiderInstruction::Move(SpiderMove { from, run, to }) => {
|
||||
let src_len = self.tableaus[from.index()].face_up().len();
|
||||
let (cards, _flipped) =
|
||||
self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
|
||||
self.tableaus[to.index()].extend(cards);
|
||||
if self.sweep_completed_run(to) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpiderGameState {
|
||||
seed: u64,
|
||||
session: Session<SpiderGame>,
|
||||
session: Session<Spider>,
|
||||
}
|
||||
|
||||
impl SpiderGameState {
|
||||
@@ -478,7 +638,7 @@ impl SpiderGameState {
|
||||
};
|
||||
Self {
|
||||
seed,
|
||||
session: Session::new(SpiderGame::with_seed(seed, suits), config),
|
||||
session: Session::new(Spider::with_seed(seed, suits), config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,10 +653,18 @@ impl SpiderGameState {
|
||||
}
|
||||
|
||||
/// Current position (read-only).
|
||||
pub fn game(&self) -> &SpiderGame {
|
||||
pub fn game(&self) -> &Spider {
|
||||
self.session.state().state()
|
||||
}
|
||||
|
||||
/// The underlying upstream session, for solver access and replay —
|
||||
/// the same escape hatch [`crate::game_state::GameState::session`]
|
||||
/// provides (`session().solve()` honours the budgets configured in
|
||||
/// [`SessionConfig`]).
|
||||
pub const fn session(&self) -> &Session<Spider> {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// In-play score, clamped at 0 like the Klondike wrapper.
|
||||
pub fn score(&self) -> i32 {
|
||||
self.session
|
||||
@@ -563,7 +731,7 @@ impl SpiderGameState {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGame {
|
||||
impl Spider {
|
||||
/// Builds an arbitrary position for tests: per-pile
|
||||
/// `(face_down, face_up)` card lists plus stock and completed-run
|
||||
/// count. No card-count invariants are enforced — stacked
|
||||
@@ -592,7 +760,7 @@ impl SpiderGame {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGameState {
|
||||
/// Wraps an arbitrary position in a fresh session (empty history).
|
||||
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
|
||||
pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
@@ -614,6 +782,8 @@ impl SpiderGameState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RunLength::*;
|
||||
use super::SpiderTableau::*;
|
||||
use super::*;
|
||||
|
||||
/// `Card::new` shorthand for stacked positions.
|
||||
@@ -621,6 +791,11 @@ mod tests {
|
||||
Card::new(Deck::Deck1, suit, rank)
|
||||
}
|
||||
|
||||
/// `SpiderMove` shorthand for rule tests.
|
||||
const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove {
|
||||
SpiderMove { from, run, to }
|
||||
}
|
||||
|
||||
/// K→A same-suit run, bottom (King) first — the face-up order a
|
||||
/// completed run occupies on a pile.
|
||||
fn full_run(suit: Suit) -> Vec<Card> {
|
||||
@@ -643,21 +818,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
|
||||
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
let game = Spider::with_seed(42, SpiderSuits::Four);
|
||||
for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
|
||||
let expected_down = if index < 4 { 5 } else { 4 };
|
||||
assert_eq!(game.tableau_face_down(index).len(), expected_down);
|
||||
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
|
||||
assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
|
||||
assert_eq!(
|
||||
game.tableau_face_up_cards(tableau).len(),
|
||||
1,
|
||||
"one card face-up"
|
||||
);
|
||||
}
|
||||
assert_eq!(game.stock_len(), STOCK_SIZE);
|
||||
assert_eq!(game.stock().len(), STOCK_SIZE);
|
||||
assert_eq!(game.completed_runs(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_deal_different_seed_different_deal() {
|
||||
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
|
||||
let a = Spider::with_seed(7, SpiderSuits::Four);
|
||||
let b = Spider::with_seed(7, SpiderSuits::Four);
|
||||
let c = Spider::with_seed(8, SpiderSuits::Four);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
@@ -691,15 +870,21 @@ mod tests {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
|
||||
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
|
||||
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau1, Run1, Tableau2)),
|
||||
"5♥ onto 6♠ is legal"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau2, Run1, Tableau1)),
|
||||
"6♠ onto 5♥ is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_same_suit_runs_are_movable_as_a_group() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
|
||||
// Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed).
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
@@ -711,10 +896,19 @@ mod tests {
|
||||
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
|
||||
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
|
||||
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
|
||||
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
|
||||
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
|
||||
"same-suit pair moves"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau2, Run2, Tableau3)),
|
||||
"mixed-suit pair does not"
|
||||
);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau2, Run1, Tableau4)),
|
||||
"its top card alone does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -724,27 +918,40 @@ mod tests {
|
||||
card(Suit::Spades, Rank::Nine),
|
||||
card(Suit::Spades, Rank::Eight),
|
||||
];
|
||||
// Pile 1 deliberately left empty.
|
||||
// Pile 2 deliberately left empty.
|
||||
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
|
||||
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
!game.is_move_valid(0, 2, 2),
|
||||
game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
|
||||
"run onto empty pile"
|
||||
);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau3, Run1, Tableau2)),
|
||||
"single onto empty pile"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
|
||||
"9♠8♠ cannot land on 9♥ (needs a 10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
|
||||
fn invalid_moves_rejected_self_move_and_overlong_run() {
|
||||
// Out-of-range piles and zero-card runs are unrepresentable in
|
||||
// `SpiderMove` — the enums only span legal values — so the only
|
||||
// structurally invalid shapes left are self-moves and runs
|
||||
// longer than the movable suffix.
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(!game.is_move_valid(0, 0, 1), "self-move");
|
||||
assert!(!game.is_move_valid(0, 1, 0), "zero count");
|
||||
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
|
||||
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
|
||||
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
|
||||
"self-move"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
|
||||
"count exceeds run"
|
||||
);
|
||||
}
|
||||
|
||||
// -- dealing from stock -------------------------------------------------
|
||||
@@ -752,31 +959,35 @@ mod tests {
|
||||
#[test]
|
||||
fn deal_requires_stock_and_no_empty_pile() {
|
||||
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
assert!(game.is_deal_valid());
|
||||
|
||||
let mut with_gap = junk_layout();
|
||||
with_gap[3].1.clear();
|
||||
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
|
||||
let game = Spider::from_test_layout(with_gap, stock, 0);
|
||||
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
|
||||
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_puts_one_card_on_every_pile() {
|
||||
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
|
||||
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| state.game().tableau_face_up(i).len())
|
||||
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 (index, previous) in before.iter().enumerate() {
|
||||
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
|
||||
for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up_cards(tableau).len(),
|
||||
previous + 1
|
||||
);
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -787,7 +998,7 @@ mod tests {
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("five deals must all be legal on untouched piles");
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), 0);
|
||||
assert_eq!(state.game().stock().len(), 0);
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::RuleViolation(_))
|
||||
@@ -799,30 +1010,26 @@ mod tests {
|
||||
#[test]
|
||||
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 1.
|
||||
// Pile 1: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 2.
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
|
||||
.expect("ace onto two completes the run");
|
||||
|
||||
assert_eq!(state.game().completed_runs(), 1);
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up(0),
|
||||
state.game().tableau_face_up_cards(Tableau1),
|
||||
&[card(Suit::Hearts, Rank::Nine)],
|
||||
"run removed and the buried card flipped face-up"
|
||||
);
|
||||
assert!(state.game().tableau_face_up(1).is_empty());
|
||||
assert!(state.game().tableau_face_up_cards(Tableau2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -832,16 +1039,12 @@ mod tests {
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0].1 = run;
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
assert!(!state.is_won());
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
|
||||
.expect("winning move is legal");
|
||||
assert!(state.is_won());
|
||||
assert!(matches!(
|
||||
@@ -882,11 +1085,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rule_violation_surfaces_move_error() {
|
||||
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
|
||||
let result = state.apply_instruction(SpiderInstruction::Move {
|
||||
from: 0,
|
||||
to: 0,
|
||||
count: 1,
|
||||
});
|
||||
let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||
}
|
||||
|
||||
@@ -905,6 +1104,13 @@ mod tests {
|
||||
assert!(state.game().is_instruction_valid(&config, instruction));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_iter_walks_the_full_space_once() {
|
||||
// 10 sources × 13 run lengths × 10 destinations, plus Deal.
|
||||
let count = SpiderIter::new().count();
|
||||
assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -915,11 +1121,15 @@ mod proptests {
|
||||
/// Total cards across tableaus + stock + removed runs must always
|
||||
/// equal 104, and every generated instruction must validate — for
|
||||
/// any seed, difficulty, and random walk through legal moves.
|
||||
fn card_conservation(game: &SpiderGame) -> usize {
|
||||
let on_piles: usize = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
|
||||
fn card_conservation(game: &Spider) -> usize {
|
||||
let on_piles: usize = SpiderTableau::ALL
|
||||
.into_iter()
|
||||
.map(|tableau| {
|
||||
game.tableau_face_up_cards(tableau).len()
|
||||
+ game.tableau_face_down_cards(tableau).len()
|
||||
})
|
||||
.sum();
|
||||
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
}
|
||||
|
||||
proptest! {
|
||||
|
||||
Reference in New Issue
Block a user