8bd2fb89eb
solitaire_wasm/src/lib.rs — 5 new unit tests (9 total, was 4): - serialize_from_saved_round_trip: board key matches after JSON round-trip - undo_reverts_to_prior_state: state + history length restored after undo - draw_one_advances_waste_by_one: DrawOne takes exactly 1 card from stock - draw_three_advances_waste_by_three: DrawThree takes up to 3 cards - debug_apply_move_json_stock_click: JSON DebugMove path via native method solitaire_server/e2e/tests/game_behaviors.spec.js — 5 new Playwright tests: - resume overlay shows when localStorage save exists; seed() returns null until user interacts (before bootstrap completes a game) - clicking New Game on overlay clears history and starts fresh (0 moves) - clicking Resume restores saved move history length exactly - HUD new-game button resets history to 0 and score to 0 - tab-visibility timer: timer freezes during hidden, resumes when visible (tests the visibilitychange fix from the 500-game UX audit); uses page.clock.install() to control setInterval without real-time delay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1274 lines
46 KiB
Rust
1274 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 [`ReplayMove`] 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 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::klondike_adapter::{
|
|
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
|
|
};
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
|
/// player inputs, post-StockClick refinement). Only the JSON shape
|
|
/// matters for cross-crate compatibility.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ReplayMove {
|
|
Move {
|
|
from: SavedKlondikePile,
|
|
to: SavedKlondikePile,
|
|
count: usize,
|
|
},
|
|
StockClick,
|
|
}
|
|
|
|
/// Mirrors `solitaire_data::Replay` v2.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Replay {
|
|
#[serde(default)]
|
|
pub schema_version: u32,
|
|
pub seed: u64,
|
|
pub draw_mode: DrawMode,
|
|
pub mode: GameMode,
|
|
pub time_seconds: u64,
|
|
pub final_score: i32,
|
|
pub recorded_at: NaiveDate,
|
|
pub moves: Vec<ReplayMove>,
|
|
}
|
|
|
|
/// 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, Copy, Serialize, PartialEq, Eq)]
|
|
pub struct CardSnapshot {
|
|
pub id: u32,
|
|
/// `"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<&solitaire_core::card::Card> for CardSnapshot {
|
|
fn from(c: &solitaire_core::card::Card) -> Self {
|
|
Self {
|
|
id: c.id,
|
|
suit: match c.suit {
|
|
Suit::Clubs => "clubs",
|
|
Suit::Diamonds => "diamonds",
|
|
Suit::Hearts => "hearts",
|
|
Suit::Spades => "spades",
|
|
},
|
|
rank: c.rank.value(),
|
|
face_up: c.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<ReplayMove>,
|
|
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}"))?;
|
|
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
|
Ok(Self {
|
|
game,
|
|
moves: replay.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 mv = self.moves[self.step_idx].clone();
|
|
match mv {
|
|
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;
|
|
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] = [
|
|
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(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,
|
|
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
|
|
}
|
|
|
|
/// Returns `true` once every move has been applied.
|
|
pub fn is_finished(&self) -> bool {
|
|
self.step_idx >= self.moves.len()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
pub duplicate_card_ids: Vec<u32>,
|
|
pub missing_card_ids: Vec<u32>,
|
|
pub out_of_range_card_ids: Vec<u32>,
|
|
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: DrawMode,
|
|
pub mode: GameMode,
|
|
pub state: GameSnapshot,
|
|
pub legal_moves: Vec<DebugMove>,
|
|
pub move_history: Vec<SavedInstruction>,
|
|
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()
|
|
.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 = [
|
|
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
|
|
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
|
|
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
];
|
|
let tableaus = [
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
];
|
|
|
|
let mut seen = [false; 52];
|
|
let mut duplicate_card_ids = Vec::new();
|
|
let mut out_of_range_card_ids = Vec::new();
|
|
let mut total_cards_seen = 0_usize;
|
|
|
|
let mut feed = |cards: &[solitaire_core::card::Card]| {
|
|
for card in cards {
|
|
total_cards_seen += 1;
|
|
if card.id >= 52 {
|
|
out_of_range_card_ids.push(card.id);
|
|
continue;
|
|
}
|
|
let idx = card.id as usize;
|
|
if seen[idx] {
|
|
duplicate_card_ids.push(card.id);
|
|
} else {
|
|
seen[idx] = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
feed(&stock);
|
|
feed(&waste);
|
|
for pile in &foundations {
|
|
feed(pile);
|
|
}
|
|
for pile in &tableaus {
|
|
feed(pile);
|
|
}
|
|
|
|
let missing_card_ids = (0_u32..52_u32)
|
|
.filter(|id| !seen[*id as usize])
|
|
.collect::<Vec<_>>();
|
|
|
|
let stock_has_face_up_cards = stock.iter().any(|c| c.face_up);
|
|
let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up);
|
|
let foundation_has_face_down_cards = foundations
|
|
.iter()
|
|
.any(|pile| pile.iter().any(|c| !c.face_up));
|
|
|
|
let tableau_visibility_violation = tableaus.iter().any(|pile| {
|
|
let mut seen_face_up = false;
|
|
for card in pile {
|
|
if card.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_card_ids.is_empty()
|
|
&& missing_card_ids.is_empty()
|
|
&& out_of_range_card_ids.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_card_ids,
|
|
missing_card_ids,
|
|
out_of_range_card_ids,
|
|
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: [
|
|
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
|
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
|
cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
],
|
|
tableaus: [
|
|
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<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}"))?;
|
|
if slot >= 4 {
|
|
return Err(format!("foundation slot out of range: {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()..]
|
|
.parse()
|
|
.map_err(|_| format!("bad pile: {s}"))?;
|
|
if col >= 7 {
|
|
return Err(format!("tableau col out of range: {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}")),
|
|
}
|
|
}
|
|
|
|
fn legal_moves_native(&self) -> Vec<DebugMove> {
|
|
legal_moves_for_game(&self.game)
|
|
}
|
|
|
|
fn move_history_native(&self) -> Vec<SavedInstruction> {
|
|
self.game.instruction_history()
|
|
}
|
|
|
|
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
|
|
let mut replay_game =
|
|
GameState::new_with_mode(self.game.seed, self.game.draw_mode, self.game.mode);
|
|
let mut replay_moves = Vec::new();
|
|
|
|
for instruction in self.game.instruction_history() {
|
|
let replay_move = match instruction {
|
|
SavedInstruction::RotateStock => ReplayMove::StockClick,
|
|
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
|
|
from: dst.src,
|
|
to: SavedKlondikePile::Foundation(dst.foundation),
|
|
count: 1,
|
|
},
|
|
SavedInstruction::DstTableau(dst) => {
|
|
let (from, count) = match dst.src {
|
|
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
|
|
SavedKlondikePileStack::Foundation(foundation) => {
|
|
(SavedKlondikePile::Foundation(foundation), 1)
|
|
}
|
|
SavedKlondikePileStack::Tableau(tableau_stack) => {
|
|
let tableau =
|
|
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|
|
|| {
|
|
format!(
|
|
"invalid tableau index in move history: {}",
|
|
tableau_stack.tableau.0
|
|
)
|
|
},
|
|
)?;
|
|
let face_up_count = replay_game
|
|
.pile(KlondikePile::Tableau(tableau))
|
|
.iter()
|
|
.rev()
|
|
.take_while(|card| card.face_up)
|
|
.count();
|
|
let skip = tableau_stack.skip_cards.0 as usize;
|
|
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
|
|
format!(
|
|
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
|
|
)
|
|
})?;
|
|
if count == 0 {
|
|
return Err(
|
|
"invalid tableau move in move history: zero-card move".into()
|
|
);
|
|
}
|
|
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
|
|
}
|
|
};
|
|
ReplayMove::Move {
|
|
from,
|
|
to: SavedKlondikePile::Tableau(dst.tableau),
|
|
count,
|
|
}
|
|
}
|
|
};
|
|
|
|
match &replay_move {
|
|
ReplayMove::StockClick => replay_game
|
|
.draw()
|
|
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
|
|
ReplayMove::Move { from, to, count } => {
|
|
let src: KlondikePile = (*from)
|
|
.try_into()
|
|
.map_err(|e| format!("invalid replay source pile: {e}"))?;
|
|
let dst: KlondikePile = (*to)
|
|
.try_into()
|
|
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
|
|
replay_game.move_cards(src, dst, *count).map_err(|e| {
|
|
format!(
|
|
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
replay_moves.push(replay_move);
|
|
}
|
|
|
|
Ok(replay_moves)
|
|
}
|
|
|
|
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 {
|
|
DrawMode::DrawThree
|
|
} else {
|
|
DrawMode::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.
|
|
///
|
|
/// This derives move counts from the deterministic instruction history and
|
|
/// validates that the resulting move stream replays cleanly from the current
|
|
/// game's seed/draw mode.
|
|
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
|
|
let moves = self
|
|
.replay_moves_native()
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// 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;
|
|
|
|
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: DrawMode, 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, DrawMode::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 = DrawMode::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 = match game.replay_moves_native() {
|
|
Ok(moves) => moves,
|
|
Err(err) => panic!("replay export failed: {err}"),
|
|
};
|
|
assert!(
|
|
!exported_moves.is_empty(),
|
|
"progressed game must export a non-empty replay move list"
|
|
);
|
|
|
|
let moves_json = match serde_json::to_value(&exported_moves) {
|
|
Ok(value) => value,
|
|
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
|
};
|
|
let array = match moves_json.as_array() {
|
|
Some(values) => values,
|
|
None => panic!("exported replay moves must serialise as a JSON array"),
|
|
};
|
|
assert!(
|
|
array.iter().all(|entry| {
|
|
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
|
|
}),
|
|
"replay move JSON must match ReplayMove wire shape"
|
|
);
|
|
|
|
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
|
|
Ok(parsed) => parsed,
|
|
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"),
|
|
};
|
|
assert_eq!(
|
|
parsed_back, exported_moves,
|
|
"replay move JSON must round-trip through ReplayMove"
|
|
);
|
|
|
|
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
|
Some(date) => date,
|
|
None => panic!("invalid recorded_at date in test"),
|
|
};
|
|
let replay = Replay {
|
|
schema_version: 2,
|
|
seed,
|
|
draw_mode,
|
|
mode: GameMode::Classic,
|
|
time_seconds: 120,
|
|
final_score: game.game.score,
|
|
recorded_at,
|
|
moves: exported_moves,
|
|
};
|
|
let replay_json = match serde_json::to_string(&replay) {
|
|
Ok(json) => json,
|
|
Err(err) => panic!("failed to serialise replay JSON: {err}"),
|
|
};
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn debug_api_autonomous_seed_batch_smoke() {
|
|
for seed in 0_u64..128_u64 {
|
|
let draw_mode = if seed % 2 == 0 {
|
|
DrawMode::DrawOne
|
|
} else {
|
|
DrawMode::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 {
|
|
DrawMode::DrawOne
|
|
} else {
|
|
DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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"
|
|
);
|
|
}
|
|
}
|