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:
funman300
2026-06-09 09:38:04 -07:00
parent 37a21b9b42
commit 920f2c8597
40 changed files with 105 additions and 210 deletions
+3 -18
View File
@@ -1,7 +1,8 @@
use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
@@ -33,15 +34,6 @@ fn schema_v1() -> u32 {
1
}
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
@@ -185,8 +177,6 @@ pub struct GameState {
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column.
pub take_from_foundation: bool,
/// Save-file schema version.
pub schema_version: u32,
pub(crate) session: Session<Klondike>,
/// Score recorded immediately before each instruction was applied.
/// Parallel to `session.history()` during live play; used by `undo()` to
@@ -215,7 +205,6 @@ impl PartialEq for GameState {
&& self.undo_count == other.undo_count
&& self.recycle_count == other.recycle_count
&& self.take_from_foundation == other.take_from_foundation
&& self.schema_version == other.schema_version
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8)
@@ -243,7 +232,7 @@ impl Serialize for GameState {
undo_count: self.undo_count,
recycle_count: self.recycle_count,
take_from_foundation: self.take_from_foundation,
schema_version: self.schema_version,
schema_version: GAME_STATE_SCHEMA_VERSION,
saved_moves: self.saved_moves(),
}
.serialize(serializer)
@@ -279,9 +268,6 @@ impl<'de> Deserialize<'de> for GameState {
// due to the pre-Phase-3 undo drift bug.
recycle_count: 0,
take_from_foundation: persisted.take_from_foundation,
// Always stamp the current schema version after a successful load so
// storage.rs schema checks pass and re-saving writes the v4 format.
schema_version: GAME_STATE_SCHEMA_VERSION,
session: Self::new_session(persisted.seed, persisted.draw_mode),
// score_history cannot be faithfully rebuilt from the instruction
// history because live-play undo penalties are not recorded in
@@ -358,7 +344,6 @@ impl GameState {
undo_count: 0,
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
session: Self::new_session(seed, draw_mode),
score_history: Vec::new(),
is_recycle_history: Vec::new(),
+10 -1
View File
@@ -18,7 +18,16 @@ use klondike::{
use serde::{Deserialize, Serialize};
use crate::card;
use crate::game_state::{DrawMode, GameMode};
use crate::game_state::GameMode;
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
+1 -2
View File
@@ -3,8 +3,6 @@ pub mod card;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod solver;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
@@ -15,6 +13,7 @@ pub mod solver;
// not appear in any public method signature.
pub use card_game::Session;
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
#[cfg(test)]
mod proptest_tests;
-90
View File
@@ -1,90 +0,0 @@
use crate::card::{Card, Suit};
use klondike::KlondikePile;
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
///
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
/// sync system may populate `cards`; no game logic should mutate this struct directly.
/// [`GameState`] is always the authoritative source of truth.
///
/// [`GameState`]: crate::game_state::GameState
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pile {
/// Which logical Klondike pile this is.
pub pile_type: KlondikePile,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
/// Populated by the sync system; do not mutate from game-logic code.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: KlondikePile) -> Self {
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.top().is_none());
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+2 -1
View File
@@ -2,7 +2,8 @@ use card_game::Game;
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use proptest::prelude::*;
use crate::game_state::{DrawMode, GameState};
use crate::game_state::GameState;
use crate::klondike_adapter::DrawMode;
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
-282
View File
@@ -1,282 +0,0 @@
//! 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 crate::game_state::{DrawMode, GameState};
use crate::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() {
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
// Differing results confirm solve_game_state passes the caller's
// state_budget unchanged to the underlying solver.
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() {
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
// states but Winnable at 50 000. Before this fix, solve_game_state
// applied `config.state_budget.min(5_000)` internally, so a 50k config
// was silently reduced to 5k — making both calls return Inconclusive and
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
// This assertion fails if the cap is re-introduced.
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",
);
}
}