fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s
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:
+146
-46
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user