Files
Ferrous-Solitaire/solitaire_wasm/src/lib.rs
T
funman300 da601bebd6
Build and Deploy / build-and-push (push) Successful in 4m24s
fix(engine,wasm,web): detect no-legal-moves correctly and surface banner
Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.

WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.

Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:54:01 -07:00

727 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
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: PileType,
to: PileType,
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)]
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)]
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,
}
// 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 `None` once the list is exhausted.
pub fn step_native(&mut self) -> Option<StateSnapshot> {
if self.step_idx >= self.moves.len() {
return None;
}
let mv = self.moves[self.step_idx].clone();
let _ = match mv {
ReplayMove::Move { from, to, count } => self.game.move_cards(from, to, count),
ReplayMove::StockClick => self.game.draw(),
};
self.step_idx += 1;
Some(self.snapshot())
}
fn snapshot(&self) -> StateSnapshot {
let pile_cards = |t: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let foundations: [Vec<CardSnapshot>; 4] = [
pile_cards(PileType::Foundation(0)),
pile_cards(PileType::Foundation(1)),
pile_cards(PileType::Foundation(2)),
pile_cards(PileType::Foundation(3)),
];
let tableaus: [Vec<CardSnapshot>; 7] = [
pile_cards(PileType::Tableau(0)),
pile_cards(PileType::Tableau(1)),
pile_cards(PileType::Tableau(2)),
pile_cards(PileType::Tableau(3)),
pile_cards(PileType::Tableau(4)),
pile_cards(PileType::Tableau(5)),
pile_cards(PileType::Tableau(6)),
];
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: pile_cards(PileType::Stock),
waste: pile_cards(PileType::Waste),
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 a JS string exception on serialisation failure.
pub fn step(&mut self) -> Result<JsValue, JsValue> {
match self.step_native() {
Some(snap) => serde_wasm_bindgen::to_value(&snap)
.map_err(|e| JsValue::from_str(&e.to_string())),
None => Ok(JsValue::NULL),
}
}
/// 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)]
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>,
}
/// 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: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let has_moves = {
let stock_empty = self.game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = self.game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
!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: cards(PileType::Stock),
waste: cards(PileType::Waste),
foundations: [
cards(PileType::Foundation(0)),
cards(PileType::Foundation(1)),
cards(PileType::Foundation(2)),
cards(PileType::Foundation(3)),
],
tableaus: [
cards(PileType::Tableau(0)),
cards(PileType::Tableau(1)),
cards(PileType::Tableau(2)),
cards(PileType::Tableau(3)),
cards(PileType::Tableau(4)),
cards(PileType::Tableau(5)),
cards(PileType::Tableau(6)),
],
}
}
fn pile_from_str(s: &str) -> Result<PileType, String> {
match s {
"stock" => Ok(PileType::Stock),
"waste" => Ok(PileType::Waste),
_ 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(PileType::Foundation(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(PileType::Tableau(col))
}
_ => Err(format!("unknown pile: {s}")),
}
}
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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_replay_json() -> String {
// Minimal v2 replay: seed 42, two stock clicks. Real winning
// replays will have many more moves; for the test we just
// verify deserialization + step() advances correctly.
r#"{
"schema_version": 2,
"seed": 42,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": ["StockClick", "StockClick"]
}"#
.to_string()
}
/// Constructing from a valid v2 replay JSON must succeed and
/// initialise step_idx to 0.
#[test]
fn new_initialises_step_idx_zero() {
let player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert_eq!(player.step_idx, 0);
assert_eq!(player.moves.len(), 2);
}
/// Each step advances the index; once exhausted, step_native returns None.
#[test]
fn steps_advance_then_terminate() {
let mut player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert!(player.step_native().is_some());
assert_eq!(player.step_idx, 1);
assert!(player.step_native().is_some());
assert_eq!(player.step_idx, 2);
assert!(player.step_native().is_none(), "no further steps");
}
/// Malformed JSON returns an error rather than panicking.
#[test]
fn invalid_json_returns_error() {
let result = ReplayPlayer::from_json("not valid json");
assert!(result.is_err());
}
// -------------------------------------------------------------------------
// Winning-sequence step-through
// -------------------------------------------------------------------------
/// Greedy Klondike solver for DrawOne Classic.
///
/// Returns a `ReplayMove` list that wins the game from `seed`, or `None`
/// when the greedy heuristic gets stuck within the move budget.
///
/// Priority order (highest first):
/// 1. Waste → Foundation
/// 2. Tableau top → Foundation
/// 3. Tableau stack → Tableau, only if the move uncovers a face-down card
/// 4. Waste → Tableau
/// 5. Draw from stock (recycle is automatic inside `GameState::draw`)
fn greedy_solve(seed: u64) -> Option<Vec<ReplayMove>> {
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
let mut game = GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic);
let mut moves: Vec<ReplayMove> = Vec::new();
const MAX_MOVES: usize = 10_000;
'outer: loop {
if game.is_won {
return Some(moves);
}
if moves.len() >= MAX_MOVES {
return None;
}
// Auto-complete: drive to win without further player input.
if game.is_auto_completable {
while let Some((from, to)) = game.next_auto_complete_move() {
if game.move_cards(from.clone(), to.clone(), 1).is_err() {
return None;
}
moves.push(ReplayMove::Move { from, to, count: 1 });
}
return if game.is_won { Some(moves) } else { None };
}
// P1: Waste → Foundation.
for slot in 0..4_u8 {
if game
.move_cards(PileType::Waste, PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
// P2: Tableau top → Foundation.
for i in 0..7_usize {
for slot in 0..4_u8 {
if game
.move_cards(PileType::Tableau(i), PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
}
// P3: Tableau stack → Tableau only when it uncovers a face-down card.
let mut made_move = false;
'p3: for i in 0..7_usize {
let pile_len = game.piles[&PileType::Tableau(i)].cards.len();
for count in 1..=pile_len {
let start = pile_len - count;
// Only worth moving if a face-down card sits just below.
let would_uncover =
start > 0 && !game.piles[&PileType::Tableau(i)].cards[start - 1].face_up;
if !would_uncover {
continue;
}
for j in 0..7_usize {
if i == j {
continue;
}
if game
.move_cards(PileType::Tableau(i), PileType::Tableau(j), count)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Tableau(j),
count,
});
made_move = true;
break 'p3;
}
}
}
}
if made_move {
continue 'outer;
}
// P4: Waste → Tableau.
for j in 0..7_usize {
if game
.move_cards(PileType::Waste, PileType::Tableau(j), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(j),
count: 1,
});
continue 'outer;
}
}
// P5: Draw from stock (handles recycle automatically).
if game.draw().is_ok() {
moves.push(ReplayMove::StockClick);
continue 'outer;
}
// No moves available — greedy solver is stuck on this seed.
return None;
}
}
/// Full end-to-end winning-sequence regression test.
///
/// 1. Runs the greedy solver on seeds 1200 to find the first
/// deterministically winnable game.
/// 2. Serialises the winning move list as a `Replay` JSON string.
/// 3. Feeds the JSON to `ReplayPlayer::from_json`.
/// 4. Steps through every move via `step_native` and asserts `is_won`
/// on the final snapshot.
///
/// Regression target: a `GameState` or `ReplayMove` change that breaks
/// an historically valid move sequence will cause `is_won` to be `false`
/// at the end of the replay, failing this test before any release.
#[test]
fn replay_player_completes_full_winning_sequence() {
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
let (seed, winning_moves) = (1_u64..=200)
.find_map(|s| greedy_solve(s).map(|m| (s, m)))
.expect("at least one seed in 1..=200 must be solvable by the greedy strategy");
let replay = Replay {
schema_version: 2,
seed,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 300,
final_score: 0,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 12)
.expect("2026-05-12 is a valid date"),
moves: winning_moves.clone(),
};
let json = serde_json::to_string(&replay).expect("replay serialises to JSON cleanly");
let mut player =
ReplayPlayer::from_json(&json).expect("solver-generated replay JSON must be valid");
assert_eq!(player.step_idx, 0, "player must start at step 0");
assert_eq!(
player.moves.len(),
winning_moves.len(),
"player must hold the complete move list"
);
let mut last_snap: Option<StateSnapshot> = None;
while let Some(snap) = player.step_native() {
last_snap = Some(snap);
}
let snap = last_snap.expect("winning sequence must contain at least one move");
assert!(
snap.is_won,
"seed {seed}: final snapshot after full replay must have is_won = true \
({} moves applied)",
winning_moves.len()
);
assert_eq!(
snap.step_idx,
winning_moves.len(),
"step_idx after the last move must equal the total move count"
);
assert!(
player.step_native().is_none(),
"step_native must return None once all moves are exhausted"
);
}
}