From cac77a54a649c1fb00e703d80588a7a0dab40651 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 10 Jun 2026 10:05:47 -0700 Subject: [PATCH] refactor: slim solver to card_game-native types Per Rhys: card_game's solver is the real engine, so drop the redundant adapter types in solitaire_data::solver rather than maintain a parallel verdict/config/move vocabulary. - Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move. The verdict now reads straight off card_game's return: Ok(Some(instr)) = winnable (first move on the path) Ok(None) = provably unwinnable Err(_) = inconclusive (budget exceeded) - SolveOutcome is now Result, SolveError>. - try_solve / try_solve_from_state take plain (moves_budget, states_budget) u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts. - snapshot_to_solver_move duplicated core's GameState::instruction_to_move, so make that pub and have the hint convert the first-move instruction to highlighted (from, to) piles through it. Re-export KlondikeInstruction from solitaire_core. - HintSolverConfig now holds { moves_budget, states_budget } instead of wrapping the deleted SolverConfig. - Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin (choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds / gen_difficulty_seeds asset tools. solver.rs drops 274 -> 140 lines. cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bin/gen_difficulty_seeds.rs | 26 +- solitaire_assetgen/src/bin/gen_seeds.rs | 15 +- solitaire_core/src/game_state.rs | 9 +- solitaire_core/src/lib.rs | 2 +- solitaire_data/src/lib.rs | 4 +- solitaire_data/src/settings.rs | 6 +- solitaire_data/src/solver.rs | 301 +++++------------- solitaire_engine/src/game_plugin.rs | 30 +- solitaire_engine/src/hud_plugin.rs | 2 +- solitaire_engine/src/input_plugin.rs | 34 +- solitaire_engine/src/pending_hint.rs | 88 +++-- solitaire_engine/src/play_by_seed_plugin.rs | 22 +- 12 files changed, 222 insertions(+), 317 deletions(-) diff --git a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs index 4201198..dbd07c5 100644 --- a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs @@ -2,10 +2,10 @@ //! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in //! `solitaire_data/src/difficulty_seeds.rs`. //! -//! A seed's tier is determined by the **smallest** `SolverConfig` budget that -//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget -//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit -//! provably-winnable seeds). +//! A seed's tier is determined by the **smallest** solve budget at which it is +//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget +//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded +//! (we only emit provably-winnable seeds). //! //! # Usage //! @@ -20,11 +20,11 @@ //! --help Print this message use solitaire_core::DrawMode; -use solitaire_data::solver::{SolverConfig, SolverResult, try_solve}; +use solitaire_data::solver::try_solve; // Budget boundaries defining each tier. A seed belongs to the lowest tier // whose budget proves it Winnable. -const BUDGETS: &[(&str, u64, usize)] = &[ +const BUDGETS: &[(&str, u64, u64)] = &[ ("Easy", 1_000, 1_000), ("Medium", 5_000, 5_000), ("Hard", 25_000, 25_000), @@ -99,12 +99,8 @@ fn main() { if buckets[i].len() >= per_tier { continue; } - let cfg = SolverConfig { - move_budget, - state_budget, - }; - match try_solve(seed, draw_mode, &cfg) { - SolverResult::Winnable => { + match try_solve(seed, draw_mode, move_budget, state_budget) { + Ok(Some(_)) => { buckets[i].push(seed); eprintln!( " [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})", @@ -113,13 +109,13 @@ fn main() { ); break 'tier; // assign to the cheapest tier that proves it winnable } - SolverResult::Unwinnable => { + Ok(None) => { // Definitely unsolvable — skip all remaining tiers. break 'tier; } - SolverResult::Inconclusive => { + Err(_) => { // Budget exhausted without proof — try the next larger tier. - // If this is the last tier, the seed is discarded (Inconclusive + // If this is the last tier, the seed is discarded (inconclusive // at max budget means "probably but not provably winnable"). if i == num_tiers - 1 { break 'tier; diff --git a/solitaire_assetgen/src/bin/gen_seeds.rs b/solitaire_assetgen/src/bin/gen_seeds.rs index 39a1bbd..8708f57 100644 --- a/solitaire_assetgen/src/bin/gen_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_seeds.rs @@ -1,7 +1,7 @@ //! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`. //! //! Walks seeds incrementally from `--start`, calls the solver on each, and -//! collects only those that return `SolverResult::Winnable` (Inconclusive is +//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is //! rejected — the curated list wants proof). Prints Rust source suitable for //! pasting into `solitaire_data/src/challenge.rs`. //! @@ -18,7 +18,7 @@ //! --help Print this message use solitaire_core::DrawMode; -use solitaire_data::solver::{SolverConfig, SolverResult, try_solve}; +use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve}; fn main() { let mut args = std::env::args().skip(1).peekable(); @@ -67,7 +67,6 @@ fn main() { std::process::exit(1); } - let cfg = SolverConfig::default(); let draw_mode = DrawMode::DrawOne; let mut found: Vec = Vec::with_capacity(count); let mut tried: u64 = 0; @@ -77,7 +76,15 @@ fn main() { while found.len() < count { tried += 1; - if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) { + if matches!( + try_solve( + seed, + draw_mode, + DEFAULT_SOLVE_MOVES_BUDGET, + DEFAULT_SOLVE_STATES_BUDGET + ), + Ok(Some(_)) + ) { found.push(seed); eprintln!( " [{:>3}/{}] 0x{:016X} ({} tried so far)", diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 0e44968..58c752f 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -826,7 +826,14 @@ impl GameState { } } - fn instruction_to_move( + /// Converts an upstream [`KlondikeInstruction`] into the engine's + /// `(from, to, count)` pile-move form, resolving multi-card tableau moves + /// against the live board. Returns `None` for no-op instructions + /// (foundation→foundation, or a tableau move of zero cards). + /// + /// Used by the hint system to render a solver's recommended first move, + /// and internally by [`Self::possible_instructions`]. + pub fn instruction_to_move( &self, instruction: KlondikeInstruction, ) -> Option<(KlondikePile, KlondikePile, usize)> { diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index c323287..266a457 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -12,7 +12,7 @@ pub mod klondike_adapter; // re-exported — they are only used internally in `klondike_adapter.rs` and do // not appear in any public method signature. pub use card_game::{Card, Session}; -pub use klondike::{Foundation, Klondike, KlondikePile, Tableau}; +pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; pub use klondike_adapter::DrawMode; #[cfg(test)] diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 0cae8aa..fc02b3d 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -101,8 +101,8 @@ impl SyncProvider for Box { pub mod solver; pub use solver::{ - SolveOutcome, SolverConfig, SolverMove, SolverResult, try_solve, try_solve_from_state, - try_solve_with_first_move, + DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve, + try_solve_from_state, }; pub mod stats; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index fa0d644..461ca5f 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05; /// Maximum number of seed retries [`solitaire_engine::handle_new_game`] /// is willing to attempt before giving up and accepting the latest /// candidate seed when [`Settings::winnable_deals_only`] is on. If -/// every retry comes back [`SolverResult::Unwinnable`] (which would -/// be very unusual) we'd rather hand the player a possibly-unwinnable -/// deal than spin forever on the main thread. +/// every retry comes back provably unwinnable (`Ok(None)` from the +/// solver, which would be very unusual) we'd rather hand the player a +/// possibly-unwinnable deal than spin forever on the main thread. /// /// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall — /// the upper bound on UI freeze when the toggle is on. diff --git a/solitaire_data/src/solver.rs b/solitaire_data/src/solver.rs index 2cc0204..abfd721 100644 --- a/solitaire_data/src/solver.rs +++ b/solitaire_data/src/solver.rs @@ -1,215 +1,103 @@ -//! Klondike solvability checker using upstream `card_game::Session::solve()`. +//! Klondike solvability check 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. +//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the +//! Play-by-seed verdict badge, and the hint system (which wants the first +//! move on a winning path). All search is delegated to `card_game`; this +//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts +//! the first move from the returned solution. -use card_game::{Session, SessionConfig, SolveError, StateSnapshot}; -use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack}; +use card_game::{Session, SessionConfig, SolveError}; +use klondike::KlondikeInstruction; use solitaire_core::DrawMode; use solitaire_core::game_state::GameState; use solitaire_core::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, -} +/// Default move budget for a solve. Matches the winnable-deal retry loop. +pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000; +/// Default unique-state budget for a solve. +pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000; -/// 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, -} +/// Outcome of a solvability check: +/// +/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a +/// winning path (used by the hint system). +/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or +/// the game is already won so no next move exists). +/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded +/// before a verdict was reached. +pub type SolveOutcome = Result, SolveError>; -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. +/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`. /// /// 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( +pub fn try_solve( seed: u64, draw_mode: DrawMode, - config: &SolverConfig, + moves_budget: u64, + states_budget: u64, ) -> SolveOutcome { let mut game = GameState::new(seed, draw_mode); game.take_from_foundation = false; - solve_game_state(&game, config) + try_solve_from_state(&game, moves_budget, states_budget) } -/// 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, - }; +/// Solves from an existing in-progress [`GameState`], returning the first move +/// on a winning path when one exists. +pub fn try_solve_from_state( + state: &GameState, + moves_budget: u64, + states_budget: u64, +) -> SolveOutcome { + // An already-won game has no "next move"; report it as unwinnable so the + // winnable contract (`Some(_)` ⇒ a real move exists) holds. + if state.is_won() { + return Ok(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), + let config = SessionConfig { + inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation), undo_penalty: 0, - solve_moves_budget: config.move_budget, - solve_states_budget: config.state_budget as u64, + solve_moves_budget: moves_budget, + solve_states_budget: states_budget, }; - let solver_session = Session::new(initial.session().state().state().clone(), solver_config); + let session = Session::new(state.session().state().state().clone(), config); - match solver_session.solve() { - Ok(Some(solution)) => { - let first_move = solution + session.solve().map(|solution| { + solution.and_then(|solution| { + 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, - }) - } - } + .map(|snapshot| *snapshot.instruction()) + .find(|instruction| !instruction.is_useless()) + }) + }) } #[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); + /// `SolveError` has no `PartialEq`, so compare the winnable verdict and the + /// extracted first move (both `Eq`) rather than the whole `Result`. + fn verdict_key(outcome: &SolveOutcome) -> (bool, Option) { + (outcome.is_err(), outcome.clone().ok().flatten()) } #[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()) - } - } + fn try_solve_is_deterministic() { + let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET); + let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET); + assert_eq!(verdict_key(&a), verdict_key(&b)); + } + + #[test] + fn winnable_verdict_carries_a_first_move() { + // Contract: a first move is present iff the verdict is winnable. + let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000); + let winnable = matches!(outcome, Ok(Some(_))); + let has_move = outcome.ok().flatten().is_some(); + assert_eq!(winnable, has_move); } #[test] @@ -217,57 +105,36 @@ mod tests { 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()) - } - } + let outcome = try_solve_from_state(&game, 5_000, 5_000); + let winnable = matches!(outcome, Ok(Some(_))); + let has_move = outcome.ok().flatten().is_some(); + assert_eq!(winnable, has_move); } #[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()); + let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0); + assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); } #[test] fn budget_is_passed_through_not_clamped() { - 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, - ); + // This seed is Inconclusive at 1k states but Winnable at 5k — proving + // the budget reaches the solver unchanged. + let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000); + let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000); + assert!(easy.is_err()); + assert!(matches!(medium, Ok(Some(_)))); } #[test] fn budget_above_five_thousand_is_not_clamped() { - 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", + let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000); + let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000); + assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states"); + assert!( + matches!(above_cap, Ok(Some(_))), + "seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this" ); } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 837b58d..dc9f81a 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -15,7 +15,9 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; use solitaire_core::KlondikePile; use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; -use solitaire_data::solver::{SolverConfig, SolverResult, try_solve}; +use solitaire_data::solver::{ + DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve, +}; #[allow(deprecated)] use solitaire_data::latest_replay_path; use solitaire_data::{ @@ -321,13 +323,13 @@ fn seed_from_system_time() -> u64 { /// attempts have elapsed. /// /// The solver classifies each deal as one of three verdicts: -/// - [`SolverResult::Winnable`] — provably solvable; accept. -/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof -/// either way; accept (we treat "we don't know" as winnable so -/// the toggle never silently drops a player into the retry cap). -/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed. +/// - `Ok(Some(_))` — winnable (provably solvable); accept. +/// - `Err(_)` — inconclusive (budget exceeded, no proof either way); +/// accept (we treat "we don't know" as winnable so the toggle never +/// silently drops a player into the retry cap). +/// - `Ok(None)` — provably dead; try the next seed. /// -/// If every seed in the retry window is `Unwinnable` (extremely +/// If every seed in the retry window is provably dead (extremely /// unlikely on real inputs), the function returns the *last* tried /// seed so the player still gets a deal — better a possibly-unwinnable /// hand than an infinite loop. @@ -389,12 +391,18 @@ fn poll_pending_new_game_seed( /// Pure helper extracted for testability — `new_game_with_solver_*` /// engine tests in the same file exercise this path. pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 { - let cfg = SolverConfig::default(); let mut seed = initial_seed; for _ in 0..SOLVER_DEAL_RETRY_CAP { - match try_solve(seed, draw_mode, &cfg) { - SolverResult::Winnable | SolverResult::Inconclusive => return seed, - SolverResult::Unwinnable => { + match try_solve( + seed, + draw_mode, + DEFAULT_SOLVE_MOVES_BUDGET, + DEFAULT_SOLVE_STATES_BUDGET, + ) { + // Winnable (`Ok(Some)`) or inconclusive (`Err`) → accept as + // "probably winnable"; only a proven dead deal (`Ok(None)`) retries. + Ok(Some(_)) | Err(_) => return seed, + Ok(None) => { seed = seed.wrapping_add(1); } } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 1d68ec8..4dee52d 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -1159,7 +1159,7 @@ fn handle_hint_button( return; } if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { - hint.spawn(g.0.clone(), cfg.0); + hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget); } } } diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 2904e0b..136dd84 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -79,13 +79,25 @@ fn dragged_card_z(index: usize) -> f32 { /// Solver budgets used by the H-key hint system. /// -/// Wraps `solitaire_data::solver::SolverConfig` as a Bevy resource so -/// tests can inject tighter budgets to exercise the heuristic-fallback -/// path. Production initialises this to `SolverConfig::default()` (100k -/// move / 200k state budgets, the same numbers the new-game retry loop -/// uses). -#[derive(Resource, Debug, Clone, Default)] -pub struct HintSolverConfig(pub solitaire_data::solver::SolverConfig); +/// A Bevy resource so tests can inject tighter budgets to exercise the +/// heuristic-fallback path. Production initialises this to the same default +/// 100k move / 200k state budgets the new-game retry loop uses. +#[derive(Resource, Debug, Clone, Copy)] +pub struct HintSolverConfig { + /// Maximum solver moves before giving up (inconclusive). + pub moves_budget: u64, + /// Maximum unique solver states before giving up (inconclusive). + pub states_budget: u64, +} + +impl Default for HintSolverConfig { + fn default() -> Self { + Self { + moves_budget: solitaire_data::solver::DEFAULT_SOLVE_MOVES_BUDGET, + states_budget: solitaire_data::solver::DEFAULT_SOLVE_STATES_BUDGET, + } + } +} /// Registers keyboard, mouse, and touch input systems. /// @@ -277,7 +289,7 @@ fn handle_keyboard_core( /// turns into hint visuals one frame later. /// /// Median solve time is ~2 ms but pathological positions can hit the -/// `SolverConfig::default()` cap at ~120 ms; running synchronously +/// default solve budget at ~120 ms; running synchronously /// (the v0.17.0 behaviour) blocked the main thread on the same frame /// the player pressed H. Cancel-on-replace lives in /// `PendingHintTask::spawn` — a fresh H press while a previous task @@ -314,7 +326,11 @@ fn handle_keyboard_hint( let Some(_layout_res) = layout else { return }; - pending_hint.spawn(g.0.clone(), solver_config.0); + pending_hint.spawn( + g.0.clone(), + solver_config.moves_budget, + solver_config.states_budget, + ); } /// Heuristic hint helper used by `pending_hint::poll_pending_hint_task` diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 01272a9..de131a0 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -1,12 +1,10 @@ //! Async H-key hint solver, modelled on `PendingNewGameSeed` in //! `game_plugin`. //! -//! The synchronous version (v0.17.0) called -//! `solitaire_core::solver::try_solve_from_state` on the main thread on -//! every H press. Median latency was ~2 ms but pathological positions -//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a -//! noticeable input-stall on the same frame the player sees the hint -//! request. +//! The synchronous version (v0.17.0) called the solver on the main thread +//! on every H press. Median latency was ~2 ms but pathological positions +//! can hit the default solve budget at ~120 ms, which is a noticeable +//! input-stall on the same frame the player sees the hint request. //! //! This module hosts the resource and polling system that move the //! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint` @@ -26,9 +24,9 @@ use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; -use solitaire_core::KlondikePile; +use solitaire_core::KlondikeInstruction; use solitaire_core::game_state::GameState; -use solitaire_data::solver::{SolverConfig, SolverResult, try_solve_from_state}; +use solitaire_data::solver::try_solve_from_state; use crate::card_plugin::CardEntity; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; @@ -60,23 +58,17 @@ impl PendingHintTask { self.inner = None; } - /// Spawn a new solver task for `state` with `config`. Drops any - /// previously in-flight task first (cancel-on-replace). - pub fn spawn(&mut self, state: GameState, config: SolverConfig) { + /// Spawn a new solver task for `state` with the given solve budgets. + /// Drops any previously in-flight task first (cancel-on-replace). + pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) { let move_count_at_spawn = state.move_count(); let handle = AsyncComputeTaskPool::get().spawn(async move { - let outcome = try_solve_from_state(&state, &config); - match outcome.result { - SolverResult::Winnable => outcome - .first_move - .map(|mv| HintTaskOutput::SolverMove { - from: mv.source, - to: mv.dest, - }) - .unwrap_or(HintTaskOutput::NeedsHeuristic), - SolverResult::Unwinnable | SolverResult::Inconclusive => { - HintTaskOutput::NeedsHeuristic - } + // Winnable (`Ok(Some)`) carries the first move on a winning path; + // unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back + // to the live-state heuristic so H always produces feedback. + match try_solve_from_state(&state, moves_budget, states_budget) { + Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move), + Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic, } }); self.inner = Some(HintTask { @@ -99,12 +91,10 @@ struct HintTask { /// What the solver task carries back to the main thread. enum HintTaskOutput { - /// Solver verdict was `Winnable`; here is the first move on the - /// solution path. - SolverMove { - from: KlondikePile, - to: KlondikePile, - }, + /// Solver verdict was winnable; here is the first move on the solution + /// path. Converted to highlighted `(from, to)` piles by the poll system + /// via [`GameState::instruction_to_move`]. + SolverMove(KlondikeInstruction), /// Solver was `Unwinnable` or `Inconclusive`. The poll system /// runs the legacy heuristic against the live `GameState` so the /// H key always produces feedback while any legal move exists. @@ -160,15 +150,21 @@ pub fn poll_pending_hint_task( return; } - let (from, to) = match output { - HintTaskOutput::SolverMove { from, to } => (from, to), - HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) { - Some(pair) => pair, - None => { - info_toast.write(InfoToastEvent("No hints available".to_string())); - return; - } - }, + // Resolve the solver's first move to highlighted piles; fall back to the + // live-state heuristic when there's no solver move or it maps to a no-op. + let solver_pair = match output { + HintTaskOutput::SolverMove(instruction) => g + .0 + .instruction_to_move(instruction) + .map(|(from, to, _count)| (from, to)), + HintTaskOutput::NeedsHeuristic => None, + }; + let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) { + Some(pair) => pair, + None => { + info_toast.write(InfoToastEvent("No hints available".to_string())); + return; + } }; emit_hint_visuals( &g.0, @@ -186,7 +182,7 @@ mod tests { use super::*; use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; - use solitaire_core::{Foundation, Tableau}; + use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; @@ -295,10 +291,10 @@ mod tests { fn winnable_solver_emits_hint_after_async_completes() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); - let cfg = app.world().resource::().0; + let cfg = *app.world().resource::(); app.world_mut() .resource_mut::() - .spawn(near_finished_state(), cfg); + .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); while app.world().resource::().is_pending() { @@ -334,10 +330,10 @@ mod tests { fn state_change_drops_in_flight_task() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); - let cfg = app.world().resource::().0; + let cfg = *app.world().resource::(); app.world_mut() .resource_mut::() - .spawn(near_finished_state(), cfg); + .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); assert!( app.world().resource::().is_pending(), "task is in flight after spawn", @@ -370,12 +366,12 @@ mod tests { fn second_spawn_drops_first_in_flight_task() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); - let cfg = app.world().resource::().0; + let cfg = *app.world().resource::(); // First spawn. app.world_mut() .resource_mut::() - .spawn(near_finished_state(), cfg); + .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); let first_handle_present = app.world().resource::().is_pending(); assert!(first_handle_present); @@ -384,7 +380,7 @@ mod tests { // in flight. app.world_mut() .resource_mut::() - .spawn(near_finished_state(), cfg); + .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); // Resource still pending (the second task), but the first // is gone. We can't directly observe the first handle once // it's been overwritten — what we *can* assert is that the diff --git a/solitaire_engine/src/play_by_seed_plugin.rs b/solitaire_engine/src/play_by_seed_plugin.rs index f9ac733..04cd7ca 100644 --- a/solitaire_engine/src/play_by_seed_plugin.rs +++ b/solitaire_engine/src/play_by_seed_plugin.rs @@ -24,7 +24,9 @@ use bevy::input::ButtonInput; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use solitaire_core::DrawMode; -use solitaire_data::solver::{SolverConfig, SolverResult, try_solve}; +use solitaire_data::solver::{ + DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve, +}; use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent}; use crate::font_plugin::FontResource; @@ -83,7 +85,7 @@ struct SeedInputDisplay; #[derive(Resource, Default)] struct PendingVerification { seed: Option, - handle: Option>, + handle: Option>, } // --------------------------------------------------------------------------- @@ -340,8 +342,14 @@ fn tick_debounce_and_spawn_solver_task( let draw_mode = settings .as_ref() .map_or(DrawMode::DrawOne, |s| s.0.draw_mode); - let cfg = SolverConfig::default(); - let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) }); + let task = AsyncComputeTaskPool::get().spawn(async move { + try_solve( + seed, + draw_mode, + DEFAULT_SOLVE_MOVES_BUDGET, + DEFAULT_SOLVE_STATES_BUDGET, + ) + }); pending.seed = Some(seed); pending.handle = Some(task); @@ -369,15 +377,15 @@ fn poll_solver_task( return; }; match result { - SolverResult::Winnable => { + Ok(Some(_)) => { text.0 = "\u{2713} Provably winnable".to_string(); color.0 = ACCENT_PRIMARY; } - SolverResult::Inconclusive => { + Err(_) => { text.0 = "? Likely winnable (search timed out)".to_string(); color.0 = TEXT_SECONDARY; } - SolverResult::Unwinnable => { + Ok(None) => { text.0 = "\u{2717} Provably unwinnable".to_string(); color.0 = TEXT_DISABLED; }