refactor: slim solver to card_game-native types
Build and Deploy / build-and-push (push) Failing after 1m34s
Web E2E / web-e2e (push) Failing after 4m22s

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<Option<KlondikeInstruction>, 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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-10 10:05:47 -07:00
parent 2d0359c2ee
commit cac77a54a6
12 changed files with 222 additions and 317 deletions
@@ -2,10 +2,10 @@
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in //! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`. //! `solitaire_data/src/difficulty_seeds.rs`.
//! //!
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that //! A seed's tier is determined by the **smallest** solve budget at which it is
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget //! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit //! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
//! provably-winnable seeds). //! (we only emit provably-winnable seeds).
//! //!
//! # Usage //! # Usage
//! //!
@@ -20,11 +20,11 @@
//! --help Print this message //! --help Print this message
use solitaire_core::DrawMode; 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 // Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable. // whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[ const BUDGETS: &[(&str, u64, u64)] = &[
("Easy", 1_000, 1_000), ("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000), ("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000), ("Hard", 25_000, 25_000),
@@ -99,12 +99,8 @@ fn main() {
if buckets[i].len() >= per_tier { if buckets[i].len() >= per_tier {
continue; continue;
} }
let cfg = SolverConfig { match try_solve(seed, draw_mode, move_budget, state_budget) {
move_budget, Ok(Some(_)) => {
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed); buckets[i].push(seed);
eprintln!( eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})", " [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
@@ -113,13 +109,13 @@ fn main() {
); );
break 'tier; // assign to the cheapest tier that proves it winnable break 'tier; // assign to the cheapest tier that proves it winnable
} }
SolverResult::Unwinnable => { Ok(None) => {
// Definitely unsolvable — skip all remaining tiers. // Definitely unsolvable — skip all remaining tiers.
break 'tier; break 'tier;
} }
SolverResult::Inconclusive => { Err(_) => {
// Budget exhausted without proof — try the next larger tier. // 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"). // at max budget means "probably but not provably winnable").
if i == num_tiers - 1 { if i == num_tiers - 1 {
break 'tier; break 'tier;
+11 -4
View File
@@ -1,7 +1,7 @@
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`. //! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
//! //!
//! Walks seeds incrementally from `--start`, calls the solver on each, and //! 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 //! rejected — the curated list wants proof). Prints Rust source suitable for
//! pasting into `solitaire_data/src/challenge.rs`. //! pasting into `solitaire_data/src/challenge.rs`.
//! //!
@@ -18,7 +18,7 @@
//! --help Print this message //! --help Print this message
use solitaire_core::DrawMode; 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() { fn main() {
let mut args = std::env::args().skip(1).peekable(); let mut args = std::env::args().skip(1).peekable();
@@ -67,7 +67,6 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
let cfg = SolverConfig::default();
let draw_mode = DrawMode::DrawOne; let draw_mode = DrawMode::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count); let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0; let mut tried: u64 = 0;
@@ -77,7 +76,15 @@ fn main() {
while found.len() < count { while found.len() < count {
tried += 1; 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); found.push(seed);
eprintln!( eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)", " [{:>3}/{}] 0x{:016X} ({} tried so far)",
+8 -1
View File
@@ -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, &self,
instruction: KlondikeInstruction, instruction: KlondikeInstruction,
) -> Option<(KlondikePile, KlondikePile, usize)> { ) -> Option<(KlondikePile, KlondikePile, usize)> {
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod klondike_adapter;
// re-exported — they are only used internally in `klondike_adapter.rs` and do // re-exported — they are only used internally in `klondike_adapter.rs` and do
// not appear in any public method signature. // not appear in any public method signature.
pub use card_game::{Card, Session}; 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; pub use klondike_adapter::DrawMode;
#[cfg(test)] #[cfg(test)]
+2 -2
View File
@@ -101,8 +101,8 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
pub mod solver; pub mod solver;
pub use solver::{ pub use solver::{
SolveOutcome, SolverConfig, SolverMove, SolverResult, try_solve, try_solve_from_state, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
try_solve_with_first_move, try_solve_from_state,
}; };
pub mod stats; pub mod stats;
+3 -3
View File
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`] /// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest /// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If /// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would /// every retry comes back provably unwinnable (`Ok(None)` from the
/// be very unusual) we'd rather hand the player a possibly-unwinnable /// solver, which would be very unusual) we'd rather hand the player a
/// deal than spin forever on the main thread. /// possibly-unwinnable deal than spin forever on the main thread.
/// ///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall — /// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on. /// the upper bound on UI freeze when the toggle is on.
+84 -217
View File
@@ -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"** //! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
//! toggle and by the hint system when it wants the first move on a winning path. //! 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 card_game::{Session, SessionConfig, SolveError};
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack}; use klondike::KlondikeInstruction;
use solitaire_core::DrawMode; use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter; use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Verdict returned by [`try_solve`]. /// Default move budget for a solve. Matches the winnable-deal retry loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
pub enum SolverResult { /// Default unique-state budget for a solve.
/// The solver found a sequence of moves that wins the deal. pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
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. /// Outcome of a solvability check:
#[derive(Debug, Clone, Copy, PartialEq, Eq)] ///
pub struct SolverConfig { /// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
/// Maximum total moves to consider across the entire search tree. /// winning path (used by the hint system).
pub move_budget: u64, /// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
/// Maximum unique states to visit. /// the game is already won so no next move exists).
pub state_budget: usize, /// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
} /// before a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
impl Default for SolverConfig { /// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
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 /// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here. /// take-from-foundation house rule stays disabled here.
pub fn try_solve_with_first_move( pub fn try_solve(
seed: u64, seed: u64,
draw_mode: DrawMode, draw_mode: DrawMode,
config: &SolverConfig, moves_budget: u64,
states_budget: u64,
) -> SolveOutcome { ) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode); let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false; 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`]. /// Solves from an existing in-progress [`GameState`], returning the first move
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome { /// on a winning path when one exists.
solve_game_state(state, config) pub fn try_solve_from_state(
} state: &GameState,
moves_budget: u64,
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome { states_budget: u64,
if config.state_budget == 0 { ) -> SolveOutcome {
return SolveOutcome { // An already-won game has no "next move"; report it as unwinnable so the
result: SolverResult::Inconclusive, // winnable contract (`Some(_)` ⇒ a real move exists) holds.
first_move: None, if state.is_won() {
}; return Ok(None);
} }
// Preserve the historical payload contract: winnable verdicts always carry let config = SessionConfig {
// a first move. An already-won state therefore returns no recommendation. inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
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, undo_penalty: 0,
solve_moves_budget: config.move_budget, solve_moves_budget: moves_budget,
solve_states_budget: config.state_budget as u64, 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() { session.solve().map(|solution| {
Ok(Some(solution)) => { solution.and_then(|solution| {
let first_move = solution solution
.raw_solution() .raw_solution()
.iter() .iter()
.find_map(snapshot_to_solver_move); .map(|snapshot| *snapshot.instruction())
if let Some(first_move) = first_move { .find(|instruction| !instruction.is_useless())
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] /// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
fn try_solve_with_first_move_is_deterministic() { /// extracted first move (both `Eq`) rather than the whole `Result`.
let config = SolverConfig::default(); fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &config); (outcome.is_err(), outcome.clone().ok().flatten())
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] #[test]
fn try_solve_with_first_move_returns_consistent_payload() { fn try_solve_is_deterministic() {
let config = SolverConfig { let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
move_budget: 5_000, let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
state_budget: 5_000, assert_eq!(verdict_key(&a), verdict_key(&b));
}; }
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
match outcome.result { #[test]
SolverResult::Winnable => assert!(outcome.first_move.is_some()), fn winnable_verdict_carries_a_first_move() {
SolverResult::Unwinnable | SolverResult::Inconclusive => { // Contract: a first move is present iff the verdict is winnable.
assert!(outcome.first_move.is_none()) 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] #[test]
@@ -217,57 +105,36 @@ mod tests {
let mut game = GameState::new(42, DrawMode::DrawOne); let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed"); game.draw().expect("draw must succeed");
let config = SolverConfig { let outcome = try_solve_from_state(&game, 5_000, 5_000);
move_budget: 5_000, let winnable = matches!(outcome, Ok(Some(_)));
state_budget: 5_000, let has_move = outcome.ok().flatten().is_some();
}; assert_eq!(winnable, has_move);
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] #[test]
fn zero_state_budget_is_inconclusive() { fn zero_state_budget_is_inconclusive() {
let config = SolverConfig { let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
move_budget: 5_000, assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
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] #[test]
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 }; // This seed is Inconclusive at 1k states but Winnable at 5k — proving
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 }; // the budget reaches the solver unchanged.
assert_eq!( let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy), let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
SolverResult::Inconclusive, assert!(easy.is_err());
); assert!(matches!(medium, Ok(Some(_))));
assert_eq!(
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
SolverResult::Winnable,
);
} }
#[test] #[test]
fn budget_above_five_thousand_is_not_clamped() { fn budget_above_five_thousand_is_not_clamped() {
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 }; let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 }; let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
assert_eq!( assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap), assert!(
SolverResult::Inconclusive, matches!(above_cap, Ok(Some(_))),
"seed must be Inconclusive at 5 000 states", "seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
);
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",
); );
} }
} }
+19 -11
View File
@@ -15,7 +15,9 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; 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)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
use solitaire_data::{ use solitaire_data::{
@@ -321,13 +323,13 @@ fn seed_from_system_time() -> u64 {
/// attempts have elapsed. /// attempts have elapsed.
/// ///
/// The solver classifies each deal as one of three verdicts: /// The solver classifies each deal as one of three verdicts:
/// - [`SolverResult::Winnable`] — provably solvable; accept. /// - `Ok(Some(_))` — winnable (provably solvable); accept.
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof /// - `Err(_)` — inconclusive (budget exceeded, no proof either way);
/// either way; accept (we treat "we don't know" as winnable so /// accept (we treat "we don't know" as winnable so the toggle never
/// the toggle never silently drops a player into the retry cap). /// silently drops a player into the retry cap).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed. /// - `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 /// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable /// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop. /// hand than an infinite loop.
@@ -389,12 +391,18 @@ fn poll_pending_new_game_seed(
/// Pure helper extracted for testability — `new_game_with_solver_*` /// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path. /// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 { pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed; let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP { for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode, &cfg) { match try_solve(
SolverResult::Winnable | SolverResult::Inconclusive => return seed, seed,
SolverResult::Unwinnable => { 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); seed = seed.wrapping_add(1);
} }
} }
+1 -1
View File
@@ -1159,7 +1159,7 @@ fn handle_hint_button(
return; return;
} }
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { 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);
} }
} }
} }
+25 -9
View File
@@ -79,13 +79,25 @@ fn dragged_card_z(index: usize) -> f32 {
/// Solver budgets used by the H-key hint system. /// Solver budgets used by the H-key hint system.
/// ///
/// Wraps `solitaire_data::solver::SolverConfig` as a Bevy resource so /// A Bevy resource so tests can inject tighter budgets to exercise the
/// tests can inject tighter budgets to exercise the heuristic-fallback /// heuristic-fallback path. Production initialises this to the same default
/// path. Production initialises this to `SolverConfig::default()` (100k /// 100k move / 200k state budgets the new-game retry loop uses.
/// move / 200k state budgets, the same numbers the new-game retry loop #[derive(Resource, Debug, Clone, Copy)]
/// uses). pub struct HintSolverConfig {
#[derive(Resource, Debug, Clone, Default)] /// Maximum solver moves before giving up (inconclusive).
pub struct HintSolverConfig(pub solitaire_data::solver::SolverConfig); 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. /// Registers keyboard, mouse, and touch input systems.
/// ///
@@ -277,7 +289,7 @@ fn handle_keyboard_core(
/// turns into hint visuals one frame later. /// turns into hint visuals one frame later.
/// ///
/// Median solve time is ~2 ms but pathological positions can hit the /// 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 v0.17.0 behaviour) blocked the main thread on the same frame
/// the player pressed H. Cancel-on-replace lives in /// the player pressed H. Cancel-on-replace lives in
/// `PendingHintTask::spawn` — a fresh H press while a previous task /// `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 }; 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` /// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
+42 -46
View File
@@ -1,12 +1,10 @@
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in //! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`. //! `game_plugin`.
//! //!
//! The synchronous version (v0.17.0) called //! The synchronous version (v0.17.0) called the solver on the main thread
//! `solitaire_core::solver::try_solve_from_state` on the main thread on //! on every H press. Median latency was ~2 ms but pathological positions
//! every H press. Median latency was ~2 ms but pathological positions //! can hit the default solve budget at ~120 ms, which is a noticeable
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a //! input-stall on the same frame the player sees the hint request.
//! noticeable input-stall on the same frame the player sees the hint
//! request.
//! //!
//! This module hosts the resource and polling system that move the //! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint` //! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
@@ -26,9 +24,9 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikePile; use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; 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::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
@@ -60,23 +58,17 @@ impl PendingHintTask {
self.inner = None; self.inner = None;
} }
/// Spawn a new solver task for `state` with `config`. Drops any /// Spawn a new solver task for `state` with the given solve budgets.
/// previously in-flight task first (cancel-on-replace). /// Drops any previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) { pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
let move_count_at_spawn = state.move_count(); let move_count_at_spawn = state.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move { let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config); // Winnable (`Ok(Some)`) carries the first move on a winning path;
match outcome.result { // unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
SolverResult::Winnable => outcome // to the live-state heuristic so H always produces feedback.
.first_move match try_solve_from_state(&state, moves_budget, states_budget) {
.map(|mv| HintTaskOutput::SolverMove { Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
from: mv.source, Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
to: mv.dest,
})
.unwrap_or(HintTaskOutput::NeedsHeuristic),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
HintTaskOutput::NeedsHeuristic
}
} }
}); });
self.inner = Some(HintTask { self.inner = Some(HintTask {
@@ -99,12 +91,10 @@ struct HintTask {
/// What the solver task carries back to the main thread. /// What the solver task carries back to the main thread.
enum HintTaskOutput { enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the /// Solver verdict was winnable; here is the first move on the solution
/// solution path. /// path. Converted to highlighted `(from, to)` piles by the poll system
SolverMove { /// via [`GameState::instruction_to_move`].
from: KlondikePile, SolverMove(KlondikeInstruction),
to: KlondikePile,
},
/// Solver was `Unwinnable` or `Inconclusive`. The poll system /// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the /// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists. /// H key always produces feedback while any legal move exists.
@@ -160,15 +150,21 @@ pub fn poll_pending_hint_task(
return; return;
} }
let (from, to) = match output { // Resolve the solver's first move to highlighted piles; fall back to the
HintTaskOutput::SolverMove { from, to } => (from, to), // live-state heuristic when there's no solver move or it maps to a no-op.
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) { let solver_pair = match output {
Some(pair) => pair, HintTaskOutput::SolverMove(instruction) => g
None => { .0
info_toast.write(InfoToastEvent("No hints available".to_string())); .instruction_to_move(instruction)
return; .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( emit_hint_visuals(
&g.0, &g.0,
@@ -186,7 +182,7 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; 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::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::{DrawMode, game_state::GameState};
@@ -295,10 +291,10 @@ mod tests {
fn winnable_solver_emits_hint_after_async_completes() { fn winnable_solver_emits_hint_after_async_completes() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.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); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -334,10 +330,10 @@ mod tests {
fn state_change_drops_in_flight_task() { fn state_change_drops_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -370,12 +366,12 @@ mod tests {
fn second_spawn_drops_first_in_flight_task() { fn second_spawn_drops_first_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
@@ -384,7 +380,7 @@ mod tests {
// in flight. // in flight.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+15 -7
View File
@@ -24,7 +24,9 @@ use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::DrawMode; 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::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
@@ -83,7 +85,7 @@ struct SeedInputDisplay;
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct PendingVerification { struct PendingVerification {
seed: Option<u64>, seed: Option<u64>,
handle: Option<Task<SolverResult>>, handle: Option<Task<SolveOutcome>>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -340,8 +342,14 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode); .map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default(); let task = AsyncComputeTaskPool::get().spawn(async move {
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) }); try_solve(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
)
});
pending.seed = Some(seed); pending.seed = Some(seed);
pending.handle = Some(task); pending.handle = Some(task);
@@ -369,15 +377,15 @@ fn poll_solver_task(
return; return;
}; };
match result { match result {
SolverResult::Winnable => { Ok(Some(_)) => {
text.0 = "\u{2713} Provably winnable".to_string(); text.0 = "\u{2713} Provably winnable".to_string();
color.0 = ACCENT_PRIMARY; color.0 = ACCENT_PRIMARY;
} }
SolverResult::Inconclusive => { Err(_) => {
text.0 = "? Likely winnable (search timed out)".to_string(); text.0 = "? Likely winnable (search timed out)".to_string();
color.0 = TEXT_SECONDARY; color.0 = TEXT_SECONDARY;
} }
SolverResult::Unwinnable => { Ok(None) => {
text.0 = "\u{2717} Provably unwinnable".to_string(); text.0 = "\u{2717} Provably unwinnable".to_string();
color.0 = TEXT_DISABLED; color.0 = TEXT_DISABLED;
} }