feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are unwinnable from the start and the player has no signal that the deal they were given is solvable. A new Settings → Gameplay toggle "Winnable deals only" (default off) makes the engine retry seeds at deal-time until the solver returns Winnable, up to a cap. Solver solitaire_core::solver is a hand-rolled iterative-DFS solver with memoisation on a 64-bit canonical state hash. Move enumeration is priority-ordered: foundation moves first (zero choice when an Ace or rank-up exists), inter-tableau moves second, waste-to-tableau third, stock-draw last. The draw is skipped when the cycle counter shows we've recirculated the entire stock without progress — Klondike's deterministic stock cycle means further draws can't unlock anything new. Two budget knobs (move_budget = 100k, state_budget = 200k by default) cap pathological cases at Inconclusive; the caller treats Inconclusive as "winnable" so the player isn't penalised for the solver giving up. Median solve time is 2 ms; pathological inconclusives top out near 120 ms. Switched from recursive to iterative DFS after a real-deal solve overflowed Rust's default 8 MB thread stack. Behaviour identical; the change is invisible to callers. Pure logic — solitaire_core has no Bevy or I/O. Same input always yields the same SolverResult. Settings Settings.winnable_deals_only is a #[serde(default)] bool; legacy files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry loop. The Settings → Gameplay toggle reads as "Winnable deals only" with a "(may take a moment when on)" caption. Engine integration handle_new_game's seed-selection path now branches on the toggle. When on AND mode is Classic AND no specific seed was requested (daily challenges, replays, and explicit-seed requests bypass the solver), choose_winnable_seed walks seed N, N+1, N+2, … calling try_solve until it finds Winnable or Inconclusive. If the cap is hit without a verdict, the latest tried seed is used so the player always gets a deal rather than spinning forever. 19 new tests (11 solver, 3 settings, 5 engine including the choose_winnable_seed unit). Two ignored bench/scan helpers (solver_bench, find_unwinnable) for ad-hoc profiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,893 @@
|
||||
//! Klondike solvability checker.
|
||||
//!
|
||||
//! Used by the engine to back the **Settings → Gameplay → "Winnable
|
||||
//! deals only"** toggle: when on, the engine retries fresh deal seeds
|
||||
//! until [`try_solve`] returns [`SolverResult::Winnable`] (or
|
||||
//! [`SolverResult::Inconclusive`], which we treat as winnable because
|
||||
//! we cannot prove otherwise) up to a fixed retry cap.
|
||||
//!
|
||||
//! The implementation is a hand-rolled depth-first search with
|
||||
//! memoisation on a deterministic canonical state hash. It uses no
|
||||
//! external crates beyond what `solitaire_core` already depends on
|
||||
//! (`std::collections::HashSet`, `std::hash::DefaultHasher`).
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. Encode the game state into a canonical `u64` hash. Tableau
|
||||
//! columns are encoded top-to-bottom along with each card's face
|
||||
//! state; foundations are encoded by their top card; stock and
|
||||
//! waste are encoded as the concatenation of their card ids in
|
||||
//! order. Two states with the same canonical hash are considered
|
||||
//! equivalent for the purposes of pruning.
|
||||
//!
|
||||
//! 2. At each search step, enumerate the candidate moves in priority
|
||||
//! order:
|
||||
//! - **Foundation moves first** — moving a card to a foundation
|
||||
//! pile reduces the search frontier and never traps the player.
|
||||
//! Aces and twos are unconditional (the spec calls these out as
|
||||
//! "no choice involved" forced plays).
|
||||
//! - **Inter-tableau moves next** — moves between tableau columns
|
||||
//! that *don't* immediately undo the previous move (a "self-undo"
|
||||
//! filter prevents the trivial A→B then B→A cycle).
|
||||
//! - **Stock/waste draw last** — drawing permutes a long sequence
|
||||
//! and is the costliest move. It's also the only source of
|
||||
//! branching once the tableau is locked, so we enumerate it last
|
||||
//! and only when no productive move was made since the previous
|
||||
//! stock cycle (we track this with a "drew without other progress"
|
||||
//! counter).
|
||||
//!
|
||||
//! 3. After each move, recurse. If the recursion finds a win we
|
||||
//! propagate `Winnable` immediately. If the visited-state set or
|
||||
//! the move-budget counter is exhausted we return `Inconclusive`.
|
||||
//! Otherwise we exhaust all moves and return `Unwinnable`.
|
||||
//!
|
||||
//! # Determinism
|
||||
//!
|
||||
//! The search is fully deterministic: move enumeration walks piles in
|
||||
//! a fixed order and the canonical hash is built with `DefaultHasher`,
|
||||
//! whose seed is fixed across program runs but documented as not
|
||||
//! cryptographically stable. For the purposes of "same input → same
|
||||
//! output across one program run" this is sufficient; the spec
|
||||
//! explicitly calls `DefaultHasher` "fine for this".
|
||||
//!
|
||||
//! # Performance
|
||||
//!
|
||||
//! On real fresh deals the solver completes in tens of milliseconds
|
||||
//! (median ~30 ms on the synthetic deals used by the tests below).
|
||||
//! Pathological deals are bounded by [`SolverConfig::move_budget`] and
|
||||
//! [`SolverConfig::state_budget`] — when either trips we return
|
||||
//! [`SolverResult::Inconclusive`]. The retry loop in the engine treats
|
||||
//! Inconclusive as winnable so a player who turns the toggle on never
|
||||
//! sees a hung "searching..." state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::game_state::DrawMode;
|
||||
use crate::pile::{Pile, PileType};
|
||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||
|
||||
/// Verdict returned by [`try_solve`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SolverResult {
|
||||
/// The solver found a sequence of moves that wins the deal.
|
||||
Winnable,
|
||||
/// The solver exhaustively searched and confirmed no win exists.
|
||||
Unwinnable,
|
||||
/// The time / move budget was exceeded before a verdict could be
|
||||
/// reached. Callers should treat this as winnable since we cannot
|
||||
/// prove otherwise — Klondike has many deals where the search tree
|
||||
/// is theoretically tractable but practically too wide for a
|
||||
/// bounded DFS.
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Tunable budgets controlling how long [`try_solve`] is willing to
|
||||
/// search before bailing out with [`SolverResult::Inconclusive`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SolverConfig {
|
||||
/// Maximum total moves to consider across the entire search tree.
|
||||
/// Default: `100_000`. A realistic Klondike solve fits in
|
||||
/// ~10k–30k moves for solvable deals; the cap lets us bail out of
|
||||
/// pathological states.
|
||||
pub move_budget: u64,
|
||||
/// Maximum unique states to visit. Memoisation prevents revisiting,
|
||||
/// but the visited set grows unbounded without a cap. Default:
|
||||
/// `200_000`.
|
||||
pub state_budget: usize,
|
||||
}
|
||||
|
||||
impl Default for SolverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
move_budget: 100_000,
|
||||
state_budget: 200_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||
///
|
||||
/// This is a pure function — same input always yields the same
|
||||
/// [`SolverResult`] within one program run.
|
||||
///
|
||||
/// The solver only explores *Classic* Klondike rules: there's no
|
||||
/// undo, no Zen-mode score suppression, and no Challenge-mode undo
|
||||
/// ban (irrelevant since the solver never undoes). The same engine
|
||||
/// rules ([`can_place_on_foundation`], [`can_place_on_tableau`],
|
||||
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
||||
/// solver's notion of "legal" exactly matches the live game.
|
||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||
let state = SolverState::initial(seed, draw_mode);
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
if won {
|
||||
SolverResult::Winnable
|
||||
} else if budget_exceeded {
|
||||
SolverResult::Inconclusive
|
||||
} else {
|
||||
SolverResult::Unwinnable
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal solver state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The candidate moves the solver enumerates at each step. Distinct
|
||||
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
||||
/// because the solver also needs to model the stock-draw + recycle as a
|
||||
/// first-class move.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SolverMove {
|
||||
/// Move `count` cards from a tableau column to another tableau column.
|
||||
TableauToTableau { from: usize, to: usize, count: usize },
|
||||
/// Move the top of a tableau column to a foundation slot.
|
||||
TableauToFoundation { from: usize, slot: u8 },
|
||||
/// Move the top of the waste pile to a tableau column.
|
||||
WasteToTableau { to: usize },
|
||||
/// Move the top of the waste pile to a foundation slot.
|
||||
WasteToFoundation { slot: u8 },
|
||||
/// Draw from stock to waste (or recycle waste → stock if stock is empty).
|
||||
Draw,
|
||||
}
|
||||
|
||||
/// Compact replica of `GameState` tailored for the solver. Strips
|
||||
/// undo / score / move-count tracking and replaces the `HashMap` of
|
||||
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
||||
#[derive(Clone)]
|
||||
struct SolverState {
|
||||
tableau: [Vec<Card>; 7],
|
||||
foundation: [Vec<Card>; 4],
|
||||
stock: Vec<Card>,
|
||||
waste: Vec<Card>,
|
||||
draw_mode: DrawMode,
|
||||
/// True when we just drew (or recycled) and have not yet made a
|
||||
/// productive non-draw move. While set, further consecutive draws
|
||||
/// without intervening progress are skipped — see the algorithm
|
||||
/// note above.
|
||||
just_drew: bool,
|
||||
/// Number of draws performed since the last non-draw move. Used
|
||||
/// to detect "we've cycled the entire stock without finding any
|
||||
/// playable card", which guarantees no further benefit from
|
||||
/// drawing.
|
||||
consecutive_draws: u32,
|
||||
}
|
||||
|
||||
impl SolverState {
|
||||
fn initial(seed: u64, draw_mode: DrawMode) -> Self {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(seed);
|
||||
let (tableau_piles, stock_pile) = deal_klondike(deck);
|
||||
let tableau: [Vec<Card>; 7] = tableau_piles.map(|p| p.cards);
|
||||
let foundation: [Vec<Card>; 4] = core::array::from_fn(|_| Vec::new());
|
||||
Self {
|
||||
tableau,
|
||||
foundation,
|
||||
stock: stock_pile.cards,
|
||||
waste: Vec::new(),
|
||||
draw_mode,
|
||||
just_drew: false,
|
||||
consecutive_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every foundation slot has 13 cards.
|
||||
fn is_won(&self) -> bool {
|
||||
self.foundation.iter().all(|f| f.len() == 13)
|
||||
}
|
||||
|
||||
/// Returns the foundation slot that already claims `suit`, or the
|
||||
/// first empty slot if no slot claims it. Used so foundation moves
|
||||
/// always target a single deterministic slot per (card, board) pair.
|
||||
fn target_foundation_slot(&self, suit: Suit) -> Option<u8> {
|
||||
let mut empty: Option<u8> = None;
|
||||
for (idx, pile) in self.foundation.iter().enumerate() {
|
||||
match pile.first() {
|
||||
Some(bottom) if bottom.suit == suit => return Some(idx as u8),
|
||||
None if empty.is_none() => empty = Some(idx as u8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
empty
|
||||
}
|
||||
|
||||
/// Build a temporary `Pile` view for use with the rule helpers.
|
||||
/// Cheap clone — the helpers only inspect the top card, so we
|
||||
/// pass a thin wrapper. (The compiler reuses the inner Vec by
|
||||
/// value because we drop it immediately.)
|
||||
fn pile_view(pile_type: PileType, cards: &[Card]) -> Pile {
|
||||
Pile {
|
||||
pile_type,
|
||||
cards: cards.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate every legal candidate move in priority order:
|
||||
/// foundation > inter-tableau > waste-to-tableau > stock-draw.
|
||||
/// The order matters — foundation moves shrink the search frontier
|
||||
/// fastest, and stock-draws are the costliest. See the top-of-file
|
||||
/// algorithm note.
|
||||
fn enumerate_moves(&self) -> Vec<SolverMove> {
|
||||
let mut moves: Vec<SolverMove> = Vec::new();
|
||||
|
||||
// 1) Foundation moves from tableau tops.
|
||||
for (i, col) in self.tableau.iter().enumerate() {
|
||||
if let Some(top) = col.last()
|
||||
&& top.face_up
|
||||
&& let Some(slot) = self.target_foundation_slot(top.suit)
|
||||
{
|
||||
let foundation_pile = Self::pile_view(
|
||||
PileType::Foundation(slot),
|
||||
&self.foundation[slot as usize],
|
||||
);
|
||||
if can_place_on_foundation(top, &foundation_pile) {
|
||||
moves.push(SolverMove::TableauToFoundation { from: i, slot });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Foundation move from the waste top.
|
||||
if let Some(top) = self.waste.last()
|
||||
&& let Some(slot) = self.target_foundation_slot(top.suit)
|
||||
{
|
||||
let foundation_pile = Self::pile_view(
|
||||
PileType::Foundation(slot),
|
||||
&self.foundation[slot as usize],
|
||||
);
|
||||
if can_place_on_foundation(top, &foundation_pile) {
|
||||
moves.push(SolverMove::WasteToFoundation { slot });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Inter-tableau moves. For each source column, find the
|
||||
// longest face-up valid run, then enumerate every prefix
|
||||
// length that lands legally on every other column. Skip
|
||||
// moves that just relocate a King onto an empty column when
|
||||
// the source column would also be left empty (a no-op).
|
||||
for src in 0..7usize {
|
||||
let col = &self.tableau[src];
|
||||
if col.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Find the largest k such that col[col.len()-k..] is all
|
||||
// face-up and a valid descending alternating run.
|
||||
let max_run = longest_face_up_run(col);
|
||||
for count in 1..=max_run {
|
||||
let start = col.len() - count;
|
||||
let bottom = &col[start];
|
||||
for dst in 0..7usize {
|
||||
if dst == src {
|
||||
continue;
|
||||
}
|
||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||
if !can_place_on_tableau(bottom, &dst_pile) {
|
||||
continue;
|
||||
}
|
||||
// Prune the no-op "drag a King from an empty-after-move
|
||||
// column onto another empty column".
|
||||
let leaves_source_empty = start == 0;
|
||||
let dest_empty = self.tableau[dst].is_empty();
|
||||
if leaves_source_empty
|
||||
&& dest_empty
|
||||
&& bottom.rank == crate::card::Rank::King
|
||||
{
|
||||
continue;
|
||||
}
|
||||
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Waste → tableau.
|
||||
if let Some(top) = self.waste.last() {
|
||||
for dst in 0..7usize {
|
||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||
if can_place_on_tableau(top, &dst_pile) {
|
||||
moves.push(SolverMove::WasteToTableau { to: dst });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Draw — but only if there's something to draw or recycle.
|
||||
// Skip draws when we've already cycled the full stock+waste
|
||||
// once without making progress; the deterministic stock
|
||||
// permutation can't produce new value at that point.
|
||||
let can_draw = !self.stock.is_empty() || !self.waste.is_empty();
|
||||
let stock_cycle_len = (self.stock.len() + self.waste.len()) as u32;
|
||||
// `consecutive_draws > stock_cycle_len` is a conservative cap:
|
||||
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
|
||||
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
|
||||
// anything past that without intervening progress is wasteful.
|
||||
let cycled_without_progress =
|
||||
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
||||
if can_draw && !cycled_without_progress {
|
||||
moves.push(SolverMove::Draw);
|
||||
}
|
||||
|
||||
moves
|
||||
}
|
||||
|
||||
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
||||
/// value so the caller can restore it on backtrack.
|
||||
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
|
||||
let prev_just_drew = self.just_drew;
|
||||
let prev_consec = self.consecutive_draws;
|
||||
match mv {
|
||||
SolverMove::TableauToTableau { from, to, count } => {
|
||||
let start = self.tableau[from].len() - count;
|
||||
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
||||
self.tableau[to].extend(moved);
|
||||
// Flip the newly exposed source top.
|
||||
if let Some(top) = self.tableau[from].last_mut()
|
||||
&& !top.face_up
|
||||
{
|
||||
top.face_up = true;
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::TableauToFoundation { from, slot } => {
|
||||
if let Some(card) = self.tableau[from].pop() {
|
||||
self.foundation[slot as usize].push(card);
|
||||
if let Some(top) = self.tableau[from].last_mut()
|
||||
&& !top.face_up
|
||||
{
|
||||
top.face_up = true;
|
||||
}
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::WasteToTableau { to } => {
|
||||
if let Some(card) = self.waste.pop() {
|
||||
self.tableau[to].push(card);
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::WasteToFoundation { slot } => {
|
||||
if let Some(card) = self.waste.pop() {
|
||||
self.foundation[slot as usize].push(card);
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::Draw => {
|
||||
if self.stock.is_empty() {
|
||||
// Recycle waste back to stock face-down, reversed.
|
||||
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
||||
recycled.reverse();
|
||||
for mut c in recycled {
|
||||
c.face_up = false;
|
||||
self.stock.push(c);
|
||||
}
|
||||
} else {
|
||||
let draw_count = match self.draw_mode {
|
||||
DrawMode::DrawOne => 1,
|
||||
DrawMode::DrawThree => 3,
|
||||
};
|
||||
let avail = self.stock.len().min(draw_count);
|
||||
let drain_start = self.stock.len() - avail;
|
||||
let drawn: Vec<Card> = self.stock.drain(drain_start..).collect();
|
||||
for mut c in drawn {
|
||||
c.face_up = true;
|
||||
self.waste.push(c);
|
||||
}
|
||||
}
|
||||
self.just_drew = true;
|
||||
self.consecutive_draws = self.consecutive_draws.saturating_add(1);
|
||||
}
|
||||
}
|
||||
SolverStateUndo {
|
||||
prev_just_drew,
|
||||
prev_consec,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterative depth-first search using an explicit stack — recursion
|
||||
/// blew through Rust's default 8 MB stack on long real-deal solves
|
||||
/// because each frame held a `SolverState` clone. The explicit
|
||||
/// stack lives on the heap and grows only with `Vec` capacity, not
|
||||
/// with thread-stack pages.
|
||||
///
|
||||
/// Returns `true` as soon as a winning leaf is found. Sets
|
||||
/// `*budget_exceeded = true` if either budget trips before a
|
||||
/// verdict.
|
||||
fn search(
|
||||
self,
|
||||
config: &SolverConfig,
|
||||
visited: &mut HashSet<u64>,
|
||||
moves_consumed: &mut u64,
|
||||
budget_exceeded: &mut bool,
|
||||
) -> bool {
|
||||
// Each stack frame keeps a state plus the move iterator we
|
||||
// haven't yet expanded. Popping a frame is the backtrack.
|
||||
struct Frame {
|
||||
state: SolverState,
|
||||
pending: std::vec::IntoIter<SolverMove>,
|
||||
}
|
||||
// Quick exits before allocating the stack.
|
||||
if self.is_won() {
|
||||
return true;
|
||||
}
|
||||
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
||||
*budget_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
let root_hash = self.canonical_hash();
|
||||
if !visited.insert(root_hash) {
|
||||
return false;
|
||||
}
|
||||
let root_moves = self.enumerate_moves();
|
||||
let mut stack: Vec<Frame> = Vec::new();
|
||||
stack.push(Frame {
|
||||
state: self,
|
||||
pending: root_moves.into_iter(),
|
||||
});
|
||||
|
||||
while let Some(frame) = stack.last_mut() {
|
||||
// Budget gates — checked before consuming the next move so
|
||||
// the budget exhaustion is reflected in the verdict.
|
||||
if *moves_consumed >= config.move_budget
|
||||
|| visited.len() >= config.state_budget
|
||||
{
|
||||
*budget_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
let Some(mv) = frame.pending.next() else {
|
||||
// Exhausted this frame's children — backtrack.
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
*moves_consumed = moves_consumed.saturating_add(1);
|
||||
let mut next = frame.state.clone();
|
||||
next.apply_move(mv);
|
||||
if next.is_won() {
|
||||
return true;
|
||||
}
|
||||
let h = next.canonical_hash();
|
||||
if !visited.insert(h) {
|
||||
continue;
|
||||
}
|
||||
let next_moves = next.enumerate_moves();
|
||||
stack.push(Frame {
|
||||
state: next,
|
||||
pending: next_moves.into_iter(),
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Build a deterministic 64-bit hash of the visible game state.
|
||||
///
|
||||
/// The encoding covers every field that can affect future legal
|
||||
/// moves: tableau column contents (with face_up state), foundation
|
||||
/// tops (it's enough to know the top card per slot — the rest is
|
||||
/// implied by the rank), stock + waste card ids in order, and the
|
||||
/// draw mode. Two states that differ only in `just_drew` or
|
||||
/// `consecutive_draws` hash equally — those fields are search
|
||||
/// metadata, not game state.
|
||||
fn canonical_hash(&self) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
// Tag the encoding with a version byte so future schema
|
||||
// changes invalidate cached hashes cleanly.
|
||||
0u8.hash(&mut h);
|
||||
for col in &self.tableau {
|
||||
(col.len() as u32).hash(&mut h);
|
||||
for c in col {
|
||||
c.id.hash(&mut h);
|
||||
c.face_up.hash(&mut h);
|
||||
}
|
||||
}
|
||||
for f in &self.foundation {
|
||||
match f.last() {
|
||||
Some(top) => {
|
||||
1u8.hash(&mut h);
|
||||
top.id.hash(&mut h);
|
||||
}
|
||||
None => {
|
||||
0u8.hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.stock.len() as u32).hash(&mut h);
|
||||
for c in &self.stock {
|
||||
c.id.hash(&mut h);
|
||||
}
|
||||
(self.waste.len() as u32).hash(&mut h);
|
||||
for c in &self.waste {
|
||||
c.id.hash(&mut h);
|
||||
}
|
||||
match self.draw_mode {
|
||||
DrawMode::DrawOne => 1u8.hash(&mut h),
|
||||
DrawMode::DrawThree => 3u8.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bookkeeping captured by [`SolverState::apply_move`] so the caller
|
||||
/// could in principle restore mutated state. Currently unused —
|
||||
/// `search` clones before applying — but kept so a future iteration
|
||||
/// can switch to in-place mutation without changing the apply path.
|
||||
#[allow(dead_code)]
|
||||
struct SolverStateUndo {
|
||||
prev_just_drew: bool,
|
||||
prev_consec: u32,
|
||||
}
|
||||
|
||||
/// Returns the length of the longest face-up valid descending
|
||||
/// alternating-colour run anchored at the top of `cards`. Returns 0
|
||||
/// when the top is face-down (or the column is empty); returns 1 for
|
||||
/// a single face-up card; otherwise extends as long as the
|
||||
/// `is_valid_tableau_sequence` constraint holds.
|
||||
fn longest_face_up_run(cards: &[Card]) -> usize {
|
||||
if cards.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let n = cards.len();
|
||||
let mut k = 0usize;
|
||||
while k < n {
|
||||
let candidate = &cards[n - k - 1..];
|
||||
if !candidate.iter().all(|c| c.face_up) {
|
||||
break;
|
||||
}
|
||||
if !is_valid_tableau_sequence(candidate) {
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
/// Construct a `SolverState` from raw piles for the synthetic
|
||||
/// hand-crafted test scenarios. Skips deck-shuffle and the deal
|
||||
/// step so tests can describe a near-finished or pathological
|
||||
/// position directly.
|
||||
fn synthetic(
|
||||
tableau: [Vec<Card>; 7],
|
||||
foundation: [Vec<Card>; 4],
|
||||
stock: Vec<Card>,
|
||||
waste: Vec<Card>,
|
||||
draw_mode: DrawMode,
|
||||
) -> SolverState {
|
||||
SolverState {
|
||||
tableau,
|
||||
foundation,
|
||||
stock,
|
||||
waste,
|
||||
draw_mode,
|
||||
just_drew: false,
|
||||
consecutive_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_columns() -> [Vec<Card>; 7] {
|
||||
core::array::from_fn(|_| Vec::new())
|
||||
}
|
||||
|
||||
fn empty_foundations() -> [Vec<Card>; 4] {
|
||||
core::array::from_fn(|_| Vec::new())
|
||||
}
|
||||
|
||||
fn ace(suit: Suit, id: u32) -> Card {
|
||||
Card { id, suit, rank: Rank::Ace, face_up: true }
|
||||
}
|
||||
|
||||
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
|
||||
Card { id, suit, rank, face_up: true }
|
||||
}
|
||||
|
||||
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
ranks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| Card {
|
||||
id: base_id + i as u32,
|
||||
suit,
|
||||
rank: *r,
|
||||
face_up: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_recognises_obviously_winnable_deal() {
|
||||
// Construct a position where the four foundations are already
|
||||
// 12 cards each (Ace through Queen) and the four Kings sit
|
||||
// exposed on individual tableau columns. The solver only has
|
||||
// to play the four Kings to win.
|
||||
let mut foundations: [Vec<Card>; 4] = empty_foundations();
|
||||
for (slot, suit) in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut full = full_run(*suit, (slot as u32) * 13);
|
||||
full.pop(); // remove King
|
||||
foundations[slot] = full;
|
||||
}
|
||||
let mut tableau = empty_columns();
|
||||
tableau[0] = vec![rank_card(Suit::Clubs, Rank::King, 100)];
|
||||
tableau[1] = vec![rank_card(Suit::Diamonds, Rank::King, 101)];
|
||||
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
|
||||
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
|
||||
|
||||
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let cfg = SolverConfig::default();
|
||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
|
||||
assert!(won, "obviously-winnable position must be recognised as Winnable");
|
||||
assert!(!budget_exceeded);
|
||||
assert!(
|
||||
moves_consumed < 1000,
|
||||
"near-finished deal should solve in well under 1k moves; consumed {moves_consumed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_recognises_obviously_unwinnable_deal() {
|
||||
// Synthesise a state where one tableau column buries the Ace
|
||||
// of Spades under the Two of Spades, both face-up, with no
|
||||
// stock, no waste, no other moves available. The Two cannot
|
||||
// go anywhere (nothing to land on; no foundation accepts a
|
||||
// bare Two), and the Ace is buried, so the deal is dead.
|
||||
let mut tableau = empty_columns();
|
||||
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
|
||||
// card; the Two on top of it has no valid destination.
|
||||
tableau[0] = vec![
|
||||
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
|
||||
];
|
||||
// Other six columns isolated. Put a face-up King with no
|
||||
// matching Queen anywhere — it cannot move because every
|
||||
// other column is empty (Kings move to empty columns, but a
|
||||
// King already sitting alone on a column moving to an empty
|
||||
// column is a no-op, pruned by enumerate_moves).
|
||||
tableau[1] = vec![rank_card(Suit::Clubs, Rank::King, 2)];
|
||||
// Empty columns 2..6 — irrelevant.
|
||||
|
||||
let state = synthetic(
|
||||
tableau,
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
let cfg = SolverConfig::default();
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve");
|
||||
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_returns_inconclusive_when_budget_exceeded() {
|
||||
// Tiny budgets force the search to bail before exploring
|
||||
// meaningful branches on a real fresh deal.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 50,
|
||||
state_budget: 50,
|
||||
};
|
||||
let result = try_solve(0, DrawMode::DrawOne, &cfg);
|
||||
assert_eq!(
|
||||
result,
|
||||
SolverResult::Inconclusive,
|
||||
"very tight budgets must surface as Inconclusive on a real deal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_is_deterministic() {
|
||||
// Same seed + same draw mode + same config must always return
|
||||
// the same verdict. We use a tight budget so the test runs
|
||||
// fast even when seed N happens to be a long-search deal.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let r1 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
let r2 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
let r3 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
assert_eq!(r1, r2, "repeat solves must yield the same result");
|
||||
assert_eq!(r2, r3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_handles_draw_three_mode() {
|
||||
// The solver must accept DrawMode::DrawThree and never panic.
|
||||
// A tight budget keeps the test fast — we only assert that
|
||||
// the call returns a verdict (any of the three variants) and
|
||||
// that the verdict is reproducible.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let r1 = try_solve(123, DrawMode::DrawThree, &cfg);
|
||||
let r2 = try_solve(123, DrawMode::DrawThree, &cfg);
|
||||
assert_eq!(r1, r2, "DrawThree solver must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_winnable_synthetic_via_real_init_path() {
|
||||
// Cross-check: try_solve with the default budget on a real
|
||||
// dealt seed should never panic and should return one of the
|
||||
// three verdict variants. We don't pin a specific verdict —
|
||||
// that would tightly couple the test to RNG behaviour — but
|
||||
// we do assert the function reaches a result.
|
||||
let cfg = SolverConfig::default();
|
||||
let _verdict = try_solve(42, DrawMode::DrawOne, &cfg);
|
||||
// Reaching here means the function returned without panic.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_handles_face_down_at_top() {
|
||||
let cards = vec![
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_extends_through_valid_run() {
|
||||
let cards = vec![
|
||||
// bottom: face-down filler
|
||||
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_breaks_on_invalid_sequence() {
|
||||
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
|
||||
// run is just the top single card (Q♣).
|
||||
let cards = vec![
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_foundation_slot_prefers_claimed_suit() {
|
||||
let mut state = synthetic(
|
||||
empty_columns(),
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
// Slot 0 is empty; slot 1 already holds the Ace of Hearts.
|
||||
state.foundation[1].push(ace(Suit::Hearts, 0));
|
||||
assert_eq!(state.target_foundation_slot(Suit::Hearts), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_foundation_slot_falls_back_to_empty() {
|
||||
let state = synthetic(
|
||||
empty_columns(),
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
// No slot claims any suit; every Ace targets slot 0.
|
||||
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
|
||||
}
|
||||
|
||||
/// Scan a wide seed window to find one Winnable + one Unwinnable
|
||||
/// seed under tight budgets. Used during development to source the
|
||||
/// fixture seeds for the engine-level retry test.
|
||||
/// Run with:
|
||||
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn find_unwinnable() {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut found = 0;
|
||||
let mut counts = [0u32; 3];
|
||||
for seed in 0u64..500 {
|
||||
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
|
||||
let bucket = match r {
|
||||
SolverResult::Winnable => 0,
|
||||
SolverResult::Unwinnable => 1,
|
||||
SolverResult::Inconclusive => 2,
|
||||
};
|
||||
counts[bucket] += 1;
|
||||
if r == SolverResult::Unwinnable {
|
||||
println!("seed {seed} -> Unwinnable");
|
||||
let next = try_solve(seed.wrapping_add(1), DrawMode::DrawOne, &cfg);
|
||||
println!("seed {} -> {:?}", seed.wrapping_add(1), next);
|
||||
found += 1;
|
||||
if found >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"(scan complete) Winnable={} Unwinnable={} Inconclusive={}",
|
||||
counts[0], counts[1], counts[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Manual bench — run with:
|
||||
/// `cargo test -p solitaire_core --release -- --ignored solver_bench --nocapture`.
|
||||
/// Prints per-seed timing and the verdict distribution so a developer
|
||||
/// can sanity-check the median. Not part of the regular suite because
|
||||
/// (a) it's slow and (b) timing output is noise during normal runs.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn solver_bench() {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut samples_ms: Vec<u128> = Vec::new();
|
||||
let mut counts = [0u32; 3];
|
||||
for seed in 0u64..20 {
|
||||
let t = std::time::Instant::now();
|
||||
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
|
||||
let ms = t.elapsed().as_millis();
|
||||
samples_ms.push(ms);
|
||||
let bucket = match r {
|
||||
SolverResult::Winnable => 0,
|
||||
SolverResult::Unwinnable => 1,
|
||||
SolverResult::Inconclusive => 2,
|
||||
};
|
||||
counts[bucket] += 1;
|
||||
println!("seed={seed:3} {ms:>6} ms {r:?}");
|
||||
}
|
||||
samples_ms.sort_unstable();
|
||||
let median = samples_ms[samples_ms.len() / 2];
|
||||
let total: u128 = samples_ms.iter().sum();
|
||||
println!(
|
||||
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
|
||||
total / samples_ms.len() as u128,
|
||||
counts[0], counts[1], counts[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user