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