Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2f0b592a |
@@ -1109,66 +1109,6 @@ impl GameState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Full winning line from the current position:
|
||||
///
|
||||
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
|
||||
/// [`GameState::apply_instruction`] reaches a won game.
|
||||
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
|
||||
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
|
||||
///
|
||||
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
|
||||
/// copy of the board like [`GameState::solve_first_move`], but returns the
|
||||
/// whole path instead of the first move, compacted with
|
||||
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
|
||||
/// back to an already-seen state — the raw DFS trace is full of them).
|
||||
///
|
||||
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
|
||||
/// are additionally stripped when the remaining sequence still replays to
|
||||
/// a win; if stripping one would break a later move's preconditions the
|
||||
/// unstripped cleaned line is returned instead, so the replay contract
|
||||
/// above always holds. `clean_solution` is quadratic-ish in line length —
|
||||
/// callers should run this off the UI thread with modest budgets.
|
||||
pub fn winning_line(
|
||||
&self,
|
||||
moves_budget: u64,
|
||||
states_budget: u64,
|
||||
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
|
||||
if self.is_won() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
|
||||
let config = SessionConfig {
|
||||
inner: inner.clone(),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: moves_budget,
|
||||
solve_states_budget: states_budget,
|
||||
};
|
||||
let start = self.session.state().state().clone();
|
||||
let session = Session::new(start.clone(), config);
|
||||
|
||||
let Some(solution) = session.solve()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let cleaned: Vec<KlondikeInstruction> = solution
|
||||
.clean_solution()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect();
|
||||
let filtered: Vec<KlondikeInstruction> = cleaned
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|instruction| !instruction.is_useless())
|
||||
.collect();
|
||||
|
||||
if line_replays_to_win(start, &inner, &filtered) {
|
||||
Ok(Some(filtered))
|
||||
} else {
|
||||
Ok(Some(cleaned))
|
||||
}
|
||||
}
|
||||
|
||||
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
@@ -1186,25 +1126,6 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when applying `line` in order from `start` is legal at every step
|
||||
/// and ends in a won game. Pure replay check backing
|
||||
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
|
||||
/// win" contract.
|
||||
fn line_replays_to_win(
|
||||
mut state: Klondike,
|
||||
config: &KlondikeConfig,
|
||||
line: &[KlondikeInstruction],
|
||||
) -> bool {
|
||||
let mut stats = <Klondike as card_game::Game>::Stats::default();
|
||||
for &instruction in line {
|
||||
if !state.is_instruction_valid(config, instruction) {
|
||||
return false;
|
||||
}
|
||||
state.process_instruction(&mut stats, config, instruction);
|
||||
}
|
||||
state.is_win()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1430,69 +1351,6 @@ mod tests {
|
||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||
}
|
||||
|
||||
// ── Full winning line (winning_line) ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn winning_line_replays_to_a_won_game() {
|
||||
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
|
||||
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
|
||||
// (take-from-foundation off) keep the search space identical to
|
||||
// that baseline. The returned line must apply cleanly through the
|
||||
// normal instruction pipeline and end in a win — the whole point
|
||||
// of the API contract.
|
||||
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
let line = game
|
||||
.winning_line(5_000, 5_000)
|
||||
.expect("this seed must not exhaust a 5k budget")
|
||||
.expect("this seed must be winnable");
|
||||
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
|
||||
for (i, instruction) in line.iter().enumerate() {
|
||||
game.apply_instruction(*instruction)
|
||||
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
|
||||
}
|
||||
assert!(game.is_won(), "line must end in a won game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_contains_no_useless_moves() {
|
||||
// The foundation→foundation strip must survive the replay check on
|
||||
// this seed; a line shown to the player should never shuffle
|
||||
// between foundations. Same proven-winnable seed and standard
|
||||
// rules as `winning_line_replays_to_a_won_game`.
|
||||
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
let line = game
|
||||
.winning_line(5_000, 5_000)
|
||||
.expect("budget")
|
||||
.expect("winnable");
|
||||
assert!(
|
||||
!line.iter().any(KlondikeInstruction::is_useless),
|
||||
"filtered line must not contain foundation→foundation moves"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_is_inconclusive_when_budget_exhausted() {
|
||||
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||
let outcome = game.winning_line(5_000, 0);
|
||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_matches_first_move_verdict() {
|
||||
// The two solver entry points must agree on winnability for the
|
||||
// same position and budgets (both are deterministic DFS).
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let first = game.solve_first_move(5_000, 5_000);
|
||||
let line = game.winning_line(5_000, 5_000);
|
||||
assert_eq!(
|
||||
matches!(first, Ok(Some(_))),
|
||||
matches!(line, Ok(Some(_))),
|
||||
"solve_first_move and winning_line disagree on winnability"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod scoring;
|
||||
pub mod spider;
|
||||
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
@@ -21,6 +22,13 @@ pub use klondike::{
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
///
|
||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
//! Spider solitaire rules — a second [`card_game::Game`] implementation
|
||||
//! alongside the upstream Klondike.
|
||||
//!
|
||||
//! Built directly on the upstream `card_game` containers: two decks
|
||||
//! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten
|
||||
//! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the
|
||||
//! `Stack`/`Pile` public API that `klondike` uses internally.
|
||||
//!
|
||||
//! ## Rules implemented
|
||||
//!
|
||||
//! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive
|
||||
//! 5 (top card face-up) — 54 dealt, 50 in stock.
|
||||
//! - Build down regardless of suit; only same-suit descending runs may
|
||||
//! be picked up and moved.
|
||||
//! - Empty piles accept any card or movable run.
|
||||
//! - The stock deals one card to every pile (10 total), only while no
|
||||
//! pile is empty.
|
||||
//! - A completed K→A same-suit run is removed automatically; the game
|
||||
//! is won when all 8 runs are removed.
|
||||
//! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits
|
||||
//! spread over the same 104 cards.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! Deals are seeded with an inline SplitMix64 + Fisher–Yates shuffle:
|
||||
//! `solitaire_core` has no `rand` dependency (and adding one needs
|
||||
//! explicit approval), so Spider's seed space is deliberately
|
||||
//! self-contained rather than shared with Klondike's `StdRng` seeds.
|
||||
//! The same seed + suit count always produces the same deal.
|
||||
//!
|
||||
//! ## Card identity caveat (engine integration, later phase)
|
||||
//!
|
||||
//! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit
|
||||
//! games need more than four copies of a suit, so *identical* `Card`
|
||||
//! values legitimately coexist (e.g. eight `♠A` in a 1-suit game,
|
||||
//! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so
|
||||
//! core is unaffected, but the engine's `Card → Entity` mapping is
|
||||
//! keyed by card value and will need positional keys before a Spider
|
||||
//! UI lands.
|
||||
|
||||
use card_game::{Card, Deck, Game, Pile, Rank, Session, SessionConfig, Stack, Suit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::MoveError;
|
||||
|
||||
/// Number of tableau piles.
|
||||
pub const SPIDER_TABLEAUS: usize = 10;
|
||||
/// Total cards in play (two decks).
|
||||
pub const SPIDER_DECK_SIZE: usize = 104;
|
||||
/// Cards left in the stock after the opening deal (5 deals × 10).
|
||||
const STOCK_SIZE: usize = 50;
|
||||
/// Cards in one completed run (K → A).
|
||||
const RUN_LEN: usize = 13;
|
||||
/// Runs required to win.
|
||||
const TOTAL_RUNS: u8 = 8;
|
||||
/// Face-down cards in the deepest opening pile.
|
||||
const MAX_FACE_DOWN: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spider difficulty: how many distinct suits the 104 cards span.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum SpiderSuits {
|
||||
/// All spades — the beginner layout (default).
|
||||
#[default]
|
||||
One,
|
||||
/// Spades + hearts.
|
||||
Two,
|
||||
/// Full two-deck spread, the classic four-suit game.
|
||||
Four,
|
||||
}
|
||||
|
||||
impl SpiderSuits {
|
||||
/// Suits used at this difficulty.
|
||||
fn suits(self) -> &'static [Suit] {
|
||||
match self {
|
||||
Self::One => &[Suit::Spades],
|
||||
Self::Two => &[Suit::Spades, Suit::Hearts],
|
||||
Self::Four => &[Suit::Spades, Suit::Hearts, Suit::Clubs, Suit::Diamonds],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`.
|
||||
///
|
||||
/// Defaults follow the familiar Microsoft formula: start at 500, −1
|
||||
/// per move, +100 per completed run (the upstream session adds
|
||||
/// `undos × undo_penalty` on top).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpiderScoring {
|
||||
/// Score a fresh deal starts from.
|
||||
pub base: i32,
|
||||
/// Added per move (conventionally negative).
|
||||
pub move_penalty: i32,
|
||||
/// Added per completed K→A run.
|
||||
pub run_bonus: i32,
|
||||
}
|
||||
|
||||
impl Default for SpiderScoring {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: 500,
|
||||
move_penalty: -1,
|
||||
run_bonus: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full Spider rules configuration (the `Game::Config` type).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpiderConfig {
|
||||
/// Suit-count difficulty.
|
||||
pub suits: SpiderSuits,
|
||||
/// Scoring knobs.
|
||||
pub scoring: SpiderScoring,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-game counters (the `Game::Stats` type). Cumulative — they are
|
||||
/// not rolled back by undo, matching upstream `KlondikeStats`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpiderStats {
|
||||
moves: u32,
|
||||
deals: u32,
|
||||
runs_completed: u32,
|
||||
}
|
||||
|
||||
impl SpiderStats {
|
||||
/// Total instructions processed (moves + deals).
|
||||
pub const fn moves(&self) -> u32 {
|
||||
self.moves
|
||||
}
|
||||
/// Stock deals performed.
|
||||
pub const fn deals(&self) -> u32 {
|
||||
self.deals
|
||||
}
|
||||
/// K→A runs completed over the whole game (not undone-adjusted).
|
||||
pub const fn runs_completed(&self) -> u32 {
|
||||
self.runs_completed
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instruction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One atomic Spider action (the `Game::Instruction` type).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SpiderInstruction {
|
||||
/// Deal one card from the stock onto every tableau pile.
|
||||
Deal,
|
||||
/// Move the top `count` face-up cards (a same-suit descending run)
|
||||
/// from pile `from` to pile `to`. Pile indices are `0..10`.
|
||||
Move {
|
||||
/// Source pile index.
|
||||
from: u8,
|
||||
/// Destination pile index.
|
||||
to: u8,
|
||||
/// Number of cards in the moved run (≥ 1).
|
||||
count: u8,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure Spider game position: ten tableaus, the stock, and the count
|
||||
/// of completed runs. Everything else (undo, score bookkeeping) lives
|
||||
/// in the wrapping [`Session`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SpiderGame {
|
||||
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
|
||||
stock: Stack<STOCK_SIZE>,
|
||||
completed_runs: u8,
|
||||
}
|
||||
|
||||
/// SplitMix64 step — small, well-known, and dependency-free. Spider
|
||||
/// only needs a deterministic shuffle, not cryptographic quality.
|
||||
const fn splitmix64(state: u64) -> (u64, u64) {
|
||||
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
(state, z ^ (z >> 31))
|
||||
}
|
||||
|
||||
/// In-place Fisher–Yates driven by SplitMix64. The modulo bias is
|
||||
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
|
||||
fn shuffle(cards: &mut [Card], seed: u64) {
|
||||
let mut state = seed;
|
||||
for i in (1..cards.len()).rev() {
|
||||
let (next_state, r) = splitmix64(state);
|
||||
state = next_state;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let j = (r % (i as u64 + 1)) as usize;
|
||||
cards.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the 104-card Spider deck for a suit-count difficulty.
|
||||
///
|
||||
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
|
||||
/// 1- and 2-suit games necessarily contain identical `Card` values —
|
||||
/// see the module docs.
|
||||
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
|
||||
let suit_set = suits.suits();
|
||||
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
|
||||
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
|
||||
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
|
||||
for copy in 0..copies {
|
||||
for &suit in suit_set {
|
||||
for rank in Rank::RANKS {
|
||||
cards.push(Card::new(decks[copy % decks.len()], suit, rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
impl SpiderGame {
|
||||
/// Deals a new seeded game at the given suit difficulty.
|
||||
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
|
||||
let mut deck = build_deck(suits);
|
||||
shuffle(&mut deck, seed);
|
||||
let mut cards = deck.into_iter();
|
||||
|
||||
let tableaus = core::array::from_fn(|index| {
|
||||
// Piles 0–3 open with 5 face-down cards, piles 4–9 with 4;
|
||||
// one face-up card lands on each afterwards.
|
||||
let down_count = if index < 4 { 5 } else { 4 };
|
||||
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
|
||||
let mut pile = Pile::new_face_down(stack);
|
||||
if let Some(card) = cards.next() {
|
||||
pile.push(card);
|
||||
}
|
||||
pile
|
||||
});
|
||||
let stock: Stack<STOCK_SIZE> = cards.collect();
|
||||
|
||||
Self {
|
||||
tableaus,
|
||||
stock,
|
||||
completed_runs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Face-up cards of pile `index` (bottom → top). Empty slice for
|
||||
/// out-of-range indices.
|
||||
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
|
||||
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
|
||||
}
|
||||
|
||||
/// Face-down cards of pile `index` (bottom → top).
|
||||
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
|
||||
self.tableaus
|
||||
.get(index)
|
||||
.map_or(&[], |pile| pile.face_down())
|
||||
}
|
||||
|
||||
/// Cards remaining in the stock.
|
||||
pub fn stock_len(&self) -> usize {
|
||||
self.stock.len()
|
||||
}
|
||||
|
||||
/// Completed K→A runs removed from play so far.
|
||||
pub const fn completed_runs(&self) -> u8 {
|
||||
self.completed_runs
|
||||
}
|
||||
|
||||
/// Length of the longest movable run on top of pile `index`: the
|
||||
/// maximal same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, index: usize) -> usize {
|
||||
let Some(pile) = self.tableaus.get(index) else {
|
||||
return 0;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
let mut len = usize::from(!up.is_empty());
|
||||
while len < up.len() {
|
||||
let above = &up[up.len() - len];
|
||||
let below = &up[up.len() - len - 1];
|
||||
let descends =
|
||||
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
|
||||
if !descends {
|
||||
break;
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
/// Whether `Move { from, to, count }` is legal in this position.
|
||||
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
|
||||
return false;
|
||||
}
|
||||
if count > self.movable_run_len(from) {
|
||||
return false;
|
||||
}
|
||||
let src_up = self.tableaus[from].face_up();
|
||||
// Bottom card of the moved run; `count <= movable_run_len <=
|
||||
// src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
|
||||
return false;
|
||||
};
|
||||
match self.tableaus[to].face_up().last() {
|
||||
// Build down regardless of suit.
|
||||
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
|
||||
// Empty pile accepts anything (face-down remnant can't
|
||||
// exist without a face-up card — Pile flips eagerly).
|
||||
None => self.tableaus[to].is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the stock may deal: cards remain and no pile is empty.
|
||||
fn is_deal_valid(&self) -> bool {
|
||||
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
|
||||
}
|
||||
|
||||
/// Removes a completed K→A same-suit run from the top of pile
|
||||
/// `index`, if one is present. Returns `true` when a run was
|
||||
/// removed (and the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, index: usize) -> bool {
|
||||
let Some(pile) = self.tableaus.get_mut(index) else {
|
||||
return false;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
if up.len() < RUN_LEN {
|
||||
return false;
|
||||
}
|
||||
let run = &up[up.len() - RUN_LEN..];
|
||||
let suit = run[0].suit();
|
||||
let is_complete = run.iter().enumerate().all(|(offset, card)| {
|
||||
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
|
||||
});
|
||||
if !is_complete {
|
||||
return false;
|
||||
}
|
||||
let start = up.len() - RUN_LEN;
|
||||
let (_removed, _flipped) = pile.take_range_flip_up(start..);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Game for SpiderGame {
|
||||
type Score = i32;
|
||||
type Stats = SpiderStats;
|
||||
type Config = SpiderConfig;
|
||||
type Instruction = SpiderInstruction;
|
||||
|
||||
fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score {
|
||||
let scoring = &config.scoring;
|
||||
let moves = i32::try_from(stats.moves).unwrap_or(i32::MAX);
|
||||
let runs = i32::from(self.completed_runs);
|
||||
scoring.base + moves.saturating_mul(scoring.move_penalty) + runs * scoring.run_bonus
|
||||
}
|
||||
|
||||
fn possible_instructions(
|
||||
&self,
|
||||
config: &Self::Config,
|
||||
) -> impl Iterator<Item = Self::Instruction> + use<> {
|
||||
let mut out = Vec::new();
|
||||
if self.is_deal_valid() {
|
||||
out.push(SpiderInstruction::Deal);
|
||||
}
|
||||
for from in 0..SPIDER_TABLEAUS {
|
||||
let max_run = self.movable_run_len(from);
|
||||
for count in 1..=max_run {
|
||||
for to in 0..SPIDER_TABLEAUS {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let instruction = SpiderInstruction::Move {
|
||||
from: from as u8,
|
||||
to: to as u8,
|
||||
count: count as u8,
|
||||
};
|
||||
if self.is_move_valid(from as u8, to as u8, count as u8) {
|
||||
out.push(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = config;
|
||||
out.into_iter()
|
||||
}
|
||||
|
||||
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => self.is_deal_valid(),
|
||||
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_instruction(
|
||||
&mut self,
|
||||
stats: &mut Self::Stats,
|
||||
config: &Self::Config,
|
||||
instruction: Self::Instruction,
|
||||
) {
|
||||
// The trait offers no error channel; an invalid instruction is
|
||||
// a no-op rather than a panic (error policy: no panics in game
|
||||
// logic). Callers route through `SpiderGameState`, which
|
||||
// validates first and surfaces `MoveError`.
|
||||
if !self.is_instruction_valid(config, instruction) {
|
||||
return;
|
||||
}
|
||||
stats.moves += 1;
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => {
|
||||
stats.deals += 1;
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
match self.stock.pop() {
|
||||
Some(card) => self.tableaus[index].push(card),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
if self.sweep_completed_run(index) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SpiderInstruction::Move { from, to, count } => {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
let src_len = self.tableaus[from].face_up().len();
|
||||
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
|
||||
self.tableaus[to].extend(cards);
|
||||
if self.sweep_completed_run(to) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_win(&self) -> bool {
|
||||
self.completed_runs >= TOTAL_RUNS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spider counterpart of [`crate::game_state::GameState`]: owns the
|
||||
/// upstream [`Session`] (undo history + score/undo bookkeeping) and
|
||||
/// exposes `Result<_, MoveError>` mutations, mirroring the Klondike
|
||||
/// wrapper's conventions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpiderGameState {
|
||||
seed: u64,
|
||||
session: Session<SpiderGame>,
|
||||
}
|
||||
|
||||
impl SpiderGameState {
|
||||
/// New seeded game with the default (1-suit) difficulty.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self::new_with_suits(seed, SpiderSuits::default())
|
||||
}
|
||||
|
||||
/// New seeded game at an explicit suit difficulty.
|
||||
pub fn new_with_suits(seed: u64, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
scoring: SpiderScoring::default(),
|
||||
},
|
||||
// Undoing costs one move, matching the familiar Spider
|
||||
// scoring; the upstream formula applies `undos × penalty`.
|
||||
undo_penalty: -1,
|
||||
..SessionConfig::default()
|
||||
};
|
||||
Self {
|
||||
seed,
|
||||
session: Session::new(SpiderGame::with_seed(seed, suits), config),
|
||||
}
|
||||
}
|
||||
|
||||
/// The deal seed this game was created from.
|
||||
pub const fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
|
||||
/// Suit difficulty of this game.
|
||||
pub fn suits(&self) -> SpiderSuits {
|
||||
self.session.config().inner.suits
|
||||
}
|
||||
|
||||
/// Current position (read-only).
|
||||
pub fn game(&self) -> &SpiderGame {
|
||||
self.session.state().state()
|
||||
}
|
||||
|
||||
/// In-play score, clamped at 0 like the Klondike wrapper.
|
||||
pub fn score(&self) -> i32 {
|
||||
self.session
|
||||
.state()
|
||||
.score(self.session.stats(), self.session.config())
|
||||
.max(0)
|
||||
}
|
||||
|
||||
/// Whether all 8 runs are complete.
|
||||
pub fn is_won(&self) -> bool {
|
||||
self.session.is_win()
|
||||
}
|
||||
|
||||
/// Total instructions applied (deals + moves), from history.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
u32::try_from(self.session.history().len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Successful undos this session.
|
||||
pub fn undo_count(&self) -> u32 {
|
||||
self.session.stats().undos()
|
||||
}
|
||||
|
||||
/// Legal instructions in the current position.
|
||||
pub fn possible_instructions(&self) -> Vec<SpiderInstruction> {
|
||||
self.session.possible_instructions().collect()
|
||||
}
|
||||
|
||||
/// Applies one instruction. `Err` on illegal instructions and
|
||||
/// finished games; the session records the undo snapshot.
|
||||
pub fn apply_instruction(&mut self, instruction: SpiderInstruction) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
let config = &self.session.config().inner;
|
||||
if !self
|
||||
.session
|
||||
.state()
|
||||
.state()
|
||||
.is_instruction_valid(config, instruction)
|
||||
{
|
||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||
}
|
||||
self.session.process_instruction(instruction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores the previous snapshot. Mirrors the Klondike wrapper's
|
||||
/// guards; the upstream session counts the undo for scoring.
|
||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
if self.session.history().is_empty() {
|
||||
return Err(MoveError::UndoStackEmpty);
|
||||
}
|
||||
self.session.undo();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGame {
|
||||
/// Builds an arbitrary position for tests: per-pile
|
||||
/// `(face_down, face_up)` card lists plus stock and completed-run
|
||||
/// count. No card-count invariants are enforced — stacked
|
||||
/// positions for rule tests routinely use partial layouts.
|
||||
pub fn from_test_layout(
|
||||
piles: [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS],
|
||||
stock: Vec<Card>,
|
||||
completed_runs: u8,
|
||||
) -> Self {
|
||||
let mut iter = piles.into_iter();
|
||||
let tableaus = core::array::from_fn(|_| {
|
||||
// `piles` has exactly SPIDER_TABLEAUS entries.
|
||||
let (down, up) = iter.next().unwrap_or_default();
|
||||
let mut pile = Pile::new_face_down(down.into_iter().collect());
|
||||
pile.extend(up);
|
||||
pile
|
||||
});
|
||||
Self {
|
||||
tableaus,
|
||||
stock: stock.into_iter().collect(),
|
||||
completed_runs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGameState {
|
||||
/// Wraps an arbitrary position in a fresh session (empty history).
|
||||
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
scoring: SpiderScoring::default(),
|
||||
},
|
||||
undo_penalty: -1,
|
||||
..SessionConfig::default()
|
||||
};
|
||||
Self {
|
||||
seed: 0,
|
||||
session: Session::new(game, config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Card::new` shorthand for stacked positions.
|
||||
fn card(suit: Suit, rank: Rank) -> Card {
|
||||
Card::new(Deck::Deck1, suit, rank)
|
||||
}
|
||||
|
||||
/// K→A same-suit run, bottom (King) first — the face-up order a
|
||||
/// completed run occupies on a pile.
|
||||
fn full_run(suit: Suit) -> Vec<Card> {
|
||||
let mut ranks: Vec<Rank> = Rank::RANKS.to_vec();
|
||||
ranks.reverse();
|
||||
ranks.into_iter().map(|rank| card(suit, rank)).collect()
|
||||
}
|
||||
|
||||
fn empty_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
|
||||
core::array::from_fn(|_| (Vec::new(), Vec::new()))
|
||||
}
|
||||
|
||||
/// A layout where every pile is non-empty (single junk card), so
|
||||
/// deal-validity tests can toggle exactly one condition.
|
||||
fn junk_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
|
||||
core::array::from_fn(|_| (Vec::new(), vec![card(Suit::Clubs, Rank::King)]))
|
||||
}
|
||||
|
||||
// -- dealing ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
|
||||
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
let expected_down = if index < 4 { 5 } else { 4 };
|
||||
assert_eq!(game.tableau_face_down(index).len(), expected_down);
|
||||
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
|
||||
}
|
||||
assert_eq!(game.stock_len(), STOCK_SIZE);
|
||||
assert_eq!(game.completed_runs(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_deal_different_seed_different_deal() {
|
||||
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_difficulty_controls_suit_spread() {
|
||||
let one = build_deck(SpiderSuits::One);
|
||||
let two = build_deck(SpiderSuits::Two);
|
||||
let four = build_deck(SpiderSuits::Four);
|
||||
for deck in [&one, &two, &four] {
|
||||
assert_eq!(deck.len(), SPIDER_DECK_SIZE);
|
||||
}
|
||||
assert!(one.iter().all(|c| c.suit() == Suit::Spades));
|
||||
assert!(
|
||||
two.iter()
|
||||
.all(|c| matches!(c.suit(), Suit::Spades | Suit::Hearts))
|
||||
);
|
||||
for suit in Suit::SUITS {
|
||||
assert_eq!(
|
||||
four.iter().filter(|c| c.suit() == suit).count(),
|
||||
2 * RUN_LEN,
|
||||
"four-suit deck has exactly two copies per suit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- move rules -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn single_card_builds_down_regardless_of_suit() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
|
||||
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
|
||||
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_same_suit_runs_are_movable_as_a_group() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
];
|
||||
layout[1].1 = vec![
|
||||
card(Suit::Hearts, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
];
|
||||
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
|
||||
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
|
||||
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
|
||||
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
|
||||
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Nine),
|
||||
card(Suit::Spades, Rank::Eight),
|
||||
];
|
||||
// Pile 1 deliberately left empty.
|
||||
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
|
||||
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
|
||||
assert!(
|
||||
!game.is_move_valid(0, 2, 2),
|
||||
"9♠8♠ cannot land on 9♥ (needs a 10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(!game.is_move_valid(0, 0, 1), "self-move");
|
||||
assert!(!game.is_move_valid(0, 1, 0), "zero count");
|
||||
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
|
||||
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
|
||||
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
|
||||
}
|
||||
|
||||
// -- dealing from stock -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn deal_requires_stock_and_no_empty_pile() {
|
||||
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
assert!(game.is_deal_valid());
|
||||
|
||||
let mut with_gap = junk_layout();
|
||||
with_gap[3].1.clear();
|
||||
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
|
||||
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
|
||||
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_puts_one_card_on_every_pile() {
|
||||
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
|
||||
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| state.game().tableau_face_up(i).len())
|
||||
.collect();
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal is legal on a fresh game");
|
||||
for (index, previous) in before.iter().enumerate() {
|
||||
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_supports_exactly_five_deals() {
|
||||
let mut state = SpiderGameState::new_with_suits(11, SpiderSuits::One);
|
||||
for _ in 0..5 {
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("five deals must all be legal on untouched piles");
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), 0);
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::RuleViolation(_))
|
||||
));
|
||||
}
|
||||
|
||||
// -- run completion & win ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 1.
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.expect("ace onto two completes the run");
|
||||
|
||||
assert_eq!(state.game().completed_runs(), 1);
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up(0),
|
||||
&[card(Suit::Hearts, Rank::Nine)],
|
||||
"run removed and the buried card flipped face-up"
|
||||
);
|
||||
assert!(state.game().tableau_face_up(1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_run_wins_the_game() {
|
||||
let mut layout = empty_layout();
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0].1 = run;
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
assert!(!state.is_won());
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.expect("winning move is legal");
|
||||
assert!(state.is_won());
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::GameAlreadyWon)
|
||||
));
|
||||
}
|
||||
|
||||
// -- session wrapper -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undo_restores_position_and_counts() {
|
||||
let mut state = SpiderGameState::new_with_suits(21, SpiderSuits::Two);
|
||||
let fresh = state.game().clone();
|
||||
|
||||
assert!(matches!(state.undo(), Err(MoveError::UndoStackEmpty)));
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal");
|
||||
assert_ne!(*state.game(), fresh);
|
||||
|
||||
state.undo().expect("one snapshot to restore");
|
||||
assert_eq!(*state.game(), fresh);
|
||||
assert_eq!(state.undo_count(), 1);
|
||||
assert_eq!(state.move_count(), 0, "undo pops the history entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_follows_base_move_penalty_and_run_bonus() {
|
||||
let mut state = SpiderGameState::new_with_suits(5, SpiderSuits::One);
|
||||
assert_eq!(state.score(), 500, "fresh game starts at base");
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal");
|
||||
assert_eq!(state.score(), 499, "one move costs one point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_violation_surfaces_move_error() {
|
||||
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
|
||||
let result = state.apply_instruction(SpiderInstruction::Move {
|
||||
from: 0,
|
||||
to: 0,
|
||||
count: 1,
|
||||
});
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||
}
|
||||
|
||||
// -- generated instructions ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_are_all_valid_and_include_deal() {
|
||||
let state = SpiderGameState::new_with_suits(13, SpiderSuits::Four);
|
||||
let config = SpiderConfig::default();
|
||||
let instructions = state.possible_instructions();
|
||||
assert!(
|
||||
instructions.contains(&SpiderInstruction::Deal),
|
||||
"fresh game has no empty pile, so Deal must be offered"
|
||||
);
|
||||
for instruction in instructions {
|
||||
assert!(state.game().is_instruction_valid(&config, instruction));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Total cards across tableaus + stock + removed runs must always
|
||||
/// equal 104, and every generated instruction must validate — for
|
||||
/// any seed, difficulty, and random walk through legal moves.
|
||||
fn card_conservation(game: &SpiderGame) -> usize {
|
||||
let on_piles: usize = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
|
||||
.sum();
|
||||
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn random_walks_conserve_cards_and_stay_valid(
|
||||
seed in any::<u64>(),
|
||||
suit_pick in 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,9 @@ use crate::{
|
||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
@@ -94,7 +93,6 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
|
||||
@@ -159,12 +159,6 @@ pub struct DeleteAccountRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct PauseRequestEvent;
|
||||
|
||||
/// Request to solve the current deal and auto-play the winning line.
|
||||
/// Fired by the pause menu's "Show solution" button; consumed by
|
||||
/// `solution_playback_plugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ShowSolutionRequestEvent;
|
||||
|
||||
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
||||
/// button alongside the existing `F1` accelerator so the overlay is
|
||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||
|
||||
@@ -46,7 +46,6 @@ pub mod safe_area;
|
||||
mod schedule_checks;
|
||||
pub mod selection_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod solution_playback_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -162,7 +161,6 @@ pub use settings_plugin::{
|
||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
};
|
||||
pub use solitaire_data::SyncProvider;
|
||||
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{
|
||||
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
||||
|
||||
@@ -71,12 +71,6 @@ struct PauseResumeButton;
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseForfeitButton;
|
||||
|
||||
/// Marker on the "Show solution" secondary button on the pause modal.
|
||||
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
|
||||
/// `solution_playback_plugin` takes it from there.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseSolutionButton;
|
||||
|
||||
/// Marker on the forfeit-confirm modal scrim.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ForfeitConfirmScreen;
|
||||
@@ -113,7 +107,6 @@ impl Plugin for PausePlugin {
|
||||
.add_message::<PauseRequestEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(
|
||||
@@ -132,7 +125,6 @@ impl Plugin for PausePlugin {
|
||||
handle_pause_draw_buttons,
|
||||
handle_pause_resume_button,
|
||||
handle_pause_forfeit_button,
|
||||
handle_pause_solution_button,
|
||||
handle_forfeit_request,
|
||||
handle_forfeit_confirm_buttons,
|
||||
handle_forfeit_keyboard,
|
||||
@@ -312,22 +304,6 @@ fn handle_pause_resume_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a click on the pause modal's "Show solution" button into
|
||||
/// a resume (`PauseRequestEvent` — playback can't run while paused)
|
||||
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
|
||||
fn handle_pause_solution_button(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
|
||||
mut pause: MessageWriter<PauseRequestEvent>,
|
||||
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction == Interaction::Pressed {
|
||||
pause.write(PauseRequestEvent);
|
||||
solution.write(crate::events::ShowSolutionRequestEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a click on the pause modal's Forfeit button into a
|
||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||
/// confirm modal — same code path as the `G` accelerator.
|
||||
@@ -522,14 +498,6 @@ fn spawn_pause_screen(
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseSolutionButton,
|
||||
"Show solution",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseResumeButton,
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
//! "Show solution" — solve the current deal off-thread and auto-play
|
||||
//! the winning line through the normal move pipeline.
|
||||
//!
|
||||
//! The pause menu's "Show solution" button fires
|
||||
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
|
||||
//! the live [`GameState`], runs [`GameState::winning_line`] on
|
||||
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
|
||||
//! seconds — §2.4 never block the main thread), then steps the returned
|
||||
//! instructions on a cadence, one `MoveRequestEvent` /
|
||||
//! `DrawRequestEvent` per tick — the same events player input produces,
|
||||
//! so animations, scoring, undo history, and win detection all behave
|
||||
//! exactly as if the player made the moves.
|
||||
//!
|
||||
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
|
||||
//! on any rejected move (which is what a player interfering mid-line
|
||||
//! produces — their move diverges the state, the next scripted step
|
||||
//! becomes illegal, and the rejection stops the run cleanly).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{
|
||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
ShowSolutionRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Seconds between scripted moves — slow enough to follow, fast enough
|
||||
/// not to drag on a 100-move line.
|
||||
const STEP_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Initial delay before the first scripted move, giving the pause modal
|
||||
/// time to close and the player a beat to see what's happening.
|
||||
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
|
||||
|
||||
/// In-flight solver task plus the `move_count` snapshot used to detect
|
||||
/// a stale result (player moved while the solver ran). Mirrors
|
||||
/// `PendingHintTask`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SolutionSolveTask {
|
||||
inner: Option<(u32, Task<SolveTaskOutput>)>,
|
||||
}
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum SolveTaskOutput {
|
||||
Line(Vec<KlondikeInstruction>),
|
||||
Unwinnable,
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Queue of instructions currently being auto-played, or empty when no
|
||||
/// playback is active. HUD/UI may read `is_active` to badge the state.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SolutionPlayback {
|
||||
queue: VecDeque<KlondikeInstruction>,
|
||||
cooldown: f32,
|
||||
}
|
||||
|
||||
impl SolutionPlayback {
|
||||
/// `true` while a solution line is being auto-played.
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.queue.is_empty()
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy plugin for the Show-solution flow. See the module docs.
|
||||
pub struct SolutionPlaybackPlugin;
|
||||
|
||||
impl Plugin for SolutionPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// add_message is idempotent — GamePlugin registers most of these
|
||||
// too, but this plugin must also boot standalone under
|
||||
// MinimalPlugins in tests.
|
||||
app.init_resource::<SolutionSolveTask>()
|
||||
.init_resource::<SolutionPlayback>()
|
||||
.add_message::<ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_show_solution_request,
|
||||
poll_solution_task,
|
||||
cancel_playback_on_interrupt,
|
||||
drive_solution_playback,
|
||||
)
|
||||
.chain()
|
||||
.before(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a solver task from the live game state. A repeat request
|
||||
/// while one is already in flight (or playback is running) is ignored
|
||||
/// — the button is idempotent, not a queue.
|
||||
fn handle_show_solution_request(
|
||||
mut requests: MessageReader<ShowSolutionRequestEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut task: ResMut<SolutionSolveTask>,
|
||||
playback: Res<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
requests.clear();
|
||||
if task.inner.is_some() || playback.is_active() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else { return };
|
||||
if game.0.is_won() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
|
||||
let snapshot: GameState = game.0.clone();
|
||||
let move_count = snapshot.move_count();
|
||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
|
||||
Ok(Some(line)) => SolveTaskOutput::Line(line),
|
||||
Ok(None) => SolveTaskOutput::Unwinnable,
|
||||
Err(_) => SolveTaskOutput::Inconclusive,
|
||||
}
|
||||
});
|
||||
task.inner = Some((move_count, handle));
|
||||
}
|
||||
|
||||
/// Polls the solver; on completion either starts playback or explains
|
||||
/// why there is nothing to play. A result computed for a position the
|
||||
/// player has since moved past is discarded silently.
|
||||
fn poll_solution_task(
|
||||
mut task: ResMut<SolutionSolveTask>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(output) = future::block_on(future::poll_once(handle)) else {
|
||||
return;
|
||||
};
|
||||
let move_count_at_spawn = *move_count_at_spawn;
|
||||
task.inner = None;
|
||||
|
||||
let Some(game) = game else { return };
|
||||
if game.0.move_count() != move_count_at_spawn {
|
||||
return; // Stale — the board moved while we were solving.
|
||||
}
|
||||
|
||||
match output {
|
||||
SolveTaskOutput::Line(line) if line.is_empty() => {}
|
||||
SolveTaskOutput::Line(line) => {
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"Solution found — playing {} moves. Press Esc to stop.",
|
||||
line.len()
|
||||
)));
|
||||
playback.queue = line.into();
|
||||
playback.cooldown = FIRST_STEP_DELAY_SECS;
|
||||
}
|
||||
SolveTaskOutput::Unwinnable => {
|
||||
toast.write(InfoToastEvent(
|
||||
"No winning line exists from this position.".to_string(),
|
||||
));
|
||||
}
|
||||
SolveTaskOutput::Inconclusive => {
|
||||
toast.write(InfoToastEvent(
|
||||
"Couldn't find a solution within the search budget.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback on Esc, pause, undo / new-game requests, or a
|
||||
/// rejected move (the signature of the player diverging the board
|
||||
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
|
||||
/// effect without one extra scripted move slipping out.
|
||||
fn cancel_playback_on_interrupt(
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut rejected: MessageReader<MoveRejectedEvent>,
|
||||
mut undos: MessageReader<UndoRequestEvent>,
|
||||
mut new_games: MessageReader<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !playback.is_active() {
|
||||
rejected.clear();
|
||||
undos.clear();
|
||||
new_games.clear();
|
||||
return;
|
||||
}
|
||||
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||
let interrupted = esc
|
||||
|| paused.is_some_and(|p| p.0)
|
||||
|| rejected.read().next().is_some()
|
||||
|| undos.read().next().is_some()
|
||||
|| new_games.read().next().is_some();
|
||||
if interrupted {
|
||||
playback.stop();
|
||||
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
|
||||
/// while playback is active, translated to the same request events
|
||||
/// player input produces. Instructions that no longer decode against
|
||||
/// the live state stop the run instead of guessing.
|
||||
fn drive_solution_playback(
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut draws: MessageWriter<DrawRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !playback.is_active() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else {
|
||||
playback.stop();
|
||||
return;
|
||||
};
|
||||
if game.0.is_won() {
|
||||
playback.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
playback.cooldown -= time.delta_secs();
|
||||
if playback.cooldown > 0.0 {
|
||||
return;
|
||||
}
|
||||
playback.cooldown = STEP_INTERVAL_SECS;
|
||||
|
||||
let Some(instruction) = playback.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
draws.write(DrawRequestEvent);
|
||||
}
|
||||
other => {
|
||||
// Decode against the LIVE state — tableau run lengths depend on
|
||||
// the current face-up counts, so this must happen at step time,
|
||||
// not at solve time.
|
||||
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
|
||||
playback.stop();
|
||||
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||
return;
|
||||
};
|
||||
moves.write(MoveRequestEvent { from, to, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
|
||||
/// Seed proven Winnable at 5k budgets by the core
|
||||
/// `budget_is_passed_through_not_clamped` test. Solved here under
|
||||
/// standard rules (no take-from-foundation) to match that baseline.
|
||||
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
|
||||
|
||||
fn winnable_state() -> GameState {
|
||||
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
game
|
||||
}
|
||||
|
||||
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
|
||||
/// playback driver emits, exactly as in production.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn request_solution(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
|
||||
.write(ShowSolutionRequestEvent);
|
||||
}
|
||||
|
||||
/// Pump updates until the solver task resolves (wall-clock bounded,
|
||||
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
|
||||
fn pump_until_solved(app: &mut App) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"solver task should have completed within 30 s wall-clock",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_solves_and_arms_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_some(),
|
||||
"request must spawn a solver task",
|
||||
);
|
||||
pump_until_solved(&mut app);
|
||||
assert!(
|
||||
app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"a winnable position must arm playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_reaches_win_through_normal_pipeline() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
pump_until_solved(&mut app);
|
||||
assert!(app.world().resource::<SolutionPlayback>().is_active());
|
||||
|
||||
// Force each step instead of waiting out the real cadence; a line
|
||||
// is at most a few hundred instructions.
|
||||
for _ in 0..600 {
|
||||
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
|
||||
app.update();
|
||||
if app.world().resource::<GameStateResource>().0.is_won() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<GameStateResource>().0.is_won(),
|
||||
"auto-played line must drive the real game to a win",
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"playback must deactivate once the game is won",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"Esc must stop playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_request_is_ignored_while_active() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"a request during active playback must not spawn a solver task",
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user