refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root) - Delete solitaire_core::pile — no external users - Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode - Remove schema_version field from GameState (redundant — deserializer stamps it from the constant) - Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_sync = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -99,6 +99,12 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod solver;
|
||||
pub use solver::{
|
||||
SolveOutcome, SolverConfig, SolverMove, SolverResult, try_solve, try_solve_from_state,
|
||||
try_solve_with_first_move,
|
||||
};
|
||||
|
||||
pub mod stats;
|
||||
pub use stats::{StatsExt, StatsSnapshot};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
|
||||
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@@ -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_core::solver`] cannot prove winnable, retrying
|
||||
/// [`solitaire_data::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
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
|
||||
//!
|
||||
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
|
||||
//! toggle and by the hint system when it wants the first move on a winning path.
|
||||
|
||||
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
|
||||
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
|
||||
use solitaire_core::DrawMode;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::klondike_adapter::KlondikeAdapter;
|
||||
|
||||
/// Verdict returned by [`try_solve`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SolverResult {
|
||||
/// The solver found a sequence of moves that wins the deal.
|
||||
Winnable,
|
||||
/// The solver exhaustively searched and confirmed no win exists.
|
||||
Unwinnable,
|
||||
/// The move / state budget was exceeded before a verdict could be reached.
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Tunable budgets controlling how long [`try_solve`] is willing to search.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SolverConfig {
|
||||
/// Maximum total moves to consider across the entire search tree.
|
||||
pub move_budget: u64,
|
||||
/// Maximum unique states to visit.
|
||||
pub state_budget: usize,
|
||||
}
|
||||
|
||||
impl Default for SolverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
move_budget: 100_000,
|
||||
state_budget: 200_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single move the solver can recommend, expressed in engine-level pile terms.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SolverMove {
|
||||
/// Pile the move originates from.
|
||||
pub source: KlondikePile,
|
||||
/// Pile the move lands on.
|
||||
pub dest: KlondikePile,
|
||||
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Solver verdict plus, when winnable, the first move on a winning path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SolveOutcome {
|
||||
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
|
||||
pub result: SolverResult,
|
||||
/// First move on the solution path when `result == Winnable`.
|
||||
pub first_move: Option<SolverMove>,
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||
try_solve_with_first_move(seed, draw_mode, config).result
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game and, when winnable, returns the
|
||||
/// first move on a winning path.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
/// take-from-foundation house rule stays disabled here.
|
||||
pub fn try_solve_with_first_move(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
config: &SolverConfig,
|
||||
) -> SolveOutcome {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
game.take_from_foundation = false;
|
||||
solve_game_state(&game, config)
|
||||
}
|
||||
|
||||
/// Tries to solve from an existing in-progress [`GameState`].
|
||||
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||
solve_game_state(state, config)
|
||||
}
|
||||
|
||||
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||
if config.state_budget == 0 {
|
||||
return SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Preserve the historical payload contract: winnable verdicts always carry
|
||||
// a first move. An already-won state therefore returns no recommendation.
|
||||
if initial.is_won {
|
||||
return SolveOutcome {
|
||||
result: SolverResult::Unwinnable,
|
||||
first_move: None,
|
||||
};
|
||||
}
|
||||
|
||||
let solver_config = SessionConfig {
|
||||
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: config.move_budget,
|
||||
solve_states_budget: config.state_budget as u64,
|
||||
};
|
||||
let solver_session = Session::new(initial.session().state().state().clone(), solver_config);
|
||||
|
||||
match solver_session.solve() {
|
||||
Ok(Some(solution)) => {
|
||||
let first_move = solution
|
||||
.raw_solution()
|
||||
.iter()
|
||||
.find_map(snapshot_to_solver_move);
|
||||
if let Some(first_move) = first_move {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Winnable,
|
||||
first_move: Some(first_move),
|
||||
}
|
||||
} else {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => SolveOutcome {
|
||||
result: SolverResult::Unwinnable,
|
||||
first_move: None,
|
||||
},
|
||||
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
||||
let source_state = snapshot.state().state();
|
||||
match *snapshot.instruction() {
|
||||
KlondikeInstruction::RotateStock => Some(SolverMove {
|
||||
source: KlondikePile::Stock,
|
||||
dest: KlondikePile::Stock,
|
||||
count: 1,
|
||||
}),
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
let source = match dst_foundation.src {
|
||||
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
|
||||
KlondikePile::Stock => KlondikePile::Stock,
|
||||
KlondikePile::Foundation(_) => return None,
|
||||
};
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Foundation(dst_foundation.foundation),
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||
let (source, count) = match dst_tableau.src {
|
||||
KlondikePileStack::Tableau(tableau_stack) => {
|
||||
let face_up_count =
|
||||
source_state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||||
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
(KlondikePile::Tableau(tableau_stack.tableau), count)
|
||||
}
|
||||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||||
KlondikePileStack::Foundation(foundation) => {
|
||||
(KlondikePile::Foundation(foundation), 1)
|
||||
}
|
||||
};
|
||||
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
||||
count,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn try_solve_with_first_move_is_deterministic() {
|
||||
let config = SolverConfig::default();
|
||||
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
let b = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
let c = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(b, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_with_first_move_returns_consistent_payload() {
|
||||
let config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
assert!(outcome.first_move.is_none())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let outcome = try_solve_from_state(&game, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
assert!(outcome.first_move.is_none())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_budget_is_inconclusive() {
|
||||
let config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 0,
|
||||
};
|
||||
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
assert_eq!(outcome.result, SolverResult::Inconclusive);
|
||||
assert!(outcome.first_move.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
|
||||
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy),
|
||||
SolverResult::Inconclusive,
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
|
||||
SolverResult::Winnable,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_above_five_thousand_is_not_clamped() {
|
||||
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap),
|
||||
SolverResult::Inconclusive,
|
||||
"seed must be Inconclusive at 5 000 states",
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &above_cap),
|
||||
SolverResult::Winnable,
|
||||
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
||||
|
||||
pub use solitaire_sync::StatsSnapshot;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
@@ -85,9 +85,6 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won { None } else { Some(gs) }
|
||||
}
|
||||
|
||||
@@ -282,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::{StatsExt, StatsSnapshot};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawMode;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -380,7 +377,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn game_state_round_trip() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
@@ -409,7 +406,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_skips_won_games() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("won_skip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
@@ -424,7 +421,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delete_game_state_removes_file() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("delete");
|
||||
let gs = GameState::new(1, DrawMode::DrawOne);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
@@ -442,7 +439,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_is_atomic() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("atomic");
|
||||
let gs = GameState::new(55, DrawMode::DrawThree);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
@@ -510,7 +507,7 @@ mod tests {
|
||||
#[test]
|
||||
fn game_state_v4_mid_game_round_trip() {
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let path = gs_path("v4_mid_game");
|
||||
let _ = fs::remove_file(&path);
|
||||
@@ -557,7 +554,6 @@ mod tests {
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("a valid in-progress game must load without error");
|
||||
|
||||
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
loaded, gs,
|
||||
"all pile layouts and counters must be identical after schema-v4 round-trip",
|
||||
@@ -574,7 +570,7 @@ mod tests {
|
||||
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
|
||||
#[test]
|
||||
fn game_state_v3_migrates_to_v4() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let path = gs_path("v3_migrate");
|
||||
let _ = fs::remove_file(&path);
|
||||
@@ -599,12 +595,6 @@ mod tests {
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("schema v3 must be accepted and migrated to v4");
|
||||
|
||||
// After migration, the in-memory schema version must be current.
|
||||
assert_eq!(
|
||||
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
|
||||
"migrated game must report current schema version",
|
||||
);
|
||||
|
||||
// The loaded game should match a fresh game that had one draw applied.
|
||||
let mut expected = GameState::new(42, DrawMode::DrawOne);
|
||||
expected.draw().expect("draw must succeed on a fresh game");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawMode;
|
||||
|
||||
/// XP awarded each time a weekly goal is just completed.
|
||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||
|
||||
Reference in New Issue
Block a user