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:
@@ -12,22 +12,24 @@
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only the
|
||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||
//! represents the canonical path the player ultimately took to win, so
|
||||
//! backed-out missteps simply do not appear in the move list. The starting
|
||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
||||
//! the upstream `card_game` session serialisation, which stores the dealt
|
||||
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
||||
//! recorded: a replay represents the canonical path the player ultimately
|
||||
//! took to win, so backed-out missteps simply do not appear (the session
|
||||
//! pops them from its history).
|
||||
//!
|
||||
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
|
||||
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
|
||||
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
|
||||
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
|
||||
//! `mode` remain as presentation/indexing metadata only.
|
||||
//!
|
||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||
//! stock state during playback, so the same input always produces the same
|
||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||
//! never serialised — the instruction itself serialises via its compact
|
||||
//! upstream serde representation.
|
||||
//! effect on the same starting deal.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -77,13 +79,19 @@ fn history_schema_v0() -> u32 {
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// are now stored directly as upstream
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||
/// and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// were stored directly as upstream `KlondikeInstruction` (compact
|
||||
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. The starting deal was still
|
||||
/// rebuilt from the seed at playback time.
|
||||
/// - v4 (current): the bare `moves` list was replaced by a
|
||||
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
|
||||
/// `card_game` session serialisation carrying the dealt board explicitly
|
||||
/// plus the instruction list. Playback no longer re-deals from the seed,
|
||||
/// so replays survive RNG/upstream upgrades that change the seed→deal
|
||||
/// mapping (which invalidated every v3 replay). v1–v3 files fail the
|
||||
/// version gate and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
||||
|
||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||
/// [`moves`](Self::moves) in order. The presentation fields
|
||||
/// Replays are reconstructed via
|
||||
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
||||
/// which rebuilds the recorded deal directly and returns the instruction
|
||||
/// list to step through. The presentation fields
|
||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||
/// "Replay (2:14 win on 2026-05-02)".
|
||||
@@ -105,8 +114,9 @@ pub struct Replay {
|
||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Seed used for the deal — replay rasterises the deck via
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
/// Seed the recorded game was originally dealt from. Presentation /
|
||||
/// indexing metadata only — playback rebuilds the board from
|
||||
/// [`recording`](Self::recording), never by re-dealing this seed.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawStockConfig,
|
||||
@@ -119,11 +129,10 @@ pub struct Replay {
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// Ordered move list. Each entry is the atomic
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||
/// issued, replayable against a fresh `GameState` constructed from the
|
||||
/// seed via `GameState::apply_instruction`.
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
/// The dealt board plus the ordered instruction list, serialised via the
|
||||
/// upstream `card_game` session serializers. Self-contained: playback
|
||||
/// needs nothing else to reproduce the game move-for-move.
|
||||
pub recording: SessionRecording,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
@@ -133,11 +142,12 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||
/// the win condition (i.e. completed the last foundation pile).
|
||||
/// Index into the [`recording`](Self::recording)'s instruction list
|
||||
/// of the move that triggered the win condition (i.e. completed the
|
||||
/// last foundation pile).
|
||||
///
|
||||
/// For replays recorded by the live engine this is always
|
||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||
/// `Some(recording.len() - 1)` because recording freezes on win — but
|
||||
/// the field is stored explicitly so the playback UI can read it
|
||||
/// directly without re-deriving "the last move was the win" each
|
||||
/// time, and to leave room for future recording semantics that
|
||||
@@ -172,7 +182,7 @@ impl Replay {
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
recording: SessionRecording,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
@@ -182,7 +192,7 @@ impl Replay {
|
||||
time_seconds,
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
recording,
|
||||
share_url: None,
|
||||
win_move_index: None,
|
||||
}
|
||||
@@ -193,7 +203,7 @@ impl Replay {
|
||||
/// [`Replay::new`]:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
|
||||
/// ```
|
||||
///
|
||||
/// `None` is a valid input — useful for tests that don't care about
|
||||
@@ -430,7 +440,8 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
||||
KlondikePileStack, Tableau,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
@@ -447,18 +458,22 @@ mod tests {
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawThree,
|
||||
[
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -518,25 +533,22 @@ mod tests {
|
||||
/// rolling history wiped on the v0.19.0 update.
|
||||
#[test]
|
||||
fn replay_loads_when_share_url_field_is_absent() {
|
||||
let pre_v019_json = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2025-01-01",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||
// Build a current-schema JSON object, then strip the optional
|
||||
// fields to simulate a file written before they existed.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
let obj = value.as_object_mut().expect("replay serialises as object");
|
||||
obj.remove("share_url");
|
||||
obj.remove("win_move_index");
|
||||
let parsed: Replay = serde_json::from_value(value)
|
||||
.expect("replay JSON without optional fields must still deserialise");
|
||||
assert!(
|
||||
parsed.share_url.is_none(),
|
||||
"missing share_url field must default to None",
|
||||
);
|
||||
assert!(
|
||||
parsed.win_move_index.is_none(),
|
||||
"missing win_move_index field must default to None",
|
||||
);
|
||||
}
|
||||
|
||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||
@@ -588,7 +600,11 @@ mod tests {
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
id as u64,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -824,22 +840,14 @@ mod tests {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal current-schema replay JSON with no
|
||||
// win_move_index field — the additive field must still default to None.
|
||||
let no_field = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
fs::write(&path, no_field).expect("write fixture");
|
||||
// Current-schema replay JSON with the win_move_index field stripped —
|
||||
// the additive field must still default to None.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("replay serialises as object")
|
||||
.remove("win_move_index");
|
||||
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
|
||||
Reference in New Issue
Block a user