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
+1
View File
@@ -12,6 +12,7 @@ solitaire_core = { path = "../solitaire_core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
klondike = { workspace = true }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
console_error_panic_hook = { version = "0.1", optional = true }
+62 -359
View File
@@ -19,11 +19,12 @@
//! is the contract.
use chrono::NaiveDate;
use klondike::{Foundation, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::error::MoveError;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::klondike_adapter::SavedKlondikePile;
use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
@@ -32,8 +33,8 @@ use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
Move {
from: PileType,
to: PileType,
from: SavedKlondikePile,
to: SavedKlondikePile,
count: usize,
},
StockClick,
@@ -142,7 +143,13 @@ impl ReplayPlayer {
}
let mv = self.moves[self.step_idx].clone();
match mv {
ReplayMove::Move { from, to, count } => self.game.move_cards(from, to, count)?,
ReplayMove::Move { from, to, count } => self.game.move_cards(
from.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
to.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
count,
)?,
ReplayMove::StockClick => self.game.draw()?,
}
self.step_idx += 1;
@@ -150,27 +157,22 @@ impl ReplayPlayer {
}
fn snapshot(&self) -> StateSnapshot {
let pile_cards = |t: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let pile_cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let foundations: [Vec<CardSnapshot>; 4] = [
pile_cards(PileType::Foundation(0)),
pile_cards(PileType::Foundation(1)),
pile_cards(PileType::Foundation(2)),
pile_cards(PileType::Foundation(3)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus: [Vec<CardSnapshot>; 7] = [
pile_cards(PileType::Tableau(0)),
pile_cards(PileType::Tableau(1)),
pile_cards(PileType::Tableau(2)),
pile_cards(PileType::Tableau(3)),
pile_cards(PileType::Tableau(4)),
pile_cards(PileType::Tableau(5)),
pile_cards(PileType::Tableau(6)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
];
StateSnapshot {
step_idx: self.step_idx,
@@ -178,8 +180,8 @@ impl ReplayPlayer {
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
stock: pile_cards(PileType::Stock),
waste: pile_cards(PileType::Waste),
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
foundations,
tableaus,
}
@@ -289,24 +291,11 @@ pub struct SolitaireGame {
impl SolitaireGame {
fn snap(&self) -> GameSnapshot {
let cards = |t: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let has_moves = {
let stock_empty = self
.game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
let waste_empty = self
.game
.piles
.get(&PileType::Waste)
.is_none_or(|p| p.cards.is_empty());
let stock_empty = self.game.stock_cards().is_empty();
let waste_empty = self.game.waste_cards().is_empty();
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
};
GameSnapshot {
@@ -317,30 +306,29 @@ impl SolitaireGame {
has_moves,
undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(),
stock: cards(PileType::Stock),
waste: cards(PileType::Waste),
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
foundations: [
cards(PileType::Foundation(0)),
cards(PileType::Foundation(1)),
cards(PileType::Foundation(2)),
cards(PileType::Foundation(3)),
cards(KlondikePile::Foundation(Foundation::Foundation1)),
cards(KlondikePile::Foundation(Foundation::Foundation2)),
cards(KlondikePile::Foundation(Foundation::Foundation3)),
cards(KlondikePile::Foundation(Foundation::Foundation4)),
],
tableaus: [
cards(PileType::Tableau(0)),
cards(PileType::Tableau(1)),
cards(PileType::Tableau(2)),
cards(PileType::Tableau(3)),
cards(PileType::Tableau(4)),
cards(PileType::Tableau(5)),
cards(PileType::Tableau(6)),
cards(KlondikePile::Tableau(Tableau::Tableau1)),
cards(KlondikePile::Tableau(Tableau::Tableau2)),
cards(KlondikePile::Tableau(Tableau::Tableau3)),
cards(KlondikePile::Tableau(Tableau::Tableau4)),
cards(KlondikePile::Tableau(Tableau::Tableau5)),
cards(KlondikePile::Tableau(Tableau::Tableau6)),
cards(KlondikePile::Tableau(Tableau::Tableau7)),
],
}
}
fn pile_from_str(s: &str) -> Result<PileType, String> {
fn pile_from_str(s: &str) -> Result<KlondikePile, String> {
match s {
"stock" => Ok(PileType::Stock),
"waste" => Ok(PileType::Waste),
"stock" | "waste" => Ok(KlondikePile::Stock),
_ if s.starts_with("foundation-") => {
let slot: u8 = s["foundation-".len()..]
.parse()
@@ -348,7 +336,13 @@ impl SolitaireGame {
if slot >= 4 {
return Err(format!("foundation slot out of range: {slot}"));
}
Ok(PileType::Foundation(slot))
Ok(KlondikePile::Foundation(match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
3 => Foundation::Foundation4,
_ => return Err(format!("foundation slot out of range: {slot}")),
}))
}
_ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..]
@@ -357,7 +351,16 @@ impl SolitaireGame {
if col >= 7 {
return Err(format!("tableau col out of range: {col}"));
}
Ok(PileType::Tableau(col))
Ok(KlondikePile::Tableau(match col {
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
6 => Tableau::Tableau7,
_ => return Err(format!("tableau col out of range: {col}")),
}))
}
_ => Err(format!("unknown pile: {s}")),
}
@@ -495,303 +498,3 @@ impl SolitaireGame {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_replay_json() -> String {
// Minimal v2 replay: seed 42, two stock clicks. Real winning
// replays will have many more moves; for the test we just
// verify deserialization + step() advances correctly.
r#"{
"schema_version": 2,
"seed": 42,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": ["StockClick", "StockClick"]
}"#
.to_string()
}
/// Constructing from a valid v2 replay JSON must succeed and
/// initialise step_idx to 0.
#[test]
fn new_initialises_step_idx_zero() {
let player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert_eq!(player.step_idx, 0);
assert_eq!(player.moves.len(), 2);
}
/// Each step advances the index; once exhausted, step_native returns None.
#[test]
fn steps_advance_then_terminate() {
let mut player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert!(
player
.step_native()
.expect("first move should apply")
.is_some()
);
assert_eq!(player.step_idx, 1);
assert!(
player
.step_native()
.expect("second move should apply")
.is_some()
);
assert_eq!(player.step_idx, 2);
assert!(
player
.step_native()
.expect("replay should be exhausted")
.is_none(),
"no further steps"
);
}
/// Malformed JSON returns an error rather than panicking.
#[test]
fn invalid_json_returns_error() {
let result = ReplayPlayer::from_json("not valid json");
assert!(result.is_err());
}
#[test]
fn invalid_replay_move_returns_error_without_advancing() {
let replay = Replay {
schema_version: 2,
seed: 42,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 60,
final_score: 100,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
moves: vec![ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(0),
count: 1,
}],
};
let json = serde_json::to_string(&replay).expect("replay serialises");
let mut player = ReplayPlayer::from_json(&json).expect("valid JSON");
let err = player
.step_native()
.expect_err("illegal replay move must surface an error");
assert_eq!(err, MoveError::EmptySource);
assert_eq!(
player.step_idx, 0,
"desync must not advance the replay cursor"
);
}
// -------------------------------------------------------------------------
// Winning-sequence step-through
// -------------------------------------------------------------------------
/// Greedy Klondike solver for DrawOne Classic.
///
/// Returns a `ReplayMove` list that wins the game from `seed`, or `None`
/// when the greedy heuristic gets stuck within the move budget.
///
/// Priority order (highest first):
/// 1. Waste → Foundation
/// 2. Tableau top → Foundation
/// 3. Tableau stack → Tableau, only if the move uncovers a face-down card
/// 4. Waste → Tableau
/// 5. Draw from stock (recycle is automatic inside `GameState::draw`)
fn greedy_solve(seed: u64) -> Option<Vec<ReplayMove>> {
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
let mut game = GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic);
let mut moves: Vec<ReplayMove> = Vec::new();
const MAX_MOVES: usize = 10_000;
'outer: loop {
if game.is_won {
return Some(moves);
}
if moves.len() >= MAX_MOVES {
return None;
}
// Auto-complete: drive to win without further player input.
if game.is_auto_completable {
while let Some((from, to)) = game.next_auto_complete_move() {
if game.move_cards(from.clone(), to.clone(), 1).is_err() {
return None;
}
moves.push(ReplayMove::Move { from, to, count: 1 });
}
return if game.is_won { Some(moves) } else { None };
}
// P1: Waste → Foundation.
for slot in 0..4_u8 {
if game
.move_cards(PileType::Waste, PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
// P2: Tableau top → Foundation.
for i in 0..7_usize {
for slot in 0..4_u8 {
if game
.move_cards(PileType::Tableau(i), PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
}
// P3: Tableau stack → Tableau only when it uncovers a face-down card.
let mut made_move = false;
'p3: for i in 0..7_usize {
let pile_len = game.piles[&PileType::Tableau(i)].cards.len();
for count in 1..=pile_len {
let start = pile_len - count;
// Only worth moving if a face-down card sits just below.
let would_uncover =
start > 0 && !game.piles[&PileType::Tableau(i)].cards[start - 1].face_up;
if !would_uncover {
continue;
}
for j in 0..7_usize {
if i == j {
continue;
}
if game
.move_cards(PileType::Tableau(i), PileType::Tableau(j), count)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Tableau(j),
count,
});
made_move = true;
break 'p3;
}
}
}
}
if made_move {
continue 'outer;
}
// P4: Waste → Tableau.
for j in 0..7_usize {
if game
.move_cards(PileType::Waste, PileType::Tableau(j), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(j),
count: 1,
});
continue 'outer;
}
}
// P5: Draw from stock (handles recycle automatically).
if game.draw().is_ok() {
moves.push(ReplayMove::StockClick);
continue 'outer;
}
// No moves available — greedy solver is stuck on this seed.
return None;
}
}
/// Full end-to-end winning-sequence regression test.
///
/// 1. Runs the greedy solver on seeds 1200 to find the first
/// deterministically winnable game.
/// 2. Serialises the winning move list as a `Replay` JSON string.
/// 3. Feeds the JSON to `ReplayPlayer::from_json`.
/// 4. Steps through every move via `step_native` and asserts `is_won`
/// on the final snapshot.
///
/// Regression target: a `GameState` or `ReplayMove` change that breaks
/// an historically valid move sequence will cause `is_won` to be `false`
/// at the end of the replay, failing this test before any release.
#[test]
fn replay_player_completes_full_winning_sequence() {
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
let (seed, winning_moves) = (1_u64..=200)
.find_map(|s| greedy_solve(s).map(|m| (s, m)))
.expect("at least one seed in 1..=200 must be solvable by the greedy strategy");
let replay = Replay {
schema_version: 2,
seed,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 300,
final_score: 0,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 12).expect("2026-05-12 is a valid date"),
moves: winning_moves.clone(),
};
let json = serde_json::to_string(&replay).expect("replay serialises to JSON cleanly");
let mut player =
ReplayPlayer::from_json(&json).expect("solver-generated replay JSON must be valid");
assert_eq!(player.step_idx, 0, "player must start at step 0");
assert_eq!(
player.moves.len(),
winning_moves.len(),
"player must hold the complete move list"
);
let mut last_snap: Option<StateSnapshot> = None;
while let Some(snap) = player
.step_native()
.expect("solver-generated replay must stay in sync")
{
last_snap = Some(snap);
}
let snap = last_snap.expect("winning sequence must contain at least one move");
assert!(
snap.is_won,
"seed {seed}: final snapshot after full replay must have is_won = true \
({} moves applied)",
winning_moves.len()
);
assert_eq!(
snap.step_idx,
winning_moves.len(),
"step_idx after the last move must equal the total move count"
);
assert!(
player
.step_native()
.expect("winning replay should still be exhausted")
.is_none(),
"step_native must return None once all moves are exhausted"
);
}
}