Files
Ferrous-Solitaire/solitaire_wasm/src/lib.rs
T
funman300 4cb4212829
Test / test (pull_request) Successful in 36m34s
fix(replay): store the deal via upstream card_game serializers (schema v4)
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:10:38 -07:00

1255 lines
46 KiB
Rust

//! WebAssembly bindings for browser-side replay playback and interactive gameplay.
//!
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
//! step applies one [`KlondikeInstruction`] to the underlying `GameState`
//! and returns the resulting pile snapshot as JSON for the JS layer to
//! render.
//!
//! The state machine is the same Rust [`solitaire_core::GameState`]
//! the desktop client uses, so the two implementations cannot drift —
//! same seed + same input list = same pile state at every step,
//! regardless of which platform replays the game.
//!
//! The crate intentionally does **not** depend on `solitaire_data`
//! (which pulls `dirs`, `keyring`, `reqwest`, and other non-wasm
//! crates) — instead it defines a minimal `Replay` mirror with the
//! same serde shape as `solitaire_data::Replay`. The JSON wire format
//! is the contract.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::error::MoveError;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
use wasm_bindgen::prelude::*;
/// Replay schema version this player understands. Mirrors
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
/// other version with a descriptive error instead of desyncing.
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
/// Mirrors `solitaire_data::Replay` v4.
///
/// `recording` is the upstream `card_game` session serialisation
/// (`{config, initial_state, instructions}`): the dealt board is stored
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
/// an RNG or upstream upgrade changed the seed→deal mapping.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
#[serde(default)]
pub schema_version: u32,
pub seed: u64,
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub time_seconds: u64,
pub final_score: i32,
pub recorded_at: NaiveDate,
pub recording: SessionRecording,
#[serde(default)]
pub win_move_index: Option<usize>,
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StateSnapshot {
pub step_idx: usize,
pub total_steps: usize,
pub score: i32,
pub move_count: u32,
pub is_won: bool,
pub stock: Vec<CardSnapshot>,
pub waste: Vec<CardSnapshot>,
/// Length 4 — one per foundation slot, in slot order (0..=3). The
/// claimed suit (if any) is the bottom card's suit.
pub foundations: [Vec<CardSnapshot>; 4],
/// Length 7 — one per tableau column (0..=6).
pub tableaus: [Vec<CardSnapshot>; 7],
}
/// One card, projected for the JS card renderer. `face_up = false`
/// means the card back is drawn; in that case `suit` and `rank` are
/// still set (so the renderer doesn't need separate "unknown" data),
/// just hidden visually.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CardSnapshot {
/// Stable per-card identity for the JS renderer (an opaque key). Serialises
/// as the upstream `Card`'s transparent integer value.
pub id: Card,
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
pub suit: &'static str,
/// 1-13, where 1 is Ace and 13 is King.
pub rank: u8,
pub face_up: bool,
}
impl From<&(Card, bool)> for CardSnapshot {
fn from((card, face_up): &(Card, bool)) -> Self {
Self {
id: card.clone(),
suit: match card.suit() {
Suit::Clubs => "clubs",
Suit::Diamonds => "diamonds",
Suit::Hearts => "hearts",
Suit::Spades => "spades",
},
rank: card.rank() as u8,
face_up: *face_up,
}
}
}
/// Browser-side replay state machine. Owns a live `GameState` and the
/// replay's move list; each `step()` applies the next move.
#[wasm_bindgen]
pub struct ReplayPlayer {
game: GameState,
moves: Vec<KlondikeInstruction>,
step_idx: usize,
}
fn log_replay_move_error(err: &MoveError) {
#[cfg(target_arch = "wasm32")]
web_sys::console::error_1(&format!("Replay move failed: {:?}", err).into());
#[cfg(not(target_arch = "wasm32"))]
eprintln!("Replay move failed: {:?}", err);
}
// Native-callable methods. Used by both the wasm-bindgen interface
// below and by unit tests, which can't go through `serde_wasm_bindgen`
// (it panics on non-wasm targets).
impl ReplayPlayer {
/// Construct from a raw replay JSON string. Returns the parsing
/// error as a `String` so the wasm-bindgen wrapper can convert
/// it to a `JsValue` and tests can assert on it directly.
pub fn from_json(replay_json: &str) -> Result<Self, String> {
let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return Err(format!(
"unsupported replay schema_version {} (this player requires {}); \
replays recorded by older clients cannot be replayed",
replay.schema_version, REPLAY_SCHEMA_VERSION
));
}
// The recording carries the dealt board and session config, so the
// rebuilt game is bit-identical to the recorded deal no matter how
// the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self {
game,
moves,
step_idx: 0,
})
}
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
if self.step_idx >= self.moves.len() {
return Ok(None);
}
let instruction = self.moves[self.step_idx];
self.game.apply_instruction(instruction)?;
self.step_idx += 1;
Ok(Some(self.snapshot()))
}
fn snapshot(&self) -> StateSnapshot {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let foundations: [Vec<CardSnapshot>; 4] =
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
let tableaus: [Vec<CardSnapshot>; 7] =
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
StateSnapshot {
step_idx: self.step_idx,
total_steps: self.moves.len(),
score: self.game.score(),
move_count: self.game.move_count(),
is_won: self.game.is_won(),
stock: self
.game
.stock_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
waste: self
.game
.waste_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
foundations,
tableaus,
}
}
}
// JS-facing surface. Thin wrapper around the native API: serialises
// `StateSnapshot` to `JsValue` via `serde_wasm_bindgen` and converts
// `String` errors to `JsValue` strings. Native unit tests bypass this
// layer because `serde_wasm_bindgen::to_value` panics off-target.
#[wasm_bindgen]
impl ReplayPlayer {
/// Construct from a raw replay JSON string.
#[wasm_bindgen(constructor)]
pub fn new(replay_json: &str) -> Result<ReplayPlayer, JsValue> {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
Self::from_json(replay_json).map_err(|e| JsValue::from_str(&e))
}
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
///
/// Throws a JS string exception on serialisation failure (should never
/// occur in practice — `StateSnapshot` contains only primitive types).
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snapshot())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply the next move; returns the post-step snapshot, or `null`
/// once the move list is exhausted.
///
/// Returns `null` (not an exception) when the replay is finished.
/// Throws `"replay_desync"` when the next recorded move is illegal for
/// the current state, and logs the underlying core error to the JS console.
/// Throws a JS string exception on serialisation failure.
pub fn step(&mut self) -> Result<JsValue, JsValue> {
match self.step_native() {
Ok(Some(snap)) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Ok(None) => Ok(JsValue::NULL),
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains.
pub fn total_steps(&self) -> usize {
self.moves.len()
}
/// 0-indexed position of the next move to apply.
pub fn step_idx(&self) -> usize {
self.step_idx
}
}
// ---------------------------------------------------------------------------
// Interactive game surface
// ---------------------------------------------------------------------------
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GameSnapshot {
pub score: i32,
pub move_count: u32,
pub is_won: bool,
pub is_auto_completable: bool,
/// `false` when stock, waste, and all pile-to-pile moves are exhausted.
pub has_moves: bool,
pub undo_count: u32,
/// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
pub undo_stack_len: usize,
pub stock: Vec<CardSnapshot>,
pub waste: Vec<CardSnapshot>,
pub foundations: [Vec<CardSnapshot>; 4],
pub tableaus: [Vec<CardSnapshot>; 7],
}
/// Result returned to JS from every mutating game action.
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot: Option<GameSnapshot>,
}
/// Debug action understood by the automation-oriented debug API.
///
/// This mirrors legal player inputs and is intentionally independent from DOM
/// or pointer coordinates so test runners can drive the engine directly.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DebugMove {
Move {
from: String,
to: String,
count: usize,
},
StockClick,
}
/// Invariant report returned by the debug API after each step.
///
/// `state_ok` is `true` when no structural violations were detected.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugInvariantReport {
pub state_ok: bool,
pub total_cards_seen: usize,
/// Cards that appeared more than once across all piles.
pub duplicate_cards: Vec<Card>,
/// Cards from the full single-deck set that are absent from the board.
pub missing_cards: Vec<Card>,
pub stock_has_face_up_cards: bool,
pub waste_has_face_down_cards: bool,
pub foundation_has_face_down_cards: bool,
pub tableau_visibility_violation: bool,
pub soft_lock: bool,
}
/// Full debug snapshot for engine-integration and browser automation tests.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugSnapshot {
pub seed: u64,
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub state: GameSnapshot,
pub legal_moves: Vec<DebugMove>,
pub move_history: Vec<KlondikeInstruction>,
pub invariants: DebugInvariantReport,
pub state_json: String,
}
fn pile_name(pile: KlondikePile) -> String {
match pile {
KlondikePile::Stock => "stock".to_string(),
KlondikePile::Foundation(f) => format!("foundation-{}", f as u8),
KlondikePile::Tableau(t) => format!("tableau-{}", t as u8),
}
}
fn can_stock_click(game: &GameState) -> bool {
!(game.is_won() || game.stock_cards().is_empty() && game.waste_cards().is_empty())
}
fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
let mut moves: Vec<DebugMove> = game
.possible_instructions()
.into_iter()
.filter_map(|instruction| game.instruction_to_piles(instruction))
.map(|(from, to, count)| DebugMove::Move {
from: pile_name(from),
to: pile_name(to),
count,
})
.collect();
if can_stock_click(game) {
moves.push(DebugMove::StockClick);
}
moves
}
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards();
let waste = game.waste_cards();
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_cards = Vec::new();
let mut total_cards_seen = 0_usize;
let mut feed = |cards: &[(Card, bool)]| {
for (card, _) in cards {
total_cards_seen += 1;
if !seen.insert(card.clone()) {
duplicate_cards.push(card.clone());
}
}
};
feed(&stock);
feed(&waste);
for pile in &foundations {
feed(pile);
}
for pile in &tableaus {
feed(pile);
}
// Reference set: the full 52-card single deck, using whichever deck id the
// dealt cards carry. Any of those 52 not on the board is missing.
let deck = seen
.iter()
.next()
.map(|c| c.deck())
.unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid"));
let mut missing_cards = Vec::new();
for suit in Suit::SUITS {
for rank in Rank::RANKS {
let card = Card::new(deck, suit, rank);
if !seen.contains(&card) {
missing_cards.push(card);
}
}
}
let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
let foundation_has_face_down_cards = foundations
.iter()
.any(|pile| pile.iter().any(|(_, face_up)| !*face_up));
let tableau_visibility_violation = tableaus.iter().any(|pile| {
let mut seen_face_up = false;
for (_, face_up) in pile {
if *face_up {
seen_face_up = true;
} else if seen_face_up {
return true;
}
}
false
});
let soft_lock =
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty()
&& !stock_has_face_up_cards
&& !waste_has_face_down_cards
&& !foundation_has_face_down_cards
&& !tableau_visibility_violation;
DebugInvariantReport {
state_ok,
total_cards_seen,
duplicate_cards,
missing_cards,
stock_has_face_up_cards,
waste_has_face_down_cards,
foundation_has_face_down_cards,
tableau_visibility_violation,
soft_lock,
}
}
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
///
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
/// `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
/// full pile snapshot at any time without mutating state.
#[wasm_bindgen]
pub struct SolitaireGame {
game: GameState,
}
impl SolitaireGame {
fn snap(&self) -> GameSnapshot {
let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let has_moves = {
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 {
score: self.game.score(),
move_count: self.game.move_count(),
is_won: self.game.is_won(),
is_auto_completable: self.game.is_auto_completable(),
has_moves,
undo_count: self.game.undo_count(),
undo_stack_len: self.game.undo_stack_len(),
stock: self
.game
.stock_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
waste: self
.game
.waste_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
}
}
fn pile_from_str(s: &str) -> Result<KlondikePile, String> {
match s {
"stock" | "waste" => Ok(KlondikePile::Stock),
_ if s.starts_with("foundation-") => {
let slot: u8 = s["foundation-".len()..]
.parse()
.map_err(|_| format!("bad pile: {s}"))?;
let foundation = solitaire_core::FOUNDATIONS
.get(slot as usize)
.copied()
.ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
Ok(KlondikePile::Foundation(foundation))
}
_ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..]
.parse()
.map_err(|_| format!("bad pile: {s}"))?;
let tableau = solitaire_core::TABLEAUS
.get(col)
.copied()
.ok_or_else(|| format!("tableau col out of range: {col}"))?;
Ok(KlondikePile::Tableau(tableau))
}
_ => Err(format!("unknown pile: {s}")),
}
}
fn legal_moves_native(&self) -> Vec<DebugMove> {
legal_moves_for_game(&self.game)
}
fn move_history_native(&self) -> Vec<KlondikeInstruction> {
self.game.instruction_history()
}
fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
// The session's forward instruction history *is* the replayable
// move list: each entry replays cleanly via `apply_instruction`
// against a fresh game with the same seed/draw mode/mode.
self.game.instruction_history()
}
/// Builds the complete schema-v4 replay upload payload for the live
/// game as a JSON string, ready to `POST /api/replays` verbatim.
///
/// The JS layer must not assemble this payload itself: the recording
/// serialises through the upstream `card_game` serializers, and the
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
/// silently corrupts it).
///
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
/// supplies it because the wasm build has no reliable local clock.
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
let recorded_at: NaiveDate = recorded_at
.parse()
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
let recording = self.game.recording();
let win_move_index = recording.len().checked_sub(1);
let replay = Replay {
schema_version: REPLAY_SCHEMA_VERSION,
seed: self.game.seed,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
time_seconds,
final_score: self.game.score(),
recorded_at,
recording,
win_move_index,
};
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
let legal_moves = self.legal_moves_native();
let invariants = invariant_report_for_game(&self.game, &legal_moves);
let state_json = serde_json::to_string(&self.game).unwrap_or_default();
DebugSnapshot {
seed: self.game.seed,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
state: self.snap(),
legal_moves,
move_history: self.move_history_native(),
invariants,
state_json,
}
}
fn apply_debug_move_native(&mut self, mv: &DebugMove) -> Result<(), String> {
match mv {
DebugMove::StockClick => self.game.draw().map_err(|e| e.to_string()),
DebugMove::Move { from, to, count } => {
let from_pile = Self::pile_from_str(from)?;
let to_pile = Self::pile_from_str(to)?;
if from_pile == KlondikePile::Stock && to_pile == KlondikePile::Stock {
self.game.draw().map_err(|e| e.to_string())
} else {
self.game
.move_cards(from_pile, to_pile, *count)
.map_err(|e| e.to_string())
}
}
}
}
fn apply_legal_move_native(&mut self, index: usize) -> Result<(), String> {
let legal_moves = self.legal_moves_native();
let mv = legal_moves
.get(index)
.ok_or_else(|| format!("legal move index out of range: {index}"))?
.clone();
self.apply_debug_move_native(&mv)
}
fn ok_js(&self) -> JsValue {
serde_wasm_bindgen::to_value(&ActionResult {
ok: true,
error: None,
snapshot: Some(self.snap()),
})
.unwrap_or(JsValue::NULL)
}
fn err_js(msg: impl std::fmt::Display) -> JsValue {
serde_wasm_bindgen::to_value(&ActionResult {
ok: false,
error: Some(msg.to_string()),
snapshot: None,
})
.unwrap_or(JsValue::NULL)
}
}
#[wasm_bindgen]
impl SolitaireGame {
/// Create a new DrawOne or DrawThree Classic game from the given seed.
///
/// `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
/// Pass `Date.now()` or a random integer from JS for variety.
#[wasm_bindgen(constructor)]
pub fn new(seed: f64, draw_three: bool) -> SolitaireGame {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let dm = if draw_three {
DrawStockConfig::DrawThree
} else {
DrawStockConfig::DrawOne
};
SolitaireGame {
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
}
}
/// Full pile snapshot as a JS object.
///
/// Throws a JS string exception on serialisation failure.
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snap()).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// The seed used to deal this game.
pub fn seed(&self) -> f64 {
self.game.seed as f64
}
/// Draw from stock to waste (or recycle waste → stock when stock is empty).
/// Returns `{ok, error?, snapshot?}`.
pub fn draw(&mut self) -> JsValue {
match self.game.draw() {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
/// Move `count` cards from pile `from` to pile `to`.
///
/// Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
/// `"tableau-0"` .. `"tableau-6"`.
///
/// Returns `{ok, error?, snapshot?}`.
pub fn move_cards(&mut self, from: &str, to: &str, count: usize) -> JsValue {
let from_pile = match Self::pile_from_str(from) {
Ok(p) => p,
Err(e) => return Self::err_js(e),
};
let to_pile = match Self::pile_from_str(to) {
Ok(p) => p,
Err(e) => return Self::err_js(e),
};
match self.game.move_cards(from_pile, to_pile, count) {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
/// Undo the last move. Returns `{ok, error?, snapshot?}`.
pub fn undo(&mut self) -> JsValue {
match self.game.undo() {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
/// Serialise the full game state as a JSON string for `localStorage`.
///
/// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
/// opaque — callers should treat it as a blob and store/restore it verbatim.
pub fn serialize(&self) -> Result<String, JsValue> {
serde_json::to_string(&self.game).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
///
/// Returns an error string if the JSON is malformed or describes a state
/// that can't be deserialised (e.g. from a future schema version).
pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
serde_json::from_str::<GameState>(json)
.map(|mut game| {
// Older saves serialised with take_from_foundation=false (the core default).
// The web client has no settings layer, so enforce the standard rule here.
game.take_from_foundation = true;
SolitaireGame { game }
})
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply one auto-complete move (only valid when `is_auto_completable`).
///
/// If no card can go directly to a foundation this step, advances the
/// waste by calling `draw()` so the next step can try again. Returns the
/// post-move snapshot, or `null` when no progress is possible.
pub fn auto_complete_step(&mut self) -> JsValue {
if !self.game.is_auto_completable() {
return JsValue::NULL;
}
if let Some((from, to)) = self.game.next_auto_complete_move() {
let _ = self.game.move_cards(from, to, 1);
return self.ok_js();
}
// No direct foundation move — advance through the waste.
match self.game.draw() {
Ok(()) => self.ok_js(),
Err(_) => JsValue::NULL,
}
}
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format
/// — a list of upstream [`KlondikeInstruction`]s.
///
/// This is the deterministic instruction history; together with `seed()`
/// and the draw mode it replays cleanly via `apply_instruction`.
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
let moves = self.replay_moves_native();
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Complete schema-v4 replay payload for the live game, as a JSON
/// string ready to `POST /api/replays` verbatim. See
/// [`Self::replay_export_native`] for why JS must not assemble the
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
/// would demand a `BigInt`).
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
self.replay_export_native(u64::from(time_seconds), &recorded_at)
.map_err(|e| JsValue::from_str(&e))
}
/// Returns all currently-legal debug moves as a JS array.
///
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
pub fn debug_legal_moves(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.legal_moves_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Returns deterministic instruction history for the current game.
///
/// Together with `seed()` and `draw_mode`, this history is replayable.
pub fn debug_move_history(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.move_history_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Returns a comprehensive debug snapshot for automated verification.
pub fn debug_snapshot(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.debug_snapshot_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Applies the legal move currently at `index` from `debug_legal_moves()`.
pub fn debug_apply_legal_move(&mut self, index: usize) -> JsValue {
match self.apply_legal_move_native(index) {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
/// Applies one debug move encoded as JSON.
///
/// JSON must match [`DebugMove`], for example:
/// `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
/// `{"kind":"stock_click"}`.
pub fn debug_apply_move_json(&mut self, move_json: &str) -> JsValue {
let parsed = match serde_json::from_str::<DebugMove>(move_json) {
Ok(value) => value,
Err(e) => return Self::err_js(format!("invalid debug move JSON: {e}")),
};
match self.apply_debug_move_native(&parsed) {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::fmt::Write;
/// The JS card renderer reads `card.id` as an opaque identity key. After the
/// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is
/// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a
/// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses).
#[test]
fn card_snapshot_id_serialises_as_a_plain_number() {
let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]);
let snap = CardSnapshot::from(&(card, true));
let json = serde_json::to_value(&snap).expect("serialise CardSnapshot");
assert!(
json["id"].is_number(),
"card.id must serialise as a JSON number for the JS opaque key, got {:?}",
json["id"]
);
assert_eq!(json["suit"], "hearts");
assert_eq!(json["rank"], 1);
assert_eq!(json["face_up"], true);
}
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
if moves.is_empty() {
return None;
}
if let Some((idx, _)) = moves.iter().enumerate().find(|(_, m)| {
matches!(
m,
DebugMove::Move {
to,
count: 1,
..
} if to.starts_with("foundation-")
)
}) {
return Some(idx);
}
if let Some((idx, _)) = moves
.iter()
.enumerate()
.find(|(_, m)| matches!(m, DebugMove::Move { .. }))
{
return Some(idx);
}
Some(0)
}
fn assert_invariants(snapshot: &DebugSnapshot, seed: u64) {
assert!(
snapshot.invariants.state_ok,
"state invariant failure (seed={seed}): {:?}",
snapshot.invariants
);
}
fn board_key(state: &GameSnapshot) -> String {
let mut key = String::new();
let mut push_cards = |cards: &[CardSnapshot]| {
for card in cards {
let _ = write!(
key,
"{:?}:{}:{},",
card.id,
card.rank,
if card.face_up { 1 } else { 0 }
);
}
key.push('|');
};
push_cards(&state.stock);
push_cards(&state.waste);
for pile in &state.foundations {
push_cards(pile);
}
for pile in &state.tableaus {
push_cards(pile);
}
key
}
fn run_autonomous(seed: u64, draw_mode: DrawStockConfig, max_steps: usize) -> DebugSnapshot {
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
let mut last_snapshot = game.debug_snapshot_native();
let mut seen_states = HashSet::new();
seen_states.insert(board_key(&last_snapshot.state));
assert_invariants(&last_snapshot, seed);
for step in 0..max_steps {
if last_snapshot.state.is_won || last_snapshot.legal_moves.is_empty() {
return last_snapshot;
}
let idx = pick_move_index(&last_snapshot.legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!("failed to apply legal move (seed={seed}, step={step}, idx={idx}): {e}");
}
last_snapshot = game.debug_snapshot_native();
if !seen_states.insert(board_key(&last_snapshot.state)) {
// Deterministic autoplay returned to an earlier state.
// Treat as a terminal non-winning run, not a harness failure.
return last_snapshot;
}
assert_invariants(&last_snapshot, seed);
}
panic!("autonomous run exceeded step budget (seed={seed}, max_steps={max_steps})");
}
#[test]
fn debug_snapshot_exposes_replayable_seed_and_history() {
let seed = 42_u64;
let final_snapshot = run_autonomous(seed, DrawStockConfig::DrawOne, 1500);
assert_eq!(final_snapshot.seed, seed);
assert!(
!final_snapshot.state_json.is_empty(),
"debug snapshot must include serialised current state"
);
let restored = match SolitaireGame::from_saved(&final_snapshot.state_json) {
Ok(game) => game,
Err(err) => panic!("failed to restore debug snapshot state: {err:?}"),
};
let restored_snapshot = restored.debug_snapshot_native();
assert_eq!(restored_snapshot.state, final_snapshot.state);
}
#[test]
fn replay_moves_export_is_json_compatible_and_replayable() {
let seed = 7_u64;
let draw_mode = DrawStockConfig::DrawThree;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
for step in 0..64 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!(
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
);
}
}
let exported_moves = game.replay_moves_native();
assert!(
!exported_moves.is_empty(),
"progressed game must export a non-empty replay move list"
);
let replay_json = match game.replay_export_native(120, "2026-06-01") {
Ok(json) => json,
Err(err) => panic!("failed to export replay JSON: {err}"),
};
let parsed: Replay = match serde_json::from_str(&replay_json) {
Ok(parsed) => parsed,
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
};
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
assert_eq!(
parsed.recording.instructions(),
exported_moves,
"exported recording must carry the exact instruction history"
);
assert_eq!(
parsed.win_move_index,
Some(exported_moves.len() - 1),
"win_move_index must point at the last instruction"
);
let mut player = match ReplayPlayer::from_json(&replay_json) {
Ok(value) => value,
Err(err) => panic!("failed to construct replay player: {err}"),
};
loop {
match player.step_native() {
Ok(Some(_)) => {}
Ok(None) => break,
Err(err) => panic!("replay player desynced while applying exported moves: {err}"),
}
}
let original_state = match serde_json::to_string(&game.game) {
Ok(json) => json,
Err(err) => panic!("failed to serialise original game state: {err}"),
};
let replayed_state = match serde_json::to_string(&player.game) {
Ok(json) => json,
Err(err) => panic!("failed to serialise replayed game state: {err}"),
};
assert_eq!(
replayed_state, original_state,
"replayed state must match the live state the moves were exported from"
);
}
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
/// mechanism that broke when the seed→deal mapping changed. The player
/// must refuse them with a version error, never desync silently.
#[test]
fn replay_player_rejects_pre_v4_schema_versions() {
let v3_json = r#"{
"schema_version": 3,
"seed": 7,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-01",
"recording": null,
"moves": []
}"#;
// v3 files carry `moves`, not `recording`; either way the version
// gate (or the missing field) must produce an error, not a player.
let err = match ReplayPlayer::from_json(v3_json) {
Err(err) => err,
Ok(_) => panic!("v3 replay must be rejected"),
};
assert!(
err.contains("schema_version") || err.contains("invalid replay JSON"),
"error must name the version/format problem, got: {err}"
);
}
/// The whole point of v4: playback rebuilds the deal from the
/// recording, so a replay stays correct even when the top-level
/// `seed` no longer maps to the same deal (RNG upgrades, or a
/// corrupted seed from the old JS `Math.round` path).
#[test]
fn replay_playback_ignores_seed_for_dealing() {
let game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
let replay_json = game
.replay_export_native(60, "2026-06-01")
.expect("export must succeed");
// Corrupt the seed field only — playback must be unaffected.
let mut value: serde_json::Value =
serde_json::from_str(&replay_json).expect("parse exported JSON");
value["seed"] = serde_json::Value::from(0_u64);
let corrupted = serde_json::to_string(&value).expect("reserialise");
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
let replayed_deal =
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
// Compare the board projections (piles), not the GameState wrapper
// (whose serde includes the now-different seed metadata).
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
assert_eq!(
orig["tableaus"], repl["tableaus"],
"tableau deal must come from the recording, not the seed"
);
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {
let draw_mode = if seed % 2 == 0 {
DrawStockConfig::DrawOne
} else {
DrawStockConfig::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 2000);
assert_invariants(&snapshot, seed);
}
}
#[test]
#[ignore = "long-running soak for unattended CI pipelines"]
fn debug_api_autonomous_thousands_seed_soak() {
for seed in 10_000_u64..12_000_u64 {
let draw_mode = if seed % 2 == 0 {
DrawStockConfig::DrawOne
} else {
DrawStockConfig::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 3000);
assert_invariants(&snapshot, seed);
}
}
#[test]
fn serialize_from_saved_round_trip() {
let seed = 55_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
// Advance a few moves so there is non-trivial state to round-trip.
for _ in 0..20 {
let moves = game.legal_moves_native();
if moves.is_empty() {
break;
}
let idx = pick_move_index(&moves).unwrap_or_default();
let _ = game.apply_legal_move_native(idx);
}
let json = game
.serialize()
.expect("serialize must succeed for a valid game");
assert!(!json.is_empty(), "serialized JSON must be non-empty");
let restored =
SolitaireGame::from_saved(&json).expect("from_saved must accept its own output");
assert_eq!(
board_key(&game.debug_snapshot_native().state),
board_key(&restored.debug_snapshot_native().state),
"restored game board must match original after round-trip"
);
assert_eq!(
game.game.seed, restored.game.seed,
"seed must survive serialize/from_saved"
);
}
#[test]
fn undo_reverts_to_prior_state() {
let seed = 99_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let before_key = board_key(&game.debug_snapshot_native().state);
let before_history_len = game.game.instruction_history().len();
let moves = game.legal_moves_native();
assert!(!moves.is_empty(), "seed {seed}: no legal moves at start");
let idx = pick_move_index(&moves).unwrap_or_default();
game.apply_legal_move_native(idx)
.unwrap_or_else(|e| panic!("apply_legal_move failed: {e}"));
// State should have changed.
assert_ne!(
board_key(&game.debug_snapshot_native().state),
before_key,
"board state must change after applying a legal move"
);
// Undo must restore the prior state.
game.game.undo().expect("undo must succeed after one move");
assert_eq!(
board_key(&game.debug_snapshot_native().state),
before_key,
"board state must match pre-move state after undo"
);
assert_eq!(
game.game.instruction_history().len(),
before_history_len,
"history length must return to pre-move value after undo"
);
}
#[test]
fn draw_one_advances_waste_by_one() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(
stock_before > 0,
"seed {seed}: stock must be non-empty at start"
);
game.game
.draw()
.expect("draw must succeed when stock is non-empty");
assert_eq!(
game.game.stock_cards().len(),
stock_before - 1,
"DrawOne: stock must decrease by 1"
);
assert_eq!(
game.game.waste_cards().len(),
waste_before + 1,
"DrawOne: waste must increase by 1"
);
}
#[test]
fn draw_three_advances_waste_by_three() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawStockConfig::DrawThree, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(
stock_before >= 3,
"seed {seed}: stock must have at least 3 cards for this test"
);
game.game
.draw()
.expect("draw must succeed when stock has cards");
let expected_drawn = stock_before.min(3);
assert_eq!(
game.game.stock_cards().len(),
stock_before - expected_drawn,
"DrawThree: stock must decrease by {expected_drawn}"
);
assert_eq!(
game.game.waste_cards().len(),
waste_before + expected_drawn,
"DrawThree: waste must increase by {expected_drawn}"
);
}
#[test]
fn debug_apply_move_json_stock_click_advances_waste() {
let seed = 3_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let waste_before = game.game.waste_cards().len();
assert!(
!game.game.stock_cards().is_empty(),
"seed {seed}: stock must be non-empty at start"
);
// Use the native path: parse the JSON ourselves and apply via the
// native method (debug_apply_move_json wraps this but touches js-sys
// on non-wasm targets).
let mv: DebugMove = serde_json::from_str(r#"{"kind":"stock_click"}"#)
.expect("stock_click JSON must parse to DebugMove");
game.apply_debug_move_native(&mv)
.unwrap_or_else(|e| panic!("apply_debug_move_native failed: {e}"));
assert!(
game.game.waste_cards().len() > waste_before,
"after stock_click move waste must have grown"
);
}
}