fix(wasm,stats): surface replay errors to JS, deduplicate win events per frame (#65, #69)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-27 21:53:15 -07:00
parent dbe728fef7
commit 8cb4c9808e
4 changed files with 260 additions and 121 deletions
+97 -26
View File
@@ -21,6 +21,7 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::error::MoveError;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
use wasm_bindgen::prelude::*;
@@ -108,6 +109,14 @@ pub struct ReplayPlayer {
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).
@@ -118,8 +127,7 @@ impl ReplayPlayer {
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);
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
Ok(Self {
game,
moves: replay.moves,
@@ -127,18 +135,18 @@ impl ReplayPlayer {
})
}
/// Apply the next move. Returns `None` once the list is exhausted.
pub fn step_native(&mut self) -> Option<StateSnapshot> {
/// 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 None;
return Ok(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(),
};
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())
Ok(Some(self.snapshot()))
}
fn snapshot(&self) -> StateSnapshot {
@@ -205,12 +213,19 @@ impl ReplayPlayer {
/// 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() {
Some(snap) => serde_wasm_bindgen::to_value(&snap)
.map_err(|e| JsValue::from_str(&e.to_string())),
None => Ok(JsValue::NULL),
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"))
}
}
}
@@ -282,8 +297,16 @@ impl SolitaireGame {
.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());
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 {
@@ -383,8 +406,7 @@ impl SolitaireGame {
///
/// 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()))
serde_wasm_bindgen::to_value(&self.snap()).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// The seed used to deal this game.
@@ -435,8 +457,7 @@ impl SolitaireGame {
/// 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()))
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`].
@@ -509,11 +530,27 @@ mod tests {
#[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!(
player
.step_native()
.expect("first move should apply")
.is_some()
);
assert_eq!(player.step_idx, 1);
assert!(player.step_native().is_some());
assert!(
player
.step_native()
.expect("second move should apply")
.is_some()
);
assert_eq!(player.step_idx, 2);
assert!(player.step_native().is_none(), "no further steps");
assert!(
player
.step_native()
.expect("replay should be exhausted")
.is_none(),
"no further steps"
);
}
/// Malformed JSON returns an error rather than panicking.
@@ -523,6 +560,35 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn invalid_replay_move_returns_error_without_advancing() {
let replay = Replay {
schema_version: 2,
seed: 42,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 60,
final_score: 100,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
moves: vec![ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(0),
count: 1,
}],
};
let json = serde_json::to_string(&replay).expect("replay serialises");
let mut player = ReplayPlayer::from_json(&json).expect("valid JSON");
let err = player
.step_native()
.expect_err("illegal replay move must surface an error");
assert_eq!(err, MoveError::EmptySource);
assert_eq!(
player.step_idx, 0,
"desync must not advance the replay cursor"
);
}
// -------------------------------------------------------------------------
// Winning-sequence step-through
// -------------------------------------------------------------------------
@@ -686,8 +752,7 @@ mod tests {
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"),
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");
@@ -702,7 +767,10 @@ mod tests {
);
let mut last_snap: Option<StateSnapshot> = None;
while let Some(snap) = player.step_native() {
while let Some(snap) = player
.step_native()
.expect("solver-generated replay must stay in sync")
{
last_snap = Some(snap);
}
@@ -719,7 +787,10 @@ mod tests {
"step_idx after the last move must equal the total move count"
);
assert!(
player.step_native().is_none(),
player
.step_native()
.expect("winning replay should still be exhausted")
.is_none(),
"step_native must return None once all moves are exhausted"
);
}