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:
@@ -1,11 +1,12 @@
|
||||
//! In-engine replay playback core.
|
||||
//!
|
||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||
//! plays back identically to a live game.
|
||||
//! game state is reset to the recorded deal — rebuilt directly from
|
||||
//! `replay.recording` via `GameState::from_recording`, never by
|
||||
//! re-dealing the seed — and the engine ticks through the recording's
|
||||
//! instruction list at a steady cadence, firing the canonical
|
||||
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
||||
//! existing animation pipeline plays back identically to a live game.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
@@ -95,7 +96,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`KlondikeInstruction`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// 4. When `cursor == moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
/// [`auto_clear_completed_replay`]) before returning to
|
||||
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.moves.len()` for the denominator of the progress
|
||||
/// `moves.len()` for the denominator of the progress
|
||||
/// indicator and `cursor` for the numerator.
|
||||
Playing {
|
||||
/// The replay being played back. Owned so the state is the
|
||||
/// only place playback metadata lives — no separate resource
|
||||
/// needed.
|
||||
replay: Replay,
|
||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||
/// needed. Boxed: the v4 recording embeds the dealt board and
|
||||
/// would otherwise dwarf the other variants
|
||||
/// (`clippy::large_enum_variant`).
|
||||
replay: Box<Replay>,
|
||||
/// Instruction list extracted once from `replay.recording` at
|
||||
/// [`start_replay_playback`] time, so the per-frame tick indexes
|
||||
/// a plain slice instead of re-walking the recording.
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
/// Index of the next move to apply, in `[0, moves.len()]`.
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
|
||||
}
|
||||
|
||||
impl ReplayPlaybackState {
|
||||
/// Construct a [`Self::Playing`] state, extracting the instruction
|
||||
/// list from `replay.recording` once up front.
|
||||
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
|
||||
let moves = replay.recording.instructions();
|
||||
Self::Playing {
|
||||
replay: Box::new(replay),
|
||||
moves,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when a replay is currently being played back.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
|
||||
/// the total is no longer available in `Completed`.
|
||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
||||
Self::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
// The recording carries the dealt board and the session config
|
||||
// (including draw mode), so the fresh game is bit-identical to the
|
||||
// recorded deal regardless of how the current build maps seeds to
|
||||
// deals — `replay.seed` is presentation metadata only.
|
||||
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
replay: Box::new(replay),
|
||||
moves,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
paused: false,
|
||||
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) -> bool {
|
||||
let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
paused: true,
|
||||
..
|
||||
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
return false;
|
||||
}
|
||||
let instruction = replay.moves[*cursor];
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||
*cursor += 1;
|
||||
true
|
||||
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
|
||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||
/// expires, fires the canonical event for the move at `cursor`,
|
||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||
/// reaches `replay.moves.len()`, transitions to
|
||||
/// reaches `moves.len()`, transitions to
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
@@ -384,10 +409,11 @@ fn tick_replay_playback(
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
..
|
||||
} = state.as_mut()
|
||||
{
|
||||
// While paused, the cursor and the timer freeze together —
|
||||
@@ -397,8 +423,8 @@ fn tick_replay_playback(
|
||||
// path.
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
let instruction = replay.moves[*cursor];
|
||||
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(
|
||||
instruction,
|
||||
*cursor,
|
||||
@@ -410,7 +436,7 @@ fn tick_replay_playback(
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
@@ -562,8 +588,7 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -612,11 +637,11 @@ mod tests {
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 3],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -673,11 +698,14 @@ mod tests {
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor, replay: r, ..
|
||||
cursor,
|
||||
replay: r,
|
||||
moves,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
assert_eq!(moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
@@ -778,7 +806,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
@@ -887,7 +919,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock; 10],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
7,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 10],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user