5e8735886f
Wire card_game 0.4.0 and klondike 0.3.0 as workspace deps in solitaire_core and clean the integration seam across five areas: - Move From<card_game::Suit/Rank> bridge impls out of card.rs and into klondike_adapter.rs so the product-type module is upstream-dep-free - Add `use crate::card` alias to adapter; rename card_from_kl parameter to avoid shadowing; correct score_for_undo doc (it is Ferrous policy, not an upstream default — the solver explicitly passes undo_penalty=0) - Mark Pile as a read-only projection / data-transfer type in its doc comment so game logic isn't accidentally routed through it - Add GameState::session() read accessor exposing the underlying Session<Klondike> for replay history and solver use by external crates; update solver.rs to use the accessor instead of the pub(crate) field - Re-export Foundation, Klondike, KlondikePile, Session, Tableau from solitaire_core::lib so downstream crates (engine, wasm) can import from one place without a direct klondike/card_game dep - Add proptest property tests: card conservation (52 unique IDs always present), deal determinism, undo pile-layout invariant, legal moves always succeed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
|
|
//!
|
|
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
|
|
//! toggle and by the hint system when it wants the first move on a winning path.
|
|
|
|
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
|
|
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
|
|
|
|
use crate::game_state::{DrawMode, GameState};
|
|
use crate::klondike_adapter::KlondikeAdapter;
|
|
|
|
/// 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 move / state budget was exceeded before a verdict could be reached.
|
|
Inconclusive,
|
|
}
|
|
|
|
/// Tunable budgets controlling how long [`try_solve`] is willing to search.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct SolverConfig {
|
|
/// Maximum total moves to consider across the entire search tree.
|
|
pub move_budget: u64,
|
|
/// Maximum unique states to visit.
|
|
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 engine-level pile terms.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SolverMove {
|
|
/// Pile the move originates from.
|
|
pub source: KlondikePile,
|
|
/// Pile the move lands on.
|
|
pub dest: KlondikePile,
|
|
/// 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.
|
|
#[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`.
|
|
pub first_move: Option<SolverMove>,
|
|
}
|
|
|
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
|
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
|
try_solve_with_first_move(seed, draw_mode, config).result
|
|
}
|
|
|
|
/// Tries to solve a fresh Classic-mode game and, when winnable, returns the
|
|
/// first move on a winning path.
|
|
///
|
|
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
|
/// take-from-foundation house rule stays disabled here.
|
|
pub fn try_solve_with_first_move(
|
|
seed: u64,
|
|
draw_mode: DrawMode,
|
|
config: &SolverConfig,
|
|
) -> SolveOutcome {
|
|
let mut game = GameState::new(seed, draw_mode);
|
|
game.take_from_foundation = false;
|
|
solve_game_state(&game, config)
|
|
}
|
|
|
|
/// Tries to solve from an existing in-progress [`GameState`].
|
|
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
|
|
solve_game_state(state, config)
|
|
}
|
|
|
|
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
|
|
if config.state_budget == 0 {
|
|
return SolveOutcome {
|
|
result: SolverResult::Inconclusive,
|
|
first_move: None,
|
|
};
|
|
}
|
|
|
|
// Preserve the historical payload contract: winnable verdicts always carry
|
|
// a first move. An already-won state therefore returns no recommendation.
|
|
if initial.is_won {
|
|
return SolveOutcome {
|
|
result: SolverResult::Unwinnable,
|
|
first_move: None,
|
|
};
|
|
}
|
|
|
|
let solver_config = SessionConfig {
|
|
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
|
|
undo_penalty: 0,
|
|
solve_moves_budget: config.move_budget,
|
|
solve_states_budget: config.state_budget as u64,
|
|
};
|
|
let solver_session = Session::new(initial.session().state().state().clone(), solver_config);
|
|
|
|
match solver_session.solve() {
|
|
Ok(Some(solution)) => {
|
|
let first_move = solution
|
|
.raw_solution()
|
|
.iter()
|
|
.find_map(snapshot_to_solver_move);
|
|
if let Some(first_move) = first_move {
|
|
SolveOutcome {
|
|
result: SolverResult::Winnable,
|
|
first_move: Some(first_move),
|
|
}
|
|
} else {
|
|
SolveOutcome {
|
|
result: SolverResult::Inconclusive,
|
|
first_move: None,
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => SolveOutcome {
|
|
result: SolverResult::Unwinnable,
|
|
first_move: None,
|
|
},
|
|
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
|
result: SolverResult::Inconclusive,
|
|
first_move: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
|
let source_state = snapshot.state().state();
|
|
match *snapshot.instruction() {
|
|
KlondikeInstruction::RotateStock => Some(SolverMove {
|
|
source: KlondikePile::Stock,
|
|
dest: KlondikePile::Stock,
|
|
count: 1,
|
|
}),
|
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
|
let source = match dst_foundation.src {
|
|
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
|
|
KlondikePile::Stock => KlondikePile::Stock,
|
|
KlondikePile::Foundation(_) => return None,
|
|
};
|
|
Some(SolverMove {
|
|
source,
|
|
dest: KlondikePile::Foundation(dst_foundation.foundation),
|
|
count: 1,
|
|
})
|
|
}
|
|
KlondikeInstruction::DstTableau(dst_tableau) => {
|
|
let (source, count) = match dst_tableau.src {
|
|
KlondikePileStack::Tableau(tableau_stack) => {
|
|
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
|
|
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
|
if count == 0 {
|
|
return None;
|
|
}
|
|
(KlondikePile::Tableau(tableau_stack.tableau), count)
|
|
}
|
|
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
|
KlondikePileStack::Foundation(foundation) => {
|
|
(KlondikePile::Foundation(foundation), 1)
|
|
}
|
|
};
|
|
|
|
Some(SolverMove {
|
|
source,
|
|
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
|
count,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn try_solve_with_first_move_is_deterministic() {
|
|
let config = SolverConfig::default();
|
|
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
|
let b = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
|
let c = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
|
assert_eq!(a, b);
|
|
assert_eq!(b, c);
|
|
}
|
|
|
|
#[test]
|
|
fn try_solve_with_first_move_returns_consistent_payload() {
|
|
let config = SolverConfig {
|
|
move_budget: 5_000,
|
|
state_budget: 5_000,
|
|
};
|
|
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
|
match outcome.result {
|
|
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
|
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
|
assert!(outcome.first_move.is_none())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn try_solve_from_state_uses_live_game_state() {
|
|
let mut game = GameState::new(42, DrawMode::DrawOne);
|
|
game.draw().expect("draw must succeed");
|
|
|
|
let config = SolverConfig {
|
|
move_budget: 5_000,
|
|
state_budget: 5_000,
|
|
};
|
|
let outcome = try_solve_from_state(&game, &config);
|
|
match outcome.result {
|
|
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
|
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
|
assert!(outcome.first_move.is_none())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zero_state_budget_is_inconclusive() {
|
|
let config = SolverConfig {
|
|
move_budget: 5_000,
|
|
state_budget: 0,
|
|
};
|
|
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
|
assert_eq!(outcome.result, SolverResult::Inconclusive);
|
|
assert!(outcome.first_move.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn budget_is_passed_through_not_clamped() {
|
|
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
|
|
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
|
|
// Differing results confirm solve_game_state passes the caller's
|
|
// state_budget unchanged to the underlying solver.
|
|
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
|
|
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
|
assert_eq!(
|
|
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy),
|
|
SolverResult::Inconclusive,
|
|
);
|
|
assert_eq!(
|
|
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
|
|
SolverResult::Winnable,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn budget_above_five_thousand_is_not_clamped() {
|
|
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
|
|
// states but Winnable at 50 000. Before this fix, solve_game_state
|
|
// applied `config.state_budget.min(5_000)` internally, so a 50k config
|
|
// was silently reduced to 5k — making both calls return Inconclusive and
|
|
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
|
|
// This assertion fails if the cap is re-introduced.
|
|
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
|
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
|
|
assert_eq!(
|
|
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap),
|
|
SolverResult::Inconclusive,
|
|
"seed must be Inconclusive at 5 000 states",
|
|
);
|
|
assert_eq!(
|
|
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &above_cap),
|
|
SolverResult::Winnable,
|
|
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this",
|
|
);
|
|
}
|
|
}
|