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