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>
This commit is contained in:
funman300
2026-07-10 10:59:33 -07:00
parent c60d465711
commit b2581024f3
4 changed files with 212 additions and 11 deletions
+91
View File
@@ -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 {