feat: add action-based game replay system#4633
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a deterministic action-based replay system, introducing a stand-alone replay viewer page, playback controls, and a dedicated ReplayAdapter on the frontend, alongside WASM bridge and engine-level support for recording and reconstructing game states from action logs. The review feedback highlights a critical issue where replay desync errors are silently swallowed in the WASM bridge (replay_seek_js), recommending that these errors be propagated as a Result to the frontend to prevent masking critical reconstruction failures.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| pub fn replay_seek_js(target: u32) -> JsValue { | ||
| REPLAY_PLAYER.with(|cell| { | ||
| let mut player = cell.take(); | ||
| let result = match player.as_mut() { | ||
| Some(player) => match player.seek(target) { | ||
| Ok(state) => to_js(&engine::game::derived_views::ClientGameStateRef::wrap( | ||
| state, | ||
| Some(PlayerId(0)), | ||
| )), | ||
| Err(_) => JsValue::NULL, | ||
| }, | ||
| None => JsValue::NULL, | ||
| }; | ||
| cell.set(player); | ||
| result | ||
| }) | ||
| } |
There was a problem hiding this comment.
[HIGH] Replay desync errors are silently swallowed. Evidence: crates/engine-wasm/src/lib.rs:1386.
Why it matters: Swallowing ReplayError::Desync hides critical reconstruction failures from the user, leading to a confusing UI state instead of an error message. This violates the general rule to not swallow unexpected errors.
Suggested fix: Propagate the error by changing replay_seek_js to return a Result and handle it on the frontend.
This change will also require updating client/src/stores/replayStore.ts to catch the error from adapter.seek() and update the UI accordingly. For example:
// client/src/stores/replayStore.ts
seek: async (index) => {
// ...
try {
const state = await adapter.seek(clamped);
set({ currentIndex: clamped, error: null });
pushStateToGameStore(state);
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
get().pause();
}
},| pub fn replay_seek_js(target: u32) -> JsValue { | |
| REPLAY_PLAYER.with(|cell| { | |
| let mut player = cell.take(); | |
| let result = match player.as_mut() { | |
| Some(player) => match player.seek(target) { | |
| Ok(state) => to_js(&engine::game::derived_views::ClientGameStateRef::wrap( | |
| state, | |
| Some(PlayerId(0)), | |
| )), | |
| Err(_) => JsValue::NULL, | |
| }, | |
| None => JsValue::NULL, | |
| }; | |
| cell.set(player); | |
| result | |
| }) | |
| } | |
| pub fn replay_seek_js(target: u32) -> Result<JsValue, JsValue> { | |
| REPLAY_PLAYER.with(|cell| { | |
| let mut player = cell.take(); | |
| let result = match player.as_mut() { | |
| Some(player) => match player.seek(target) { | |
| Ok(state) => Ok(to_js(&engine::game::derived_views::ClientGameStateRef::wrap( | |
| state, | |
| Some(PlayerId(0)), | |
| ))), | |
| Err(e) => Err(JsValue::from_str(&e.to_string())), | |
| }, | |
| None => Ok(JsValue::NULL), | |
| }; | |
| cell.set(player); | |
| result | |
| }) | |
| } |
References
- Avoid swallowing all errors (e.g., using
.ok()) when only specific errors are expected to be handled or ignored. Propagate unexpected errors (such as invariant violations or invalid actions) to prevent masking critical bugs.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Record resolve_all mutations in exported replays.
The recording only appends actions in submit_action, but resolve_all mutates the engine directly through resolve_all_inner. Evidence: submit_action clones action_for_replay and pushes it into REPLAY_LOG after successful apply, while resolve_all calls resolve_all_inner(state, requester, ...) inside with_state_mut; that path applies actions through apply_action_boundary_with_stack_limit without appending them to the replay log. A normal local game that uses Resolve All can therefore export an action log that omits real state transitions, so playback diverges from the game the user actually played. Please either record every applied action from the resolve-all fast-forward path or invalidate/disable replay export after resolve-all mutates state until those actions are captured.
[HIGH] Propagate replay desync errors instead of returning null.
ReplayPlayer::seek returns a Result because seek failure means the recorded action log could not be reconstructed, but the WASM bridge collapses that error into the same null value used for "no replay loaded." Evidence: replay_seek_js catches every Err(_) from player.seek(target) and returns JsValue::NULL; the worker then sends replay_seek_js(msg.target) ?? null, and ReplayStore.seek accepts that value and pushes it into the game store as the new replay state. That makes a corrupt/desynced replay look like a successful seek to an empty state, hiding the exact class of replay bugs this feature needs to expose. Please preserve the Result boundary here: throw/return an error for ReplayError::Desync and only use null for the explicit "no replay loaded" case if that case still needs to be nullable.
[MED] Fail replay load when deck data exists but the card DB is unavailable.
Replay reconstruction silently skips serialized deck data if no card database is present. Evidence: reconstruct_initial_state only hydrates decks when both header.deck_data and db are Some, load_replay_for_playback passes the TLS card DB through unchecked, and the replay adapter swallows card DB load failure. A replay exported with deck data can therefore load into a different initial state instead of failing loudly. Please make replay load fail when header.deck_data.is_some() and no card DB is available.
Adds deterministic recording, export/import, and a scrubbable Replay Viewer for local/AI games. Reconstruction replays the recorded action sequence against a state rebuilt from the same RNG seed and deck list (no per-turn snapshots needed) — see engine::game::replay. Engine: ReplayHeader/ReplayLog/RecordedAction types, reconstruct_initial_state (reuses the existing load_and_hydrate_decks/start_game building blocks), and ReplayPlayer with sparse checkpoint caching for fast scrubbing. WASM bridge: initialize_game auto-starts a recording, submit_action appends to it, undo/restore invalidates it; new exports for export/load/seek/clear. Frontend: worker + worker-client RPC plumbing, a read-only ReplayAdapter, replayStore driving the existing GameBoard in read-only "spectate" mode (no GamePage/GameProvider changes needed), a Replay Viewer page with scrubber/playback controls, and Export Replay / Watch Replay entry points. Fixes phase-rs#4613 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> # Conflicts: # crates/engine-wasm/src/lib.rs
85b0cae to
43d6ce9
Compare
matthewevans
left a comment
There was a problem hiding this comment.
The current head still has the same replay-integrity blockers from the previous review. The new set_match_config change is fine, but it does not address the blocking paths below.
[HIGH] resolve_all mutations are still not recorded in exported replays.
submit_action is still the only path that appends accepted actions into REPLAY_LOG, while resolve_all still mutates the live state through resolve_all_inner and returns the derived result without recording the applied actions or invalidating the recording. A game that uses Resolve All can still export a replay missing real state transitions.
[HIGH] Replay seek errors are still swallowed as null.
replay_seek_js still maps every Err(_) from ReplayPlayer::seek to JsValue::NULL; the worker still returns replay_seek_js(msg.target) ?? null, and the replay store still pushes that value as the current replay state. A reconstruction desync is therefore still indistinguishable from a successful seek that produced no state.
[MED] Decked replay load still proceeds without a card database.
reconstruct_initial_state still hydrates header.deck_data only when a card DB is present, and ReplayAdapter.initialize still explicitly swallows card DB load failure because deck data is "simply skipped." That means an exported replay with deck data can still load as an empty-library replay instead of failing.
- Record actions applied during a Resolve All burst into the replay recording. resolve_all_fast_forward now returns recorded_actions (every apply()-applied action, plus the fast-forward-equivalent priority passes seed_remaining_priority_cycle_passes seeds directly into state.priority_passes without going through apply()); the WASM bridge's resolve_all appends them to REPLAY_LOG. Without this, a game that used Resolve All would export a replay missing real state transitions. - Propagate ReplayError::Desync instead of collapsing it into the same null used for "no replay loaded". replay_seek_js now returns Result<JsValue, JsValue> and throws on desync; the worker lets it propagate, and replayStore.seek catches it into a visible error state (also stopping playback) instead of silently advancing past a corrupted reconstruction. - Fail replay load loudly when the header carries deck data but no card database is available, instead of silently reconstructing a different (empty-library) starting state. reconstruct_initial_state and ReplayPlayer::load now return Result and error with the new ReplayError::MissingCardDatabase variant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Backend replay-integrity follow-up looks addressed on this head: I am leaving frontend/UI acceptance for Matt direct review under the current sweep policy, so I am not approving or enqueueing this mixed-surface PR from the automation. |
matthewevans
left a comment
There was a problem hiding this comment.
I re-reviewed the current head after the earlier replay-integrity fixes. This is materially better than the tournament PR: the feature has a proposal issue, the implementation is mostly at the right seam, and the engine/WASM design is generally reasonable. The replay model lives in engine types/reconstruction, the WASM bridge owns local recording/export, and the frontend reuses the board through a read-only replay adapter instead of duplicating the game UI.
I am still requesting changes before merge.
[MED] Debug/sandbox direct mutations are not recorded or invalidated, so exported replays can desync.
submit_action clones and records normal actions only after apply succeeds, but DebugAction::CreateCard returns early through handle_debug_create_card before that recording path:
crates/engine-wasm/src/lib.rs:submit_actionbranches tohandle_debug_create_card(...)forDebugAction::CreateCard.crates/engine-wasm/src/lib.rs: theREPLAY_LOG.push_action(...)path only runs after the laterapply(...)call.crates/engine-wasm/src/lib.rs:export_replay_logstill serializes the currentREPLAY_LOGif present.
Why it matters: sandbox/debug games use the same local WASM state, and the Help sheet exposes replay export for local WASM games. If a user debug-spawns or directly mutates state, the live game state changes but the exported action log omits that mutation. Playback then reconstructs a different game from the one the user exported, which breaks the core promise of deterministic replay.
Please either:
- record the debug action in the replay log in a way playback can faithfully re-apply, or
- treat debug/direct mutation as replay-invalidating and clear
REPLAY_LOGthere, the same way restore/undo paths already clear it.
Given this is v1 and debug actions are non-normal gameplay, invalidating the recording is probably the simpler and safer fix.
[LOW] The replay adapter comment is now stale after the missing-card-DB fix.
client/src/adapter/replay-adapter.ts still says reconstruction “degrades gracefully without a card database” and that deck data is “simply skipped.” That was true before the previous review fix, but current engine behavior intentionally fails when a replay has deck data and no card DB is available.
Please update the comment so it matches the actual contract. The current runtime behavior is the right one; the comment is the stale part.
[LOW] Frontend/manual acceptance evidence is still missing.
This PR adds a new user-facing /replay page, a file-import flow, scrub/play/step controls, and a reused GameBoard rendering mode. Static checks passing is useful, but it does not prove the visual/interaction states are acceptable.
Please attach screenshots or a short recording showing at least:
- export replay from a local/AI game,
- load it in
/replay, - scrub and step through several actions,
- play/pause at different speeds,
- desktop and mobile-ish layout, or at least confirmation that mobile was checked,
- error/loading state for an invalid or unloadable replay file.
Also please check off the manual test plan item once done.
CI note
The latest Rust test failure looks like runner/resource pressure rather than a replay assertion failure: the failed shard hit linker bus errors and No space left on device while compiling unrelated test binaries. I would rerun after the changes above, but I am not treating that specific run as evidence that the replay code itself failed a test.
Overall: I do not think this should be closed outright. The architecture is mostly headed in the right direction. I do think the replay log must be made honest for direct mutation paths, and the new UI needs acceptance evidence before this is mergeable.
DebugAction::CreateCard is resolved against the WASM-local CARD_DB and short-circuits before submit_action's generic apply()-then-record path, so it was never appended to REPLAY_LOG. A debug/sandbox card spawn during a recorded game silently desynced the exported replay from the live state. handle_debug_create_card now invalidates REPLAY_LOG on spawn, the same way restore_game_state already does for undo. Split the mutation core into handle_debug_create_card_inner (mirrors the existing estimate_bracket_inner pattern) so the invalidation has a real regression test instead of relying on a manual code read — the wasm_bindgen-facing wrapper's success path calls to_js, which requires a JS runtime and can't be exercised under plain cargo test. Also updates the stale replay-adapter.ts comment: reconstruct_initial_state now fails loudly with MissingCardDatabase when a replay has deck data and no card DB is loaded, rather than silently skipping deck hydration as the old comment described. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parse changes introduced by this PR · 5 card(s), 7 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Resolve All still exports an impossible replay action order. Evidence: crates/engine/src/game/engine_resolve_batch.rs:111 seeds the non-requester priority pass into recorded_actions before the requester's own pass is applied and recorded at crates/engine/src/game/engine_resolve_batch.rs:137; the regression test now expects [(PlayerId(1), PassPriority), (PlayerId(0), PassPriority)] at crates/engine/src/game/engine_resolve_batch.rs:409, but replay applies recorded actions in order through apply at crates/engine/src/game/replay.rs:169. Why it matters: a replay starting from the original priority state cannot legally submit Player 1's pass first; apply rejects actors outside the authorized submitters with WrongPlayer, so Resolve-All-driven exported replays can still desync. Suggested fix: record fast-forward-equivalent priority passes in the same order a normal replay can legally submit them, or represent the seeded batch transition separately instead of replaying an impossible action sequence.
… order resolve_all_fast_forward recorded the fast-forward-seeded passes for other priority-cycle participants before the current actor's own pass, even though the current actor is the one holding priority in the original WaitingFor state. A replay reconstructed from that state can only legally submit the current actor's pass first -- apply() rejects an action from any actor other than the seat currently waiting, so replaying the recorded order desynced with WrongPlayer. seed_remaining_priority_cycle_passes now appends to a caller-local scratch buffer instead of the shared recorded_actions list. The actual state mutation order (seed others, then apply() the current actor last to trigger the full-cycle resolution check) is unchanged -- only the recorded order changes, so the current actor's action is pushed first and the seeded ones are appended after. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Backend/current-head re-review on I am leaving frontend acceptance deferred for Matt rather than approving/enqueueing from the sweep. |
Summary
applyis a pure reducer over a seeded RNG, so replaying the recorded actions reproduces the game exactly)..jsonfile./replay) scrubs/plays back through any point in a loaded recording, reusing the existingGameBoardcomponent in read-only"spectate"mode -- no changes toGamePage/GameProviderneeded.Closes #4613.
Design
crates/engine/src/types/replay.rs,crates/engine/src/game/replay.rs):ReplayHeader/RecordedAction/ReplayLogtypes;reconstruct_initial_statereuses the existingresolve_deck_list+load_and_hydrate_decks+start_gamebuilding blocks already shared by the WASM bridge andserver-core;ReplayPlayerdoes sparse checkpoint-cachedseek(index)so scrubbing doesn't re-simulate the whole game each time (checkpoints are cheap thanks to theim-backed structural-sharingGameState::clone()).crates/engine-wasm/src/lib.rs):initialize_gameauto-starts aReplayLogrecording from the same inputs it already receives;submit_actionappends each successfully-applied action;restore_game_state/resume_multiplayer_host_state/clear_game_stateinvalidate the in-progress recording (undo desyncs it). New exports:has_replay_recording,export_replay_log,load_replay_for_playback,replay_seek_js,replay_length_js,replay_header_js,clear_replay_playback.ReplayAdapter(own dedicated worker, never the live game's);replayStoremirrors the reconstructed state into the sharedgameStorewithgameMode: "spectate"(already disables all action dispatch viadispatchAction/useCanActForWaitingState);ReplayPage+ReplayControls(scrubber, play/pause, step, speed); an "Export Replay" entry in the in-game Help sheet and a "Watch Replay" link on the home dashboard. Full i18n across all 7 locales.Out of scope (v1)
phase-server) game recording -- local/AI (WASM) only.Test plan
cargo test -p engine(replay module: reconstruction matches live play-through at every recorded index, out-of-order seeking, clamping/empty-log edge cases)cargo test -p engine-wasm(bridge wiring: recording survives export/import round-trip, undo invalidates the recording)cargo check --workspace --all-targets/cargo clippy --workspace --all-targets -- -D warningspnpm run type-check/pnpm lint/pnpm test -- --run(1701 tests, including i18n key-parity across all locales)🤖 Generated with Claude Code