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:
@@ -5,7 +5,7 @@ use crate::klondike_adapter::{
|
||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||
tableau_from_index as adapter_tableau_from_index,
|
||||
};
|
||||
use card_game::{Card, Game as _, Session, SessionConfig};
|
||||
use card_game::{Card, Game as _, Session, SessionConfig, SolveError};
|
||||
use klondike::{
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
|
||||
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
@@ -30,6 +30,22 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
/// still carry those keys load fine — the extra fields are ignored.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
||||
|
||||
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||
/// loop in the engine.
|
||||
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
|
||||
/// Default unique-state budget for a solvability check.
|
||||
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
|
||||
|
||||
/// Outcome of a solvability check ([`GameState::solve_first_move`]):
|
||||
///
|
||||
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first useful 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>;
|
||||
|
||||
/// Default value for `GameState::schema_version` when deserialising older
|
||||
/// save files that pre-date the field.
|
||||
fn schema_v1() -> u32 {
|
||||
@@ -1090,6 +1106,56 @@ impl GameState {
|
||||
pub fn session(&self) -> &Session<Klondike> {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// Solvability of the current position: the first useful move on a winning
|
||||
/// path, `Ok(None)` if unwinnable (or already won), or `Err` if the solver
|
||||
/// hit its budget before reaching a verdict. See [`SolveOutcome`].
|
||||
///
|
||||
/// Delegates the search to upstream [`card_game::Session::solve`] on a
|
||||
/// solve-budgeted copy of the current board, then extracts the first
|
||||
/// non-useless instruction from the returned solution. Backs the hint system
|
||||
/// and the Play-by-seed verdict badge.
|
||||
pub fn solve_first_move(&self, 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 self.is_won() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let config = SessionConfig {
|
||||
inner: KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: moves_budget,
|
||||
solve_states_budget: states_budget,
|
||||
};
|
||||
let session = Session::new(self.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())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
/// take-from-foundation house rule stays disabled. Backs the
|
||||
/// "Winnable deals only" retry loop.
|
||||
pub fn solve_fresh_deal(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
moves_budget: u64,
|
||||
states_budget: u64,
|
||||
) -> SolveOutcome {
|
||||
let mut game = Self::new(seed, draw_mode);
|
||||
game.take_from_foundation = false;
|
||||
game.solve_first_move(moves_budget, states_budget)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1240,4 +1306,66 @@ mod tests {
|
||||
);
|
||||
assert!(game.move_cards(from, to, 1).is_err());
|
||||
}
|
||||
|
||||
// ── Solvability check (solve_first_move / solve_fresh_deal) ──────────────
|
||||
|
||||
/// `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 solve_fresh_deal_is_deterministic() {
|
||||
let a = GameState::solve_fresh_deal(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||
DEFAULT_SOLVE_STATES_BUDGET,
|
||||
);
|
||||
let b = GameState::solve_fresh_deal(
|
||||
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 = GameState::solve_fresh_deal(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 solve_first_move_uses_live_game_state() {
|
||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
||||
game.draw().expect("draw must succeed");
|
||||
|
||||
let outcome = game.solve_first_move(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 = GameState::solve_fresh_deal(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 = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
|
||||
let medium =
|
||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
|
||||
assert!(easy.is_err());
|
||||
assert!(matches!(medium, Ok(Some(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ pub mod klondike_adapter;
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Session};
|
||||
pub use card_game::{Card, Session, SolveError};
|
||||
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||
pub use klondike_adapter::DrawMode;
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptest_tests;
|
||||
|
||||
Reference in New Issue
Block a user