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
+19 -11
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
}
}
}
+25 -9
View File
@@ -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`
+42 -46
View File
@@ -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::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.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);
while app.world().resource::<PendingHintTask>().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::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
assert!(
app.world().resource::<PendingHintTask>().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::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn.
app.world_mut()
.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();
assert!(first_handle_present);
@@ -384,7 +380,7 @@ mod tests {
// in flight.
app.world_mut()
.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
// is gone. We can't directly observe the first handle once
// 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::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<u64>,
handle: Option<Task<SolverResult>>,
handle: Option<Task<SolveOutcome>>,
}
// ---------------------------------------------------------------------------
@@ -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;
}