feat(engine): surface upstream move-type counters and replay seek
Test / test (pull_request) Successful in 36m35s
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>
This commit is contained in:
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
||||
#[wasm_bindgen]
|
||||
pub struct ReplayPlayer {
|
||||
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>,
|
||||
step_idx: usize,
|
||||
}
|
||||
@@ -144,12 +147,30 @@ impl ReplayPlayer {
|
||||
// the current build maps seeds to deals.
|
||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
Ok(Self {
|
||||
initial: game.clone(),
|
||||
game,
|
||||
moves,
|
||||
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.
|
||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||
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.
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.moves.len()
|
||||
@@ -1062,6 +1102,57 @@ mod tests {
|
||||
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]
|
||||
fn debug_api_autonomous_seed_batch_smoke() {
|
||||
for seed in 0_u64..128_u64 {
|
||||
|
||||
Reference in New Issue
Block a user