From d4796fa25212ae6b60ea0a01e9e453856db1e584 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 29 May 2026 15:43:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20integrate=20klondike=20v0.3.0=20/?= =?UTF-8?q?=20card=5Fgame=20v0.4.0=20=E2=80=94=20solver=20+=20serde=20newt?= =?UTF-8?q?ypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6: replace 767-line DFS seed-solver with Session::solve(). - try_solve_with_first_move() now delegates to card_game::Session::solve() with solve_moves_budget/solve_states_budget from SolverConfig - Maps Ok(Some) → Winnable, Ok(None) → Unwinnable, Err → Inconclusive - try_solve_from_state() retains the DFS (pile mapping pending, step 2) - Removed dead SolverState::initial() — no longer needed for seed path - Updated tests: session solver returns no Unwinnable in 0..500 range (all non-Winnable deals are Inconclusive); updated engine seed-retry test Step 7: SavedInstruction serde newtypes in klondike_adapter. - SavedInstruction mirrors KlondikeInstruction with Serialize+Deserialize - Sub-types: SavedDstFoundation, SavedDstTableau, SavedKlondikePile, SavedKlondikePileStack, SavedTableauStack, SavedTableau, SavedFoundation, SavedSkipCards — all with serde derives - From for SavedInstruction (infallible) - TryFrom for KlondikeInstruction (InvalidSavedInstruction on out-of-range u8 values) - InvalidSavedInstruction error type via thiserror Also: chore(deps): bump klondike to v0.3.0, card_game to v0.4.0 (Cargo.toml/lock) All 1399 tests pass; clippy clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 8 +- Cargo.toml | 4 +- solitaire_core/src/klondike_adapter.rs | 278 ++++++++++++++++++++++++- solitaire_core/src/solver.rs | 137 ++++++++---- solitaire_engine/src/game_plugin.rs | 20 +- 5 files changed, 390 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 150cb24..040a1db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1887,9 +1887,9 @@ dependencies = [ [[package]] name = "card_game" -version = "0.3.0" +version = "0.4.0" source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" -checksum = "38b68e4fb32f8a1f92edf8488c012f6d8af71491a2f9f8a855362d7eaf1a2d0c" +checksum = "d206df6d87340019a0f5b621976cf98bc75c659a7f93ef348aaab2a9336098a9" dependencies = [ "arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)", ] @@ -4354,9 +4354,9 @@ dependencies = [ [[package]] name = "klondike" -version = "0.2.0" +version = "0.3.0" source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" -checksum = "0bce541f9b14e9d9d8c9b17d5df40bd0a017709b61d9be8ad5bab7b19a1a0152" +checksum = "347d55e6cf7c90b3d038262071eb2fdb0b75a713fe66c452a3400ff08fb716bc" dependencies = [ "card_game", "rand 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index fcb965e..5b6f6c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,8 @@ solitaire_core = { path = "solitaire_core" } solitaire_sync = { path = "solitaire_sync" } solitaire_data = { path = "solitaire_data" } solitaire_engine = { path = "solitaire_engine" } -klondike = { version = "0.2.0", registry = "Quaternions" } -card_game = { version = "0.3.0", registry = "Quaternions" } +klondike = { version = "0.3.0", registry = "Quaternions" } +card_game = { version = "0.4.0", registry = "Quaternions" } # Bevy with `default-features = false` to avoid the unused # `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index 901758f..e5792a7 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -11,10 +11,15 @@ //! //! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2). //! - Move validation via klondike's rule engine (step 2). -//! - DFS solver via [`klondike::KlondikeState`] (step 6). +//! - DFS solver via [`klondike::KlondikeState`] (step 6, now delegated to upstream). use card_game::{Card as KlCard, Deck as KlDeck, Rank as KlRank, Suit as KlSuit}; -use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig}; +use klondike::{ + DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction, + KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau, + TableauStack, +}; +use serde::{Deserialize, Serialize}; use crate::game_state::{DrawMode, GameMode}; use crate::pile::PileType; @@ -244,3 +249,272 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card { let id = suit_index * 13 + (rank.value() as u32 - 1); crate::card::Card { id, suit, rank, face_up: false } } + +// ── Serde newtypes for KlondikeInstruction (Step 7) ────────────────────────── +// +// `klondike::KlondikeInstruction` (and its sub-types) do not derive +// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that +// the session instruction history can be persisted and reconstructed without +// upstream changes. +// +// Conversion: `From for SavedInstruction` and the +// fallible inverse `TryFrom for KlondikeInstruction`. +// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield +// `InvalidSavedInstruction`. + +/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedTableau(pub u8); + +/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedFoundation(pub u8); + +/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedSkipCards(pub u8); + +/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavedKlondikePile { + Tableau(SavedTableau), + Stock, + Foundation(SavedFoundation), +} + +/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedTableauStack { + pub tableau: SavedTableau, + pub skip_cards: SavedSkipCards, +} + +/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavedKlondikePileStack { + Tableau(SavedTableauStack), + Stock, + Foundation(SavedFoundation), +} + +/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedDstFoundation { + pub src: SavedKlondikePile, + pub foundation: SavedFoundation, +} + +/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedDstTableau { + pub src: SavedKlondikePileStack, + pub tableau: SavedTableau, +} + +/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`]. +/// +/// Convert to/from the upstream type with: +/// ```ignore +/// let saved = SavedInstruction::from(instruction); +/// let instruction = KlondikeInstruction::try_from(saved)?; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavedInstruction { + DstFoundation(SavedDstFoundation), + DstTableau(SavedDstTableau), + RotateStock, +} + +/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value +/// and cannot be converted back to a [`klondike::KlondikeInstruction`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum InvalidSavedInstruction { + #[error("invalid tableau index {0} (expected 0–6)")] + Tableau(u8), + #[error("invalid foundation index {0} (expected 0–3)")] + Foundation(u8), + #[error("invalid skip_cards value {0} (expected 0–12)")] + SkipCards(u8), +} + +// ── From impls: KlondikeInstruction → Saved* ───────────────────────────────── + +impl From for SavedTableau { + fn from(t: Tableau) -> Self { + Self(t as u8) + } +} + +impl From for SavedFoundation { + fn from(f: Foundation) -> Self { + Self(f as u8) + } +} + +impl From for SavedSkipCards { + fn from(s: SkipCards) -> Self { + Self(s as u8) + } +} + +impl From for SavedKlondikePile { + fn from(p: KlondikePile) -> Self { + match p { + KlondikePile::Tableau(t) => Self::Tableau(t.into()), + KlondikePile::Stock => Self::Stock, + KlondikePile::Foundation(f) => Self::Foundation(f.into()), + } + } +} + +impl From for SavedTableauStack { + fn from(ts: TableauStack) -> Self { + Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() } + } +} + +impl From for SavedKlondikePileStack { + fn from(ps: KlondikePileStack) -> Self { + match ps { + KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()), + KlondikePileStack::Stock => Self::Stock, + KlondikePileStack::Foundation(f) => Self::Foundation(f.into()), + } + } +} + +impl From for SavedDstFoundation { + fn from(df: DstFoundation) -> Self { + Self { src: df.src.into(), foundation: df.foundation.into() } + } +} + +impl From for SavedDstTableau { + fn from(dt: DstTableau) -> Self { + Self { src: dt.src.into(), tableau: dt.tableau.into() } + } +} + +impl From for SavedInstruction { + fn from(i: KlondikeInstruction) -> Self { + match i { + KlondikeInstruction::RotateStock => Self::RotateStock, + KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()), + KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()), + } + } +} + +// ── TryFrom impls: Saved* → KlondikeInstruction ────────────────────────────── + +impl TryFrom for Tableau { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedTableau) -> Result { + match s.0 { + 0 => Ok(Tableau::Tableau1), + 1 => Ok(Tableau::Tableau2), + 2 => Ok(Tableau::Tableau3), + 3 => Ok(Tableau::Tableau4), + 4 => Ok(Tableau::Tableau5), + 5 => Ok(Tableau::Tableau6), + 6 => Ok(Tableau::Tableau7), + n => Err(InvalidSavedInstruction::Tableau(n)), + } + } +} + +impl TryFrom for Foundation { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedFoundation) -> Result { + match s.0 { + 0 => Ok(Foundation::Foundation1), + 1 => Ok(Foundation::Foundation2), + 2 => Ok(Foundation::Foundation3), + 3 => Ok(Foundation::Foundation4), + n => Err(InvalidSavedInstruction::Foundation(n)), + } + } +} + +impl TryFrom for SkipCards { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedSkipCards) -> Result { + match s.0 { + 0 => Ok(SkipCards::Skip0), + 1 => Ok(SkipCards::Skip1), + 2 => Ok(SkipCards::Skip2), + 3 => Ok(SkipCards::Skip3), + 4 => Ok(SkipCards::Skip4), + 5 => Ok(SkipCards::Skip5), + 6 => Ok(SkipCards::Skip6), + 7 => Ok(SkipCards::Skip7), + 8 => Ok(SkipCards::Skip8), + 9 => Ok(SkipCards::Skip9), + 10 => Ok(SkipCards::Skip10), + 11 => Ok(SkipCards::Skip11), + 12 => Ok(SkipCards::Skip12), + n => Err(InvalidSavedInstruction::SkipCards(n)), + } + } +} + +impl TryFrom for KlondikePile { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedKlondikePile) -> Result { + Ok(match s { + SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?), + SavedKlondikePile::Stock => KlondikePile::Stock, + SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?), + }) + } +} + +impl TryFrom for TableauStack { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedTableauStack) -> Result { + Ok(TableauStack { + tableau: s.tableau.try_into()?, + skip_cards: s.skip_cards.try_into()?, + }) + } +} + +impl TryFrom for KlondikePileStack { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedKlondikePileStack) -> Result { + Ok(match s { + SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?), + SavedKlondikePileStack::Stock => KlondikePileStack::Stock, + SavedKlondikePileStack::Foundation(f) => { + KlondikePileStack::Foundation(f.try_into()?) + } + }) + } +} + +impl TryFrom for DstFoundation { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedDstFoundation) -> Result { + Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? }) + } +} + +impl TryFrom for DstTableau { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedDstTableau) -> Result { + Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? }) + } +} + +impl TryFrom for KlondikeInstruction { + type Error = InvalidSavedInstruction; + fn try_from(s: SavedInstruction) -> Result { + Ok(match s { + SavedInstruction::RotateStock => KlondikeInstruction::RotateStock, + SavedInstruction::DstFoundation(df) => { + KlondikeInstruction::DstFoundation(df.try_into()?) + } + SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?), + }) + } +} diff --git a/solitaire_core/src/solver.rs b/solitaire_core/src/solver.rs index 5cd0e24..97630ef 100644 --- a/solitaire_core/src/solver.rs +++ b/solitaire_core/src/solver.rs @@ -63,9 +63,12 @@ use std::collections::HashSet; use std::hash::{Hash, Hasher}; +use card_game::{Session, SessionConfig}; +use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack, Tableau}; + use crate::card::{Card, Suit}; -use crate::deck::{Deck, deal_klondike}; use crate::game_state::{DrawMode, GameState}; +use crate::klondike_adapter::KlondikeAdapter; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; @@ -174,13 +177,77 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve /// Used by the engine hint system to promote H-key suggestions from a /// heuristic to the provably-optimal first move; the hint system falls /// back to its heuristic when this returns `Inconclusive`. +/// +/// Delegates to `card_game::Session::solve()` using the upstream `klondike` +/// solver. Budgets from `config` are forwarded directly. pub fn try_solve_with_first_move( seed: u64, draw_mode: DrawMode, config: &SolverConfig, ) -> SolveOutcome { - let state = SolverState::initial(seed, draw_mode); - state.solve(config) + let klondike = Klondike::with_seed(seed); + let adapter = KlondikeAdapter::new(draw_mode, false); + let session_config = SessionConfig { + inner: adapter.klondike_config().clone(), + undo_penalty: 0, + solve_moves_budget: config.move_budget, + solve_states_budget: config.state_budget as u64, + }; + let session = Session::new(klondike, session_config); + match session.solve() { + Ok(Some(solution)) => { + let first_move = solution + .raw_solution() + .first() + .map(|snap| klondike_instruction_to_solver_move(snap.instruction())); + SolveOutcome { result: SolverResult::Winnable, first_move } + } + Ok(None) => SolveOutcome { result: SolverResult::Unwinnable, first_move: None }, + Err(_) => SolveOutcome { result: SolverResult::Inconclusive, first_move: None }, + } +} + +fn tableau_index(t: Tableau) -> usize { + t as usize +} + +fn foundation_index(f: Foundation) -> u8 { + f as u8 +} + +fn klondike_pile_to_pile_type(pile: KlondikePile) -> PileType { + match pile { + KlondikePile::Tableau(t) => PileType::Tableau(tableau_index(t)), + KlondikePile::Stock => PileType::Waste, + KlondikePile::Foundation(f) => PileType::Foundation(foundation_index(f)), + } +} + +fn klondike_instruction_to_solver_move(instr: &KlondikeInstruction) -> SolverMove { + match *instr { + KlondikeInstruction::RotateStock => SolverMove { + source: PileType::Stock, + dest: PileType::Waste, + count: 1, + }, + KlondikeInstruction::DstFoundation(df) => SolverMove { + source: klondike_pile_to_pile_type(df.src), + dest: PileType::Foundation(foundation_index(df.foundation)), + count: 1, + }, + KlondikeInstruction::DstTableau(dt) => { + let source = match dt.src { + KlondikePileStack::Tableau(ts) => PileType::Tableau(tableau_index(ts.tableau)), + KlondikePileStack::Stock => PileType::Waste, + KlondikePileStack::Foundation(f) => PileType::Foundation(foundation_index(f)), + }; + SolverMove { + source, + dest: PileType::Tableau(tableau_index(dt.tableau)), + count: 1, + } + } + } } /// Tries to solve from an existing in-progress [`GameState`]. @@ -285,23 +352,6 @@ struct SolverState { } impl SolverState { - fn initial(seed: u64, draw_mode: DrawMode) -> Self { - let mut deck = Deck::new(); - deck.shuffle(seed); - let (tableau_piles, stock_pile) = deal_klondike(deck); - let tableau: [Vec; 7] = tableau_piles.map(|p| p.cards); - let foundation: [Vec; 4] = core::array::from_fn(|_| Vec::new()); - Self { - tableau, - foundation, - stock: stock_pile.cards, - waste: Vec::new(), - draw_mode, - just_drew: false, - consecutive_draws: 0, - } - } - /// True when every foundation slot holds a complete Ace-through-King sequence. fn is_won(&self) -> bool { self.foundation.iter().all(|pile| { @@ -1112,10 +1162,10 @@ mod tests { assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0)); } - /// Scan a wide seed window to find one Winnable + one Unwinnable - /// seed under tight budgets. Used during development to source the - /// fixture seeds for the engine-level retry test. - /// Run with: + /// Scan a wide seed window to find Winnable + Unwinnable seeds under the + /// upstream session solver. With `card_game v0.4.0` the session solver + /// returns Winnable or Inconclusive for all seeds 0..500; no seed in that + /// range is proven Unwinnable. Run for diagnostics with: /// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`. #[test] #[ignore] @@ -1359,25 +1409,32 @@ mod tests { } #[test] - fn try_solve_with_first_move_seed_form_matches_state_form() { - // For a fresh seed, the two public entry points must agree — - // they share the same internal `solve()` implementation, but - // route through different state constructors. This is the - // smoke test that catches drift between them. + fn try_solve_with_first_move_uses_session_solver() { + // `try_solve_with_first_move` now delegates to `Session::solve()` from + // the upstream `klondike` crate. `try_solve_from_state` still uses the + // internal DFS (needed for mid-game positions until pile mapping lands). + // They may disagree on borderline seeds with tight budgets; the only + // contract is that each returns a valid verdict and, when Winnable, a + // Some(first_move). let cfg = SolverConfig { move_budget: 5_000, state_budget: 5_000, }; - let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg); - let game = GameState::new(7, DrawMode::DrawOne); - let b = try_solve_from_state(&game, &cfg); - assert_eq!( - a.result, b.result, - "verdicts must match across the two entry points" - ); - assert_eq!( - a.first_move, b.first_move, - "first_move must match across the two entry points" - ); + let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg); + // Verdict must be one of the three valid variants — no panic allowed. + match outcome.result { + SolverResult::Winnable => { + assert!( + outcome.first_move.is_some(), + "Winnable verdict must carry a first_move" + ); + } + SolverResult::Unwinnable | SolverResult::Inconclusive => { + assert!( + outcome.first_move.is_none(), + "non-Winnable verdict must carry first_move == None" + ); + } + } } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index c764183..067af8f 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -2858,17 +2858,19 @@ mod tests { } #[test] - fn choose_winnable_seed_skips_unwinnable_seed() { - // Seed 394 was identified by the offline scan - // (`solver::tests::find_unwinnable`) as the only Unwinnable - // seed in 0..500 under the default solver budget. Seed 395 - // resolves as Inconclusive — the engine treats Inconclusive - // as winnable (see `choose_winnable_seed` doc), so the - // helper must return 395 when started at 394. + fn choose_winnable_seed_accepts_inconclusive_seed() { + // With the upstream session solver (card_game v0.4.0) no seeds in 0..500 + // are proven Unwinnable — they are either Winnable or Inconclusive. + // `choose_winnable_seed` must accept Inconclusive as "probably winnable", + // so calling it with any seed in this range must return quickly (at most + // the retry cap) rather than looping forever. + // + // Seed 394 was previously Unwinnable under the old DFS; now it resolves + // as Inconclusive, so the helper must accept it immediately. let chosen = choose_winnable_seed(394, DrawMode::DrawOne); assert_eq!( - chosen, 395, - "seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted" + chosen, 394, + "seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is" ); }