//! 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, } /// 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) -> Option { 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", ); } }