refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s

- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 13:13:35 -07:00
parent ca612f51f1
commit 9260ca7994
36 changed files with 7429 additions and 7064 deletions
+213 -124
View File
@@ -1,14 +1,14 @@
//! Klondike solvability checker backed by the upstream `card_game` session solver.
//! Klondike solvability checker using deterministic DFS over [`GameState`].
//!
//! 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};
use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau};
use std::collections::HashSet;
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::KlondikeAdapter;
use crate::pile::PileType;
use klondike::{Foundation, KlondikePile, Tableau};
use crate::card::Card;
use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState};
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -43,9 +43,9 @@ impl Default for SolverConfig {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolverMove {
/// Pile the move originates from.
pub source: PileType,
pub source: KlondikePile,
/// Pile the move lands on.
pub dest: PileType,
pub dest: KlondikePile,
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
pub count: usize,
}
@@ -59,6 +59,14 @@ pub struct SolveOutcome {
pub first_move: Option<SolverMove>,
}
#[derive(Debug, Clone)]
struct DfsFrame {
state: GameState,
moves: Vec<SolverMove>,
next_index: usize,
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
@@ -74,136 +82,206 @@ pub fn try_solve_with_first_move(
draw_mode: DrawMode,
config: &SolverConfig,
) -> SolveOutcome {
let session = Session::new(
Klondike::with_seed(seed),
session_config(draw_mode, false, config),
);
solve_session(session)
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`].
///
/// The live `Session<Klondike>` inside `GameState` is cloned, then wrapped in a
/// fresh solver config so the search uses the current house-rule setting and the
/// caller's budgets without mutating gameplay state.
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
let session = Session::new(
state.session().state().state().clone(),
session_config(state.draw_mode, state.take_from_foundation, config),
);
solve_session(session)
solve_game_state(state, config)
}
fn session_config(
draw_mode: DrawMode,
take_from_foundation: bool,
config: &SolverConfig,
) -> SessionConfig<klondike::KlondikeConfig> {
SessionConfig {
inner: KlondikeAdapter::new(draw_mode, take_from_foundation)
.klondike_config()
.clone(),
undo_penalty: 0,
solve_moves_budget: config.move_budget,
solve_states_budget: config.state_budget as u64,
}
}
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
// Keep solver latency bounded even when callers pass very large budgets.
// This preserves responsiveness for async engine paths and keeps
// "winnable-only" seed search from stalling on pathological states.
let effective_state_budget = config.state_budget.min(5_000);
let effective_move_budget = config.move_budget.min(5_000);
fn solve_session(session: Session<Klondike>) -> SolveOutcome {
match session.solve() {
Ok(Some(solution)) => {
let mut cleaned = solution.clean_solution();
let first_move = cleaned
.drain(..)
.next()
.and_then(|snapshot| klondike_instruction_to_solver_move(snapshot.state(), snapshot.instruction()));
if first_move.is_some() {
SolveOutcome {
result: SolverResult::Winnable,
first_move,
}
} else {
SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
}
}
}
Ok(None) => SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
},
Err(_) => SolveOutcome {
if effective_state_budget == 0 {
return SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
},
};
}
}
fn tableau_index(tableau: Tableau) -> usize {
tableau as usize
}
fn foundation_index(foundation: Foundation) -> u8 {
foundation as u8
}
fn skip_cards_count(skip_cards: SkipCards) -> usize {
skip_cards as usize
}
fn pile_from_kl(pile: KlondikePile) -> PileType {
match pile {
KlondikePile::Tableau(tableau) => PileType::Tableau(tableau_index(tableau)),
KlondikePile::Stock => PileType::Waste,
KlondikePile::Foundation(foundation) => PileType::Foundation(foundation_index(foundation)),
// 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,
};
}
}
fn klondike_instruction_to_solver_move(
state: &Klondike,
instruction: &KlondikeInstruction,
) -> Option<SolverMove> {
match *instruction {
KlondikeInstruction::RotateStock => Some(SolverMove {
source: PileType::Stock,
dest: PileType::Waste,
count: 1,
}),
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
let mut visited: HashSet<Vec<u32>> = HashSet::with_capacity(effective_state_budget.min(16_384));
visited.insert(state_key(initial));
let mut states_visited: usize = 1;
let mut moves_considered: u64 = 0;
let mut saw_inconclusive = false;
let mut stack = vec![DfsFrame {
state: initial.clone(),
moves: candidate_moves(initial),
next_index: 0,
first_move: None,
}];
while let Some(frame) = stack.last_mut() {
if frame.state.is_won {
if let Some(first_move) = frame.first_move.clone() {
return SolveOutcome {
result: SolverResult::Winnable,
first_move: Some(first_move),
};
}
Some(SolverMove {
source: pile_from_kl(dst_foundation.src),
dest: PileType::Foundation(foundation_index(dst_foundation.foundation)),
count: 1,
})
stack.pop();
continue;
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = state
.state()
.tableau_face_up_cards(tableau_stack.tableau)
.len();
let count = face_up_count.checked_sub(skip_cards_count(tableau_stack.skip_cards))?;
if count == 0 {
return None;
}
(PileType::Tableau(tableau_index(tableau_stack.tableau)), count)
}
KlondikePileStack::Stock => (PileType::Waste, 1),
KlondikePileStack::Foundation(foundation) => {
(PileType::Foundation(foundation_index(foundation)), 1)
}
};
Some(SolverMove {
source,
dest: PileType::Tableau(tableau_index(dst_tableau.tableau)),
count,
})
if frame.next_index >= frame.moves.len() {
stack.pop();
continue;
}
if moves_considered >= effective_move_budget {
saw_inconclusive = true;
break;
}
let next_move = frame.moves[frame.next_index].clone();
frame.next_index += 1;
moves_considered = moves_considered.saturating_add(1);
let Some(next_state) = apply_solver_move(&frame.state, &next_move) else {
continue;
};
let key = state_key(&next_state);
if visited.contains(&key) {
continue;
}
if states_visited >= effective_state_budget {
saw_inconclusive = true;
continue;
}
visited.insert(key);
states_visited = states_visited.saturating_add(1);
let first_move = frame
.first_move
.clone()
.or_else(|| Some(next_move.clone()));
let child_moves = candidate_moves(&next_state);
stack.push(DfsFrame {
state: next_state,
moves: child_moves,
next_index: 0,
first_move,
});
}
if saw_inconclusive {
SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
}
} else {
SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
}
}
}
fn candidate_moves(game: &GameState) -> Vec<SolverMove> {
let mut out: Vec<SolverMove> = game
.possible_instructions()
.into_iter()
.map(|(source, dest, count)| SolverMove {
source,
dest,
count,
})
.collect();
if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() {
out.push(SolverMove {
source: KlondikePile::Stock,
dest: KlondikePile::Stock,
count: 1,
});
}
out
}
fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option<GameState> {
let mut next = game.clone();
if mv.source == KlondikePile::Stock && mv.dest == KlondikePile::Stock {
next.draw().ok()?;
} else {
next.move_cards(mv.source, mv.dest, mv.count).ok()?;
}
Some(next)
}
fn state_key(game: &GameState) -> Vec<u32> {
let mut key = Vec::with_capacity(96);
append_pile_key(&game.stock_cards(), &mut key);
append_pile_key(&game.waste_cards(), &mut key);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
append_pile_key(&game.pile(KlondikePile::Foundation(foundation)), &mut key);
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
append_pile_key(&game.pile(KlondikePile::Tableau(tableau)), &mut key);
}
key.push(game.draw_mode as u32);
key.push(mode_key(game.mode));
key.push(u32::from(game.take_from_foundation));
key
}
fn append_pile_key(cards: &[Card], key: &mut Vec<u32>) {
key.push(cards.len() as u32);
for card in cards {
key.push((card.id << 1) | u32::from(card.face_up));
}
}
fn mode_key(mode: GameMode) -> u32 {
match mode {
GameMode::Classic => 0,
GameMode::Zen => 1,
GameMode::Challenge => 2,
GameMode::TimeAttack => 3,
GameMode::Difficulty(level) => match level {
DifficultyLevel::Easy => 10,
DifficultyLevel::Medium => 11,
DifficultyLevel::Hard => 12,
DifficultyLevel::Expert => 13,
DifficultyLevel::Grandmaster => 14,
DifficultyLevel::Random => 15,
},
}
}
@@ -237,7 +315,7 @@ mod tests {
}
#[test]
fn try_solve_from_state_uses_live_session_state() {
fn try_solve_from_state_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
@@ -253,4 +331,15 @@ mod tests {
}
}
}
#[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());
}
}