Files
Ferrous-Solitaire/solitaire_core/src/solver.rs
T
funman300 18d7937b51 refactor(core): derive Copy for DrawMode; drop redundant .clone() calls (M-18)
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:18:23 -07:00

1248 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, GameState};
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
/// ~10k30k 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,
}
}
}
/// A single move the solver can recommend, expressed in terms of the
/// engine-level `(source, dest, count)` triple used by `MoveRequestEvent`.
///
/// Returned as part of [`SolveOutcome::first_move`] when
/// [`try_solve_with_first_move`] or [`try_solve_from_state`] proves the
/// position winnable. The hint system surfaces this to the player as the
/// "provably best" first move.
///
/// `count` is always `1` for non-tableau-to-tableau moves (foundation moves
/// always move a single card; waste moves a single card; draws use a
/// dedicated representation that the public API surfaces as
/// `source: PileType::Stock, dest: PileType::Waste, count: 1`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolverMove {
/// Pile the move originates from.
pub source: PileType,
/// Pile the move lands on.
pub dest: PileType,
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
pub count: usize,
}
/// Solver verdict plus, when winnable, the first move on a winning path.
///
/// `result == Winnable` guarantees `first_move == Some(_)`; the inverse
/// holds only when the search proved a verdict — `Inconclusive` and
/// `Unwinnable` always carry `first_move == None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolveOutcome {
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
pub result: SolverResult,
/// First move on the solution path when `result == Winnable`,
/// otherwise `None`.
pub first_move: Option<SolverMove>,
}
/// 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 {
// Delegate to the path-recording variant and discard the move. The path
// recording is cheap (a single Option<SolverMove> per stack frame) so
// this preserves `try_solve`'s existing performance characteristics —
// the new-game retry loop, which is the hot caller, sees no slowdown.
try_solve_with_first_move(seed, draw_mode, config).result
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode` and,
/// when a win is found, returns the first move on the winning path.
///
/// Same semantics as [`try_solve`] for the verdict; the only difference is
/// that the [`SolveOutcome::first_move`] is populated when `result ==
/// SolverResult::Winnable`. `Unwinnable` and `Inconclusive` always carry
/// `first_move == None`.
///
/// Used by the engine hint system to promote H-key suggestions from a
/// heuristic to the provably-optimal first move; the hint system falls
/// back to its heuristic when this returns `Inconclusive`.
pub fn try_solve_with_first_move(
seed: u64,
draw_mode: DrawMode,
config: &SolverConfig,
) -> SolveOutcome {
let state = SolverState::initial(seed, draw_mode);
state.solve(config)
}
/// Tries to solve from an existing in-progress [`GameState`].
///
/// Mirrors [`try_solve_with_first_move`] but takes a live `GameState`
/// instead of a fresh seed. The hint system uses this so it can ask the
/// solver about the actual board the player is staring at, not just the
/// initial deal.
///
/// Reads `state.draw_mode` and the current pile contents. The active
/// `GameMode` is irrelevant — the solver only models Classic Klondike
/// rules, which are a strict subset of every other mode (Zen / Challenge
/// only differ in scoring and undo-availability).
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
let solver_state = SolverState::from_game_state(state);
solver_state.solve(config)
}
// ---------------------------------------------------------------------------
// 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. Distinct from the public [`SolverMove`] because the
/// internal form encodes each move kind structurally for fast pattern
/// matching during enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InternalMove {
/// 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,
}
impl InternalMove {
/// Convert this internal move into the public [`SolverMove`] form
/// suitable for handing off to the engine layer. Cheap — `O(1)` field
/// rewrites with no allocation.
fn to_public(self) -> SolverMove {
match self {
InternalMove::TableauToTableau { from, to, count } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Tableau(to),
count,
},
InternalMove::TableauToFoundation { from, slot } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::WasteToTableau { to } => SolverMove {
source: PileType::Waste,
dest: PileType::Tableau(to),
count: 1,
},
InternalMove::WasteToFoundation { slot } => SolverMove {
source: PileType::Waste,
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::Draw => SolverMove {
source: PileType::Stock,
dest: PileType::Waste,
count: 1,
},
}
}
}
/// 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<InternalMove> {
let mut moves: Vec<InternalMove> = 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(InternalMove::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(InternalMove::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(InternalMove::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(InternalMove::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(InternalMove::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: InternalMove) -> SolverStateUndo {
let prev_just_drew = self.just_drew;
let prev_consec = self.consecutive_draws;
match mv {
InternalMove::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;
}
InternalMove::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;
}
InternalMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() {
self.tableau[to].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
InternalMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
InternalMove::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 `Some(first_move)` (the move applied at the root that led
/// to a winning leaf) as soon as a winning leaf is found. Returns
/// `None` if the search exhausts (Unwinnable) or a budget trips —
/// callers distinguish those two cases via `*budget_exceeded`.
///
/// Path recording is implemented by stashing the root-level move on
/// each pushed frame and propagating it unchanged into deeper
/// children. Cost: one `Option<InternalMove>` (≤ 16 bytes) per
/// frame and one branch on push. Negligible on the hot path; the
/// new-game retry loop sees no measurable slowdown.
fn search(
self,
config: &SolverConfig,
visited: &mut HashSet<u64>,
moves_consumed: &mut u64,
budget_exceeded: &mut bool,
) -> Option<SolverMove> {
// 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<InternalMove>,
/// First move on the path from the root to this frame's
/// state. `None` for the root frame; populated when a child
/// frame is pushed. Propagates unchanged from parent to deeper
/// children so any winning leaf can read it directly.
root_move: Option<InternalMove>,
}
// Quick exits before allocating the stack. An already-won state
// surfaces as Winnable with no move to recommend (the player has
// nothing left to do); the engine treats this gracefully —
// `is_won` callers gate H-key hints on `!is_won` already.
if self.is_won() {
return None;
}
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return None;
}
let root_hash = self.canonical_hash();
if !visited.insert(root_hash) {
return None;
}
let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame {
state: self,
pending: root_moves.into_iter(),
root_move: None,
});
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 None;
}
let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack.
stack.pop();
continue;
};
*moves_consumed = moves_consumed.saturating_add(1);
// Determine the root-level move for the *child* we are about
// to push: if the current frame is the root (root_move is
// None) then the child's root move is `mv` itself; otherwise
// it inherits from the parent.
let child_root_move = frame.root_move.unwrap_or(mv);
let mut next = frame.state.clone();
next.apply_move(mv);
if next.is_won() {
return Some(child_root_move.to_public());
}
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(),
root_move: Some(child_root_move),
});
}
None
}
/// Drive [`SolverState::search`] and convert the raw outcome into a
/// public [`SolveOutcome`]. Shared by [`try_solve_with_first_move`]
/// and [`try_solve_from_state`].
fn solve(self, config: &SolverConfig) -> SolveOutcome {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let already_won = self.is_won();
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let result = if already_won || first_move.is_some() {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
};
SolveOutcome { result, first_move }
}
/// Build a `SolverState` from an in-progress [`GameState`].
///
/// Reads the live pile contents and `draw_mode`. Missing piles are
/// treated as empty — the engine's `GameState::new` always populates
/// every pile slot, but defensive code keeps this loader safe in the
/// face of partially-constructed test fixtures.
///
/// The search-metadata fields (`just_drew`, `consecutive_draws`)
/// reset to "no draws yet" — the solver is concerned with future
/// reachability from this position, not the engine's own draw
/// history.
fn from_game_state(game: &GameState) -> Self {
let tableau: [Vec<Card>; 7] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Tableau(i))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let foundation: [Vec<Card>; 4] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Foundation(i as u8))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let stock = game
.piles
.get(&PileType::Stock)
.map(|p| p.cards.clone())
.unwrap_or_default();
let waste = game
.piles
.get(&PileType::Waste)
.map(|p| p.cards.clone())
.unwrap_or_default();
Self {
tableau,
foundation,
stock,
waste,
draw_mode: game.draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
/// 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 first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(first_move.is_some(), "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 first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(first_move.is_none(), "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],
);
}
// -----------------------------------------------------------------------
// First-move-recording API: try_solve_with_first_move /
// try_solve_from_state. Exercised by the engine hint system.
// -----------------------------------------------------------------------
/// A synthetic GameState with each foundation holding A..Q for its
/// suit, the four Kings sitting on tableau columns 0..3, empty stock
/// and empty waste. Exactly four legal moves exist — one Tableau→
/// Foundation per King — and any one of them is the first move on a
/// solution path.
fn near_finished_game_state() -> GameState {
use crate::card::Rank;
let mut game = GameState::new(1, DrawMode::DrawOne);
// Wipe every pile.
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Foundations: A through Q for each suit. Slot 0=Clubs,
// 1=Diamonds, 2=Hearts, 3=Spades to match
// `target_foundation_slot` ordering.
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen,
];
for (slot, suit) in suit_for_slot.iter().enumerate() {
let pile = game
.piles
.get_mut(&PileType::Foundation(slot as u8))
.unwrap();
for (i, rank) in ranks_below_king.iter().enumerate() {
pile.cards.push(Card {
id: (slot as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
}
}
// Tableau 0..3: one King each, face-up.
for (col, suit) in suit_for_slot.iter().enumerate() {
game.piles
.get_mut(&PileType::Tableau(col))
.unwrap()
.cards
.push(Card {
id: 100 + col as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
});
}
game
}
#[test]
fn try_solve_with_first_move_returns_some_move_for_winnable_state() {
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Winnable,
"near-finished state must solve as Winnable"
);
let mv = outcome.first_move.expect("Winnable must include a first_move");
// The first move must be a King going from a tableau column to
// its matching foundation slot. Single-card move.
assert_eq!(mv.count, 1);
assert!(matches!(mv.source, PileType::Tableau(c) if c < 4));
assert!(matches!(mv.dest, PileType::Foundation(s) if s < 4));
}
#[test]
fn try_solve_with_first_move_returns_none_for_unwinnable_state() {
use crate::card::Rank;
// The "buried Ace under same-suit Two with no recovery" fixture
// used by `solver_recognises_obviously_unwinnable_deal`, lifted
// into a real `GameState` so we can exercise `try_solve_from_state`.
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
// destination, so the Ace is buried forever.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
// Tableau 1: a face-up King with nothing else — irrelevant; the
// pruning check elides "King → empty" no-ops.
game.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Unwinnable,
"buried-Ace fixture must be proved Unwinnable"
);
assert!(
outcome.first_move.is_none(),
"Unwinnable verdict must carry first_move == None"
);
}
#[test]
fn try_solve_with_first_move_is_deterministic() {
// Same state run multiple times yields the same first_move.
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let a = try_solve_from_state(&game, &cfg);
let b = try_solve_from_state(&game, &cfg);
let c = try_solve_from_state(&game, &cfg);
assert_eq!(a, b, "repeat solves must yield the same outcome");
assert_eq!(b, c);
}
#[test]
fn try_solve_with_first_move_seed_form_matches_state_form() {
// For a fresh seed, the two public entry points must agree —
// they share the same internal `solve()` implementation, but
// route through different state constructors. This is the
// smoke test that catches drift between them.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne);
let b = try_solve_from_state(&game, &cfg);
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
}
}