Compare commits

..

1 Commits

Author SHA1 Message Date
funman300 b2581024f3 feat(engine): surface upstream move-type counters and replay seek
Test / test (pull_request) Successful in 36m35s
Two unused-for-free upstream card_game/klondike features:

- GameState now exposes the granular KlondikeStats counters
  (move_to_foundation_count, move_to_tableau_count,
  move_from_foundation_count, flip_up_count) and the win modal shows a
  quiet per-move-type recap line built from them (e.g. "21 to
  foundation - 14 tableau moves - 9 flips")
- wasm ReplayPlayer gains seek(step): clamped jump to any position,
  rewinding via a stored copy of the recorded deal instead of reparsing
  the replay JSON; replay.js Prev now uses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:33 -07:00
5 changed files with 259 additions and 167 deletions
+68 -146
View File
@@ -22,17 +22,11 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// indices for enum variants. No longer loadable — v3 files are discarded. /// indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum /// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`). /// variants (e.g. `"Foundation1"` instead of `0`).
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer /// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session /// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that /// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
/// still carry those keys load fine — the extra fields are ignored. /// still carry those keys load fine — the extra fields are ignored.
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
/// `card_game` session serialisation carrying the dealt board explicitly.
/// Loading no longer re-deals from `seed`, so in-progress saves survive
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
/// class that PR #170 fixed for replays). v4/v5 files still load through
/// the legacy seed path and are rewritten as v6 on next save.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
/// Default move budget for a solvability check. Matches the winnable-deal retry /// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine. /// loop in the engine.
@@ -101,8 +95,8 @@ pub enum GameMode {
Difficulty(DifficultyLevel), Difficulty(DifficultyLevel),
} }
/// Output struct for schema v6 serialisation. `recording` is the upstream /// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// session serialisation (config + dealt board + instruction list). /// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PersistedGameState { struct PersistedGameState {
pub draw_mode: DrawStockConfig, pub draw_mode: DrawStockConfig,
@@ -111,16 +105,15 @@ struct PersistedGameState {
pub seed: u64, pub seed: u64,
pub take_from_foundation: bool, pub take_from_foundation: bool,
pub schema_version: u32, pub schema_version: u32,
pub recording: SessionRecording, pub saved_moves: Vec<KlondikeInstruction>,
} }
/// Input struct that accepts schema v4, v5, and v6 save formats. /// Input struct that accepts schema v4 and v5 `saved_moves` formats.
/// ///
/// v6 files carry `recording` (the upstream session serialisation, deal /// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from /// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally /// intentionally absent: all three are rebuilt by replaying the instruction
/// absent: all three are rebuilt by replaying the instruction history /// history through the upstream session stats. Older v4 save files still carry
/// through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them. /// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn { struct PersistedGameStateIn {
@@ -133,12 +126,7 @@ struct PersistedGameStateIn {
pub take_from_foundation: bool, pub take_from_foundation: bool,
#[serde(default = "schema_v1")] #[serde(default = "schema_v1")]
pub schema_version: u32, pub schema_version: u32,
/// v4/v5 only. Replayed against a seed-dealt board.
#[serde(default)]
pub saved_moves: Vec<KlondikeInstruction>, pub saved_moves: Vec<KlondikeInstruction>,
/// v6 only. Carries the dealt board explicitly.
#[serde(default)]
pub recording: Option<SessionRecording>,
} }
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
@@ -234,7 +222,7 @@ impl Serialize for GameState {
seed: self.seed, seed: self.seed,
take_from_foundation: self.take_from_foundation, take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION, schema_version: GAME_STATE_SCHEMA_VERSION,
recording: self.recording(), saved_moves: self.saved_moves(),
} }
.serialize(serializer) .serialize(serializer)
} }
@@ -244,51 +232,34 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?; let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt // Accept v4 (upstream named-variant serde) and v5 (current, derived
// saves — loadable while the seed→deal mapping they were written // stats). v3 (legacy u8-index format) and all others are rejected.
// under still holds; rewritten as v6 on the next save). v3 (legacy match persisted.schema_version {
// u8-index format) and all others are rejected. 4 | 5 => {}
let (mut game, instructions) = match persisted.schema_version {
6 => {
let Some(recording) = persisted.recording else {
return Err(serde::de::Error::custom(
"v6 save file is missing the session recording",
));
};
// The dealt board and session config come from the recording
// itself; `seed` is presentation metadata only.
Self::from_recording(&recording, persisted.seed, persisted.mode)
}
4 | 5 => (
Self {
mode: persisted.mode,
elapsed_seconds: 0,
seed: persisted.seed,
take_from_foundation: true,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
},
persisted.saved_moves,
),
v => { v => {
return Err(serde::de::Error::custom(format!( return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}" "unsupported GameState schema version {v}"
))); )));
} }
}; }
game.mode = persisted.mode;
game.elapsed_seconds = persisted.elapsed_seconds;
game.take_from_foundation = persisted.take_from_foundation;
// Replay the saved instruction history with validation — a tampered let mut game = Self {
// or corrupt file must surface an error, not a silently wrong board. mode: persisted.mode,
// The upstream session tracks score components and recycle_count as elapsed_seconds: persisted.elapsed_seconds,
// it processes each move, so the derived stats are correct once seed: persisted.seed,
// replay completes. `undo_count()` resets to 0 across save/load take_from_foundation: persisted.take_from_foundation,
// because undone moves are not part of the saved forward history. session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
// Replay the saved instruction history. The upstream session tracks
// score components and recycle_count as it processes each move, so the
// derived stats are correct once replay completes. `undo_count()` resets
// to 0 across save/load because undone moves are not part of the saved
// forward history.
let replay_config = Self::replay_config(persisted.draw_mode); let replay_config = Self::replay_config(persisted.draw_mode);
for instruction in instructions { for instruction in persisted.saved_moves {
if !game if !game
.session .session
.state() .state()
@@ -490,6 +461,32 @@ impl GameState {
self.session.stats().stats().recycle_count() self.session.stats().stats().recycle_count()
} }
/// Number of cards moved onto foundations this game, read from the
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
/// not rolled back on undo.
pub fn move_to_foundation_count(&self) -> u32 {
self.session.stats().stats().move_to_foundation_count()
}
/// Number of stacks moved onto tableaus (from stock or another tableau)
/// this game, read from the upstream session stats. Cumulative — not
/// rolled back on undo.
pub fn move_to_tableau_count(&self) -> u32 {
self.session.stats().stats().move_to_tableau_count()
}
/// Number of cards taken back off a foundation this game, read from the
/// upstream session stats. Cumulative — not rolled back on undo.
pub fn move_from_foundation_count(&self) -> u32 {
self.session.stats().stats().move_from_foundation_count()
}
/// Number of face-down cards revealed (flipped up) this game, read from
/// the upstream session stats. Cumulative — not rolled back on undo.
pub fn flip_up_count(&self) -> u32 {
self.session.stats().stats().flip_up_bonus_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived /// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length. /// from the session's instruction history length.
pub fn move_count(&self) -> u32 { pub fn move_count(&self) -> u32 {
@@ -553,6 +550,16 @@ impl GameState {
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation) KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
} }
/// Collects the session instruction history as upstream types for schema v4
/// serialisation.
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Returns the deterministic instruction history for the current deal as /// Returns the deterministic instruction history for the current deal as
/// upstream [`KlondikeInstruction`] values. /// upstream [`KlondikeInstruction`] values.
/// ///
@@ -1716,91 +1723,6 @@ mod tests {
assert_eq!(replayed.move_count(), game.move_count()); assert_eq!(replayed.move_count(), game.move_count());
} }
/// v6 save files carry the deal in the recording; the `seed` field is
/// presentation metadata. Corrupting it must not change the loaded board.
#[test]
fn v6_save_load_ignores_seed_for_dealing() {
let game = game_with_some_moves(51);
let mut value = serde_json::to_value(&game).expect("serialise");
assert_eq!(value["schema_version"], 6);
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
let loaded: GameState =
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.waste_cards(), game.waste_cards());
for index in 0..7 {
let t = GameState::tableau_from_index(index).expect("tableau index");
assert_eq!(
loaded.pile(KlondikePile::Tableau(t)),
game.pile(KlondikePile::Tableau(t)),
"tableau {index} differs"
);
}
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.score(), game.score());
}
/// Legacy v5 files (seed + saved_moves, no recording) must still load
/// through the seed-dealt path until players resave as v6.
#[test]
fn v5_legacy_save_still_loads() {
let game = game_with_some_moves(145);
let v5 = serde_json::json!({
"draw_mode": game.draw_mode(),
"mode": game.mode,
"elapsed_seconds": 12,
"seed": game.seed,
"take_from_foundation": true,
"schema_version": 5,
"saved_moves": game.instruction_history(),
});
let loaded: GameState =
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.elapsed_seconds, 12);
}
/// A v6 file without the recording payload is malformed and must be
/// rejected with an error rather than silently re-dealt from the seed.
#[test]
fn v6_save_without_recording_is_rejected() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let mut value = serde_json::to_value(&game).expect("serialise");
value.as_object_mut().expect("object").remove("recording");
let err = serde_json::from_value::<GameState>(value)
.expect_err("v6 save without recording must fail");
assert!(err.to_string().contains("recording"), "got: {err}");
}
/// A recording whose instruction list is invalid for its own deal must
/// be rejected on load — tampered or corrupt files surface an error,
/// never a silently wrong board.
#[test]
fn v6_save_with_invalid_history_is_rejected() {
use klondike::{DstFoundation, Foundation, KlondikePile};
// A foundation move from an empty tableau is never legal on a fresh
// deal's first instruction slot for this source shape.
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(Foundation::Foundation1),
foundation: Foundation::Foundation2,
});
let recording =
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
let v6 = serde_json::json!({
"draw_mode": DrawStockConfig::DrawOne,
"mode": GameMode::Classic,
"elapsed_seconds": 0,
"seed": 7,
"take_from_foundation": true,
"schema_version": 6,
"recording": recording,
});
let err =
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
assert!(err.to_string().contains("invalid"), "got: {err}");
}
#[test] #[test]
fn from_recording_of_fresh_deal_returns_empty_instructions() { fn from_recording_of_fresh_deal_returns_empty_instructions() {
let game = GameState::new(7, DrawStockConfig::DrawThree); let game = GameState::new(7, DrawStockConfig::DrawThree);
+5 -10
View File
@@ -477,7 +477,7 @@ mod tests {
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets /// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted. /// to 0 on load because only the forward instruction history is persisted.
#[test] #[test]
fn game_state_v6_mid_game_round_trip() { fn game_state_v5_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction; use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -518,16 +518,11 @@ mod tests {
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
// Verify the file carries the v6 schema marker and the recording. // Verify the file carries the v5 schema marker.
let json = fs::read_to_string(&path).expect("read json"); let json = fs::read_to_string(&path).expect("read json");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
assert_eq!(
parsed["schema_version"], 6,
"saved file must use schema version 6",
);
assert!( assert!(
parsed["recording"].is_object(), json.contains("\"schema_version\"") && json.contains('5'),
"saved file must embed the session recording", "saved file must use schema version 5",
); );
let loaded = let loaded =
@@ -535,7 +530,7 @@ mod tests {
// The forward instruction history round-trips, so the reconstructed board // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
let path_reload = gs_path("v6_mid_game_reload"); let path_reload = gs_path("v5_mid_game_reload");
let _ = fs::remove_file(&path_reload); let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded"); save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!( assert_eq!(
+91 -1
View File
@@ -18,7 +18,7 @@
//! shake duration elapses. //! shake duration elapses.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::{GameMode, GameState};
use solitaire_core::scoring::compute_time_bonus; use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
/// score-breakdown reveal can format the mode-multiplier row /// score-breakdown reveal can format the mode-multiplier row
/// (e.g. `Zen ×0.0`, `Classic ×1.0`). /// (e.g. `Zen ×0.0`, `Classic ×1.0`).
pub mode: GameMode, pub mode: GameMode,
/// Per-move-type recap of the winning game, e.g.
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
/// Built from the upstream session counters at win time; empty when
/// every counter is zero (synthesised test wins).
pub move_detail: String,
}
/// Formats the per-move-type recap line for the win modal from the
/// upstream session counters. Zero-valued components are omitted so the
/// line stays short; returns an empty string when nothing was counted
/// (only possible for synthesised test wins).
fn build_move_detail(game: &GameState) -> String {
let mut parts: Vec<String> = Vec::new();
let foundation = game.move_to_foundation_count();
if foundation > 0 {
parts.push(format!("{foundation} to foundation"));
}
let tableau = game.move_to_tableau_count();
if tableau > 0 {
parts.push(format!("{tableau} tableau moves"));
}
let flips = game.flip_up_count();
if flips > 0 {
parts.push(format!("{flips} flips"));
}
let recycles = game.recycle_count();
if recycles > 0 {
parts.push(format!("{recycles} recycles"));
}
let returns = game.move_from_foundation_count();
if returns > 0 {
parts.push(format!("{returns} foundation returns"));
}
parts.join(" \u{00B7} ")
} }
/// Builds a human-readable XP breakdown string for the win modal. /// Builds a human-readable XP breakdown string for the win modal.
@@ -492,6 +526,7 @@ fn cache_win_data(
pending.challenge_level = challenge_level; pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count(); pending.undo_count = game.0.undo_count();
pending.mode = game.0.mode; pending.mode = game.0.mode;
pending.move_detail = build_move_detail(&game.0);
if is_new_record { if is_new_record {
toast.write(InfoToastEvent("New Record!".to_string())); toast.write(InfoToastEvent("New Record!".to_string()));
@@ -913,6 +948,18 @@ fn spawn_overlay(
)); ));
} }
// Move-type recap (same quiet styling as the XP breakdown)
if !pending.move_detail.is_empty() {
card.spawn((
Text::new(pending.move_detail.clone()),
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Achievements unlocked this game — at most 3 shown explicitly; // Achievements unlocked this game — at most 3 shown explicitly;
// excess is summarised with "...and N more". // excess is summarised with "...and N more".
if !session.names.is_empty() { if !session.names.is_empty() {
@@ -1288,6 +1335,49 @@ mod tests {
assert_eq!(p.mode, GameMode::Classic); assert_eq!(p.mode, GameMode::Classic);
} }
#[test]
fn build_move_detail_fresh_game_is_empty() {
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
assert!(
build_move_detail(&game).is_empty(),
"no moves yet, so the recap line must be empty"
);
}
#[test]
fn build_move_detail_reports_played_move_types() {
use solitaire_core::KlondikeInstruction;
// Drive a real deal forward so the upstream counters accumulate:
// prefer foundation moves, then anything else, drawing as needed.
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
for _ in 0..80 {
let instructions = game.possible_instructions();
let next = instructions
.iter()
.copied()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
.or_else(|| instructions.into_iter().next());
match next {
Some(i) => {
let _ = game.apply_instruction(i);
}
None => break,
}
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
break;
}
}
let detail = build_move_detail(&game);
assert!(
detail.contains("to foundation"),
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
);
assert!(
!detail.contains("0 "),
"zero-valued components must be omitted; got: {detail}"
);
}
#[test] #[test]
fn build_xp_detail_slow_win_with_undo() { fn build_xp_detail_slow_win_with_undo() {
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus. // 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
+4 -10
View File
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS); }, STEP_INTERVAL_MS);
}); });
/// Step the player back one move. Re-creates the ReplayPlayer and fast- /// Step the player back one move via the wasm-side seek (rewinds to the
/// forwards to (step_idx - 1) without rendering intermediate frames, then /// recorded deal and fast-forwards internally), then renders once so the
/// renders once so the CSS transition animates each card to its previous /// CSS transition animates each card to its previous position.
/// position.
function stepBack() { function stepBack() {
if (!player || player.step_idx() === 0) return; if (!player || player.step_idx() === 0) return;
if (playInterval) { if (playInterval) {
@@ -326,12 +325,7 @@ function stepBack() {
playInterval = null; playInterval = null;
btnPlay.textContent = "▶ Play"; btnPlay.textContent = "▶ Play";
} }
const target = player.step_idx() - 1; render(player.seek(player.step_idx() - 1));
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
btnPrev.disabled = player.step_idx() === 0; btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0; btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false; btnStep.disabled = false;
+91
View File
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
#[wasm_bindgen] #[wasm_bindgen]
pub struct ReplayPlayer { pub struct ReplayPlayer {
game: GameState, game: GameState,
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
/// can rewind without reparsing the replay JSON.
initial: GameState,
moves: Vec<KlondikeInstruction>, moves: Vec<KlondikeInstruction>,
step_idx: usize, step_idx: usize,
} }
@@ -144,12 +147,30 @@ impl ReplayPlayer {
// the current build maps seeds to deals. // the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode); let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self { Ok(Self {
initial: game.clone(),
game, game,
moves, moves,
step_idx: 0, step_idx: 0,
}) })
} }
/// Jump to `step` (clamped to the move count): the board state after
/// `step` moves have been applied. Rewinds by resetting to the stored
/// initial deal, then fast-forwards — a few hundred instruction
/// applications, microseconds in practice.
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
let target = step.min(self.moves.len());
if target < self.step_idx {
self.game = self.initial.clone();
self.step_idx = 0;
}
while self.step_idx < target {
self.game.apply_instruction(self.moves[self.step_idx])?;
self.step_idx += 1;
}
Ok(self.snapshot())
}
/// Apply the next move. Returns `Ok(None)` once the list is exhausted. /// Apply the next move. Returns `Ok(None)` once the list is exhausted.
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> { pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
if self.step_idx >= self.moves.len() { if self.step_idx >= self.moves.len() {
@@ -236,6 +257,25 @@ impl ReplayPlayer {
} }
} }
/// Jump directly to `step` moves applied (clamped to the move count)
/// and return the snapshot there. Backwards seeks rewind to the
/// recorded deal and fast-forward, so any position is O(replay length)
/// at worst — no JSON reparse, no intermediate renders.
///
/// Throws `"replay_desync"` if a recorded move is illegal during the
/// fast-forward (corrupt recording).
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
match self.seek_native(step) {
Ok(snap) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains. /// Total number of moves the replay contains.
pub fn total_steps(&self) -> usize { pub fn total_steps(&self) -> usize {
self.moves.len() self.moves.len()
@@ -1062,6 +1102,57 @@ mod tests {
assert_eq!(orig["stock"], repl["stock"], "stock deal must match"); assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
} }
/// `seek` must land on exactly the state produced by stepping — both
/// forwards (fast-forward from the current position) and backwards
/// (rewind to the recorded deal, then fast-forward).
#[test]
fn seek_matches_stepping_in_both_directions() {
let mut game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
for _ in 0..24 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
game.apply_legal_move_native(idx).expect("advance game");
}
let replay_json = game
.replay_export_native(60, "2026-07-10")
.expect("export replay");
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
let total = stepped.total_steps();
assert!(total >= 4, "test needs a few moves, got {total}");
// Forward: step A to k, seek B to k, compare snapshots.
let k = total / 2;
for _ in 0..k {
stepped.step_native().expect("step").expect("mid-replay");
}
let sought = seeker.seek_native(k).expect("seek forward");
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
// Backward: seek B to k - 2 and compare against a fresh stepper.
let back = k - 2;
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
for _ in 0..back {
fresh.step_native().expect("step").expect("mid-replay");
}
let sought_back = seeker.seek_native(back).expect("seek backward");
assert_eq!(
sought_back,
fresh.snapshot(),
"backward seek diverged at {back}"
);
// Clamping: past-the-end seeks stop at the final state.
let end = seeker.seek_native(usize::MAX).expect("seek to end");
assert_eq!(end.step_idx, total);
}
#[test] #[test]
fn debug_api_autonomous_seed_batch_smoke() { fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 { for seed in 0_u64..128_u64 {