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:
@@ -277,6 +277,90 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained recording of one deal plus every instruction applied to it.
|
||||
///
|
||||
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
|
||||
/// format is `{config, initial_state, instructions}` — the dealt board is
|
||||
/// stored **explicitly**, so playback never depends on the seed→deal mapping
|
||||
/// staying stable across RNG or upstream-crate upgrades. This is the payload
|
||||
/// replays must persist; a bare seed is only sufficient for the exact build
|
||||
/// that recorded it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRecording(Session<Klondike>);
|
||||
|
||||
impl SessionRecording {
|
||||
/// Builds a recording by dealing a fresh board from `seed` and
|
||||
/// force-applying `instructions` **without validation**.
|
||||
///
|
||||
/// Fixture/test aid only — production recordings come from
|
||||
/// [`GameState::recording`], whose history is valid by construction.
|
||||
/// Invalid instructions are absorbed by the upstream session's
|
||||
/// `Option`-based pile pops rather than rejected, so a recording built
|
||||
/// here may not replay cleanly through
|
||||
/// [`GameState::apply_instruction`]'s validation.
|
||||
pub fn from_instructions_unchecked(
|
||||
seed: u64,
|
||||
draw_mode: DrawStockConfig,
|
||||
instructions: impl IntoIterator<Item = KlondikeInstruction>,
|
||||
) -> Self {
|
||||
let mut session = GameState::new_session(seed, draw_mode);
|
||||
for instruction in instructions {
|
||||
session.process_instruction(instruction);
|
||||
}
|
||||
Self(session)
|
||||
}
|
||||
|
||||
/// The dealt board the recording starts from (before any instruction).
|
||||
fn initial_state(&self) -> &Klondike {
|
||||
self.0
|
||||
.history()
|
||||
.first()
|
||||
.map(|snapshot| snapshot.state())
|
||||
.unwrap_or_else(|| self.0.state().state())
|
||||
}
|
||||
|
||||
/// Ordered instruction list, replayable via
|
||||
/// [`GameState::apply_instruction`] against the game returned by
|
||||
/// [`GameState::from_recording`].
|
||||
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
|
||||
self.0
|
||||
.history()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of recorded instructions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.history().len()
|
||||
}
|
||||
|
||||
/// `true` when no instructions have been recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.history().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SessionRecording {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for SessionRecording {}
|
||||
|
||||
impl Serialize for SessionRecording {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SessionRecording {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Session::deserialize(deserializer).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||
@@ -296,6 +380,44 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the live session for replay persistence: the dealt board
|
||||
/// plus the forward instruction history (undone moves are absent — the
|
||||
/// session pops them). Serialise the returned [`SessionRecording`] with
|
||||
/// the upstream `card_game` serializers; rebuild playback with
|
||||
/// [`Self::from_recording`].
|
||||
pub fn recording(&self) -> SessionRecording {
|
||||
SessionRecording(self.session.clone())
|
||||
}
|
||||
|
||||
/// Rebuilds the initial-deal game plus the ordered instruction list from
|
||||
/// a [`SessionRecording`].
|
||||
///
|
||||
/// The board and the session config (including draw mode) come from the
|
||||
/// recording itself, so playback is independent of the current build's
|
||||
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
|
||||
/// alongside the recording by the replay file. Step through the returned
|
||||
/// instructions with [`Self::apply_instruction`], which re-validates each
|
||||
/// one and fails gracefully on corrupt input.
|
||||
pub fn from_recording(
|
||||
recording: &SessionRecording,
|
||||
seed: u64,
|
||||
mode: GameMode,
|
||||
) -> (Self, Vec<KlondikeInstruction>) {
|
||||
let game = Self {
|
||||
mode,
|
||||
elapsed_seconds: 0,
|
||||
seed,
|
||||
take_from_foundation: true,
|
||||
session: Session::new(
|
||||
recording.initial_state().clone(),
|
||||
recording.0.config().clone(),
|
||||
),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
};
|
||||
(game, recording.instructions())
|
||||
}
|
||||
|
||||
/// Whether the player draws one or three cards from the stock per turn.
|
||||
/// Derived from the underlying session config (set once at deal time).
|
||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||
@@ -1512,4 +1634,77 @@ mod tests {
|
||||
assert!(easy.is_err());
|
||||
assert!(matches!(medium, Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// Play a few real moves on a fresh deal and return the game.
|
||||
fn game_with_some_moves(seed: u64) -> GameState {
|
||||
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||
for _ in 0..40 {
|
||||
let instructions = game.possible_instructions();
|
||||
let applied = instructions
|
||||
.into_iter()
|
||||
.find(|i| game.clone().apply_instruction(*i).is_ok())
|
||||
.and_then(|i| game.apply_instruction(i).ok());
|
||||
if applied.is_none() && game.draw().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!game.instruction_history().is_empty(),
|
||||
"test needs at least one recorded move"
|
||||
);
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_round_trips_through_upstream_serde() {
|
||||
let game = game_with_some_moves(51);
|
||||
let recording = game.recording();
|
||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||
assert_eq!(recording, restored);
|
||||
assert_eq!(recording.instructions(), game.instruction_history());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_recording_replays_to_identical_board_without_seed_dealing() {
|
||||
let game = game_with_some_moves(145);
|
||||
let recording = game.recording();
|
||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||
|
||||
// Deliberately pass a DIFFERENT seed: the board must come from the
|
||||
// recording, proving playback no longer depends on seed→deal mapping.
|
||||
let (mut replayed, instructions) =
|
||||
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
|
||||
assert_eq!(replayed.draw_mode(), game.draw_mode());
|
||||
for instruction in instructions {
|
||||
replayed
|
||||
.apply_instruction(instruction)
|
||||
.expect("recorded instruction must replay cleanly");
|
||||
}
|
||||
for pile in [KlondikePile::Stock]
|
||||
.into_iter()
|
||||
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
|
||||
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
|
||||
{
|
||||
assert_eq!(
|
||||
replayed.pile(pile),
|
||||
game.pile(pile),
|
||||
"pile {pile:?} differs"
|
||||
);
|
||||
}
|
||||
assert_eq!(replayed.waste_cards(), game.waste_cards());
|
||||
assert_eq!(replayed.move_count(), game.move_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let recording = game.recording();
|
||||
assert!(recording.is_empty());
|
||||
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
|
||||
assert!(instructions.is_empty());
|
||||
assert_eq!(replayed.stock_cards(), game.stock_cards());
|
||||
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ pub use klondike::{
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
pub use game_state::{
|
||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, SolveOutcome,
|
||||
};
|
||||
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
|
||||
Reference in New Issue
Block a user