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
+146 -46
View File
@@ -26,16 +26,21 @@ use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3.
/// Replay schema version this player understands. Mirrors
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
/// other version with a descriptive error instead of desyncing.
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
/// Mirrors `solitaire_data::Replay` v4.
///
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
/// move-currency `solitaire_core` persists. A stock click is
/// `KlondikeInstruction::RotateStock`; a card move is a
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
/// runtime-only and intentionally not part of the wire format.
/// `recording` is the upstream `card_game` session serialisation
/// (`{config, initial_state, instructions}`): the dealt board is stored
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
/// an RNG or upstream upgrade changed the seed→deal mapping.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
#[serde(default)]
@@ -46,7 +51,9 @@ pub struct Replay {
pub time_seconds: u64,
pub final_score: i32,
pub recorded_at: NaiveDate,
pub moves: Vec<KlondikeInstruction>,
pub recording: SessionRecording,
#[serde(default)]
pub win_move_index: Option<usize>,
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -125,10 +132,20 @@ impl ReplayPlayer {
pub fn from_json(replay_json: &str) -> Result<Self, String> {
let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return Err(format!(
"unsupported replay schema_version {} (this player requires {}); \
replays recorded by older clients cannot be replayed",
replay.schema_version, REPLAY_SCHEMA_VERSION
));
}
// The recording carries the dealt board and session config, so the
// rebuilt game is bit-identical to the recorded deal no matter how
// the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self {
game,
moves: replay.moves,
moves,
step_idx: 0,
})
}
@@ -510,6 +527,36 @@ impl SolitaireGame {
self.game.instruction_history()
}
/// Builds the complete schema-v4 replay upload payload for the live
/// game as a JSON string, ready to `POST /api/replays` verbatim.
///
/// The JS layer must not assemble this payload itself: the recording
/// serialises through the upstream `card_game` serializers, and the
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
/// silently corrupts it).
///
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
/// supplies it because the wasm build has no reliable local clock.
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
let recorded_at: NaiveDate = recorded_at
.parse()
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
let recording = self.game.recording();
let win_move_index = recording.len().checked_sub(1);
let replay = Replay {
schema_version: REPLAY_SCHEMA_VERSION,
seed: self.game.seed,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
time_seconds,
final_score: self.game.score(),
recorded_at,
recording,
win_move_index,
};
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
let legal_moves = self.legal_moves_native();
let invariants = invariant_report_for_game(&self.game, &legal_moves);
@@ -694,6 +741,17 @@ impl SolitaireGame {
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Complete schema-v4 replay payload for the live game, as a JSON
/// string ready to `POST /api/replays` verbatim. See
/// [`Self::replay_export_native`] for why JS must not assemble the
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
/// would demand a `BigInt`).
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
self.replay_export_native(u64::from(time_seconds), &recorded_at)
.map_err(|e| JsValue::from_str(&e))
}
/// Returns all currently-legal debug moves as a JS array.
///
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
@@ -897,44 +955,25 @@ mod tests {
"progressed game must export a non-empty replay move list"
);
let moves_json = match serde_json::to_value(&exported_moves) {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
assert!(
moves_json.is_array(),
"exported replay moves must serialise as a JSON array"
);
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed,
Err(err) => {
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
}
};
assert_eq!(
parsed_back, exported_moves,
"replay move JSON must round-trip through KlondikeInstruction"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
Some(date) => date,
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 3,
seed,
draw_mode,
mode: GameMode::Classic,
time_seconds: 120,
final_score: game.game.score(),
recorded_at,
moves: exported_moves,
};
let replay_json = match serde_json::to_string(&replay) {
let replay_json = match game.replay_export_native(120, "2026-06-01") {
Ok(json) => json,
Err(err) => panic!("failed to serialise replay JSON: {err}"),
Err(err) => panic!("failed to export replay JSON: {err}"),
};
let parsed: Replay = match serde_json::from_str(&replay_json) {
Ok(parsed) => parsed,
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
};
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
assert_eq!(
parsed.recording.instructions(),
exported_moves,
"exported recording must carry the exact instruction history"
);
assert_eq!(
parsed.win_move_index,
Some(exported_moves.len() - 1),
"win_move_index must point at the last instruction"
);
let mut player = match ReplayPlayer::from_json(&replay_json) {
Ok(value) => value,
@@ -962,6 +1001,67 @@ mod tests {
);
}
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
/// mechanism that broke when the seed→deal mapping changed. The player
/// must refuse them with a version error, never desync silently.
#[test]
fn replay_player_rejects_pre_v4_schema_versions() {
let v3_json = r#"{
"schema_version": 3,
"seed": 7,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-01",
"recording": null,
"moves": []
}"#;
// v3 files carry `moves`, not `recording`; either way the version
// gate (or the missing field) must produce an error, not a player.
let err = match ReplayPlayer::from_json(v3_json) {
Err(err) => err,
Ok(_) => panic!("v3 replay must be rejected"),
};
assert!(
err.contains("schema_version") || err.contains("invalid replay JSON"),
"error must name the version/format problem, got: {err}"
);
}
/// The whole point of v4: playback rebuilds the deal from the
/// recording, so a replay stays correct even when the top-level
/// `seed` no longer maps to the same deal (RNG upgrades, or a
/// corrupted seed from the old JS `Math.round` path).
#[test]
fn replay_playback_ignores_seed_for_dealing() {
let game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
let replay_json = game
.replay_export_native(60, "2026-06-01")
.expect("export must succeed");
// Corrupt the seed field only — playback must be unaffected.
let mut value: serde_json::Value =
serde_json::from_str(&replay_json).expect("parse exported JSON");
value["seed"] = serde_json::Value::from(0_u64);
let corrupted = serde_json::to_string(&value).expect("reserialise");
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
let replayed_deal =
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
// Compare the board projections (piles), not the GameState wrapper
// (whose serde includes the now-different seed metadata).
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
assert_eq!(
orig["tableaus"], repl["tableaus"],
"tableau deal must come from the recording, not the seed"
);
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {