refactor: delete solitaire_data::solver wrapper; solve via card_game directly

Remove the standalone solver wrapper module. Its thin shaping — build a
solve-budgeted Session, run card_game::Session::solve(), extract the first
useful move — moves onto the domain type in solitaire_core as
GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget
consts and the SolveOutcome alias re-exported from solitaire_core.

Solving is deterministic, IO-free game logic, so core (which already owns
GameState and exposes session().solve()) is its correct home; solitaire_data is
the persistence/sync layer and never should have owned it.

Consumers now call the core API directly:
- engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin
  (solve_fresh_deal), input_plugin (budget consts)
- assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal)

The solver tests move to solitaire_core. cargo test --workspace and
clippy --workspace --all-targets -- -D warnings both green.

Resolves the "delete the solver" directive — card_game provides the solver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-11 15:04:47 -07:00
parent 424c8b2d50
commit e841a7ab4f
12 changed files with 151 additions and 168 deletions
-6
View File
@@ -99,12 +99,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
}
}
pub mod solver;
pub use solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
try_solve_from_state,
};
pub mod stats;
pub use stats::{StatsExt, StatsSnapshot};
+1 -1
View File
@@ -200,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_data::solver`] cannot prove winnable, retrying
/// the solver cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
-140
View File
@@ -1,140 +0,0 @@
//! Klondike solvability check using upstream `card_game::Session::solve()`.
//!
//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
//! Play-by-seed verdict badge, and the hint system (which wants the first
//! move on a winning path). All search is delegated to `card_game`; this
//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts
//! the first move from the returned solution.
use card_game::{Session, SessionConfig, SolveError};
use klondike::KlondikeInstruction;
use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Default move budget for a solve. Matches the winnable-deal retry loop.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solve.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
/// Outcome of a solvability check:
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
/// winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
/// the game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
/// before a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here.
pub fn try_solve(
seed: u64,
draw_mode: DrawMode,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false;
try_solve_from_state(&game, moves_budget, states_budget)
}
/// Solves from an existing in-progress [`GameState`], returning the first move
/// on a winning path when one exists.
pub fn try_solve_from_state(
state: &GameState,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
// An already-won game has no "next move"; report it as unwinnable so the
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
if state.is_won() {
return Ok(None);
}
let config = SessionConfig {
inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let session = Session::new(state.session().state().state().clone(), config);
session.solve().map(|solution| {
solution.and_then(|solution| {
solution
.raw_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.find(|instruction| !instruction.is_useless())
})
})
}
#[cfg(test)]
mod tests {
use super::*;
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
/// extracted first move (both `Eq`) rather than the whole `Result`.
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn try_solve_is_deterministic() {
let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
assert_eq!(verdict_key(&a), verdict_key(&b));
}
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn try_solve_from_state_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
let outcome = try_solve_from_state(&game, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving
// the budget reaches the solver unchanged.
let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
#[test]
fn budget_above_five_thousand_is_not_clamped() {
let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
assert!(
matches!(above_cap, Ok(Some(_))),
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
);
}
}