Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,7 @@ console_error_panic_hook = { version = "0.1", optional = true }
|
||||
# `solitaire_core`'s deps with wasm-only flags.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
+97
-26
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user