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
+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