//! WebAssembly bindings for browser-side replay playback and interactive gameplay. //! //! The web replay player at `/replays/` 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, } /// 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, pub waste: Vec, /// 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; 4], /// Length 7 — one per tableau column (0..=6). pub tableaus: [Vec; 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, 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 { 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, 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 { self.game.pile(t).iter().map(CardSnapshot::from).collect() }; let foundations: [Vec; 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; 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 { #[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 { 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 { 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, pub waste: Vec, pub foundations: [Vec; 4], pub tableaus: [Vec; 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, #[serde(skip_serializing_if = "Option::is_none")] pub snapshot: Option, } /// 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, pub missing_card_ids: Vec, pub out_of_range_card_ids: Vec, 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, pub move_history: Vec, 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 { let mut moves: Vec = 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::>(); 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 { 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 { 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 { legal_moves_for_game(&self.game) } fn move_history_native(&self) -> Vec { self.game.instruction_history() } fn replay_moves_native(&self) -> Result, 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 { 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 { 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 { serde_json::from_str::(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 { 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 { 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 { 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 { 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::(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 { 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 = 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" ); } }