refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
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:
+213
-124
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user