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<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:
@@ -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;
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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)> {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user