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
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -22,7 +22,6 @@ use klondike::{
use serde::{Deserialize, Serialize};
use crate::game_state::{DrawMode, GameMode};
use crate::pile::PileType;
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
@@ -97,12 +96,12 @@ impl KlondikeAdapter {
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(&self, from: &PileType, to: &PileType) -> i32 {
pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = &self.config.scoring;
match (from, to) {
(_, PileType::Foundation(_)) => sc.move_to_foundation,
(PileType::Waste, PileType::Tableau(_)) => sc.move_to_tableau,
(PileType::Foundation(_), PileType::Tableau(_)) => sc.move_from_foundation,
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
_ => 0,
}
}
@@ -146,7 +145,12 @@ impl KlondikeAdapter {
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(&self, from: &PileType, to: &PileType, mode: GameMode) -> i32 {
pub fn score_for_move_with_mode(
&self,
from: &KlondikePile,
to: &KlondikePile,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen { 0 } else { self.score_for_move(from, to) }
}
+12 -37
View File
@@ -1,33 +1,18 @@
use crate::card::{Card, Suit};
use serde::{Deserialize, Serialize};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PileType {
/// The face-down draw pile.
Stock,
/// The face-up discard pile drawn to.
Waste,
/// One of the four foundation slots (0..=3). The claimed suit, if any,
/// is derived from the bottom card of the pile (always an Ace by
/// construction).
Foundation(u8),
/// One of the seven tableau columns (06).
Tableau(usize),
}
use klondike::KlondikePile;
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType,
/// 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.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: PileType) -> Self {
pub fn new(pile_type: KlondikePile) -> Self {
Self {
pile_type,
cards: Vec::new(),
@@ -44,7 +29,7 @@ impl Pile {
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
@@ -57,13 +42,13 @@ mod tests {
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(PileType::Stock);
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(PileType::Waste);
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
@@ -81,29 +66,19 @@ mod tests {
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(PileType::Waste);
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.top().is_none());
}
#[test]
fn pile_type_foundation_uses_slot_index() {
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
}
#[test]
fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(PileType::Foundation(0));
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(PileType::Tableau(0));
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
@@ -115,7 +90,7 @@ mod tests {
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(PileType::Foundation(2));
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
+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());
}
}