fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s

Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-10 09:10:38 -07:00
parent fce0266b47
commit 4cb4212829
20 changed files with 753 additions and 616 deletions
+15 -18
View File
@@ -445,33 +445,30 @@ function showWin(s) {
submitReplay(s);
}
function buildReplayPayload(s) {
if (!game || !s) return null;
let moves;
function buildReplayPayload() {
if (!game) return null;
// The wasm side assembles the entire schema-v4 payload (including the
// session recording and the u64 seed, which JS numbers can't hold);
// JS only supplies the wall-clock time and today's date.
try {
moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
const json = game.replay_export(
Math.max(1, elapsedSecs),
new Date().toISOString().slice(0, 10),
);
const payload = JSON.parse(json);
if (!Array.isArray(payload?.recording?.instructions)
|| payload.recording.instructions.length === 0) return null;
return payload;
} catch (e) {
console.warn("fs: replay export failed", e);
return null;
}
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: Math.max(1, elapsedSecs),
final_score: s.score,
recorded_at: new Date().toISOString().slice(0, 10),
moves,
win_move_index: moves.length - 1,
};
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token || !game) return;
const payload = buildReplayPayload(s);
const payload = buildReplayPayload();
if (!payload) return;
try {
await fetch('/api/replays', {
@@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = {
},
replayPayload() {
if (!game) return null;
return buildReplayPayload(snap ?? game.state());
return buildReplayPayload();
},
runAutoplay(options) {
return runDebugAutoplay(options);
+10 -9
View File
@@ -159,16 +159,17 @@
function buildReplayPayload() {
if (!game) return null;
// Schema v4: the wasm side assembles the full payload,
// including the session recording and the u64 seed.
try {
const moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
mode: "Classic",
moves,
};
const json = game.replay_export(
1,
new Date().toISOString().slice(0, 10),
);
const payload = JSON.parse(json);
if (!Array.isArray(payload?.recording?.instructions)
|| payload.recording.instructions.length === 0) return null;
return payload;
} catch { return null; }
}
+12 -1
View File
@@ -121,7 +121,18 @@ function resetPlayer() {
playInterval = null;
btnPlay.textContent = "▶ Play";
}
player = new ReplayPlayer(replayJson);
// Old replays (schema < 4) are rejected by the wasm player with a
// descriptive error — surface it instead of leaving a dead board.
try {
player = new ReplayPlayer(replayJson);
} catch (e) {
captionEl.textContent = `Cannot play this replay: ${e}`;
btnStep.disabled = true;
btnPlay.disabled = true;
btnPrev.disabled = true;
btnRestart.disabled = true;
return;
}
btnPrev.disabled = true;
btnRestart.disabled = true;
btnStep.disabled = false;