feat(core): integrate klondike v0.3.0 / card_game v0.4.0 — solver + serde newtypes
Build and Deploy / build-and-push (push) Failing after 29s

Step 6: replace 767-line DFS seed-solver with Session<Klondike>::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<KlondikeInstruction> for SavedInstruction (infallible)
- TryFrom<SavedInstruction> 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>
This commit is contained in:
funman300
2026-05-29 15:43:32 -07:00
parent 57c4b5aacf
commit d4796fa252
5 changed files with 390 additions and 57 deletions
+97 -40
View File
@@ -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<Card>; 7] = tableau_piles.map(|p| p.cards);
let foundation: [Vec<Card>; 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"
);
}
}
}
}