diff --git a/Cargo.lock b/Cargo.lock index c66cfd3..d0085ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7333,6 +7333,7 @@ dependencies = [ "proptest", "rand 0.10.1", "serde", + "serde_json", "thiserror 2.0.18", ] diff --git a/solitaire_core/Cargo.toml b/solitaire_core/Cargo.toml index 0ab1be2..7caef6f 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -9,7 +9,8 @@ default = [] test-support = [] [dev-dependencies] -proptest = "1" +proptest = "1" +serde_json = { workspace = true } [dependencies] serde = { workspace = true } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 48c322d..705f0eb 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -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); + +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, + ) -> 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 { + 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(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SessionRecording { + fn deserialize>(deserializer: D) -> Result { + 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) { + 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); + } } diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 8000091..1d53700 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -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). diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 4dad2d7..606607a 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -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, + /// 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, - /// 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, + 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); diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 0ef4471..9743e02 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -1343,7 +1343,9 @@ mod tests { use crate::replay_playback::ReplayPlaybackState; use chrono::NaiveDate; - use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode}; + use solitaire_core::{ + DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode, + }; use solitaire_data::Replay; /// Headless app variant that injects a default `ReplayPlaybackState` @@ -1364,7 +1366,11 @@ mod tests { 10, 100, NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), - vec![KlondikeInstruction::RotateStock], + SessionRecording::from_instructions_unchecked( + 1, + DrawStockConfig::DrawOne, + [KlondikeInstruction::RotateStock], + ), ) } @@ -1408,12 +1414,8 @@ mod tests { // Frame 1: enter Playing. The observer's first sample sees // `last_was_playing = false` and `now_playing = true`. - *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = + ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); app.update(); assert!( !cinephile_unlocked(&app), @@ -1442,12 +1444,8 @@ mod tests { fn cinephile_does_not_unlock_on_stop_button_abort() { let mut app = cinephile_app(); - *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = + ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); app.update(); // Direct Playing → Inactive — the path the Stop button takes via @@ -1473,12 +1471,8 @@ mod tests { let mut app = cinephile_app(); // First completion cycle to unlock. - *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = + ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); app.update(); *app.world_mut().resource_mut::() = ReplayPlaybackState::Completed; app.update(); @@ -1496,12 +1490,8 @@ mod tests { // Second cycle: Inactive → Playing → Completed once more. *app.world_mut().resource_mut::() = ReplayPlaybackState::Inactive; app.update(); - *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = + ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); app.update(); *app.world_mut().resource_mut::() = ReplayPlaybackState::Completed; app.update(); @@ -1520,12 +1510,8 @@ mod tests { fn cinephile_fires_once_across_completed_linger() { let mut app = cinephile_app(); - *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = + ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); app.update(); *app.world_mut().resource_mut::() = ReplayPlaybackState::Completed; app.update(); diff --git a/solitaire_engine/src/game_plugin/mod.rs b/solitaire_engine/src/game_plugin/mod.rs index 249e010..b9579db 100644 --- a/solitaire_engine/src/game_plugin/mod.rs +++ b/solitaire_engine/src/game_plugin/mod.rs @@ -1062,11 +1062,16 @@ pub fn record_replay_on_win( if recording.moves.is_empty() { continue; } + // The session itself is the authoritative recording: its history + // holds the dealt board plus the forward instruction list (undos + // already popped), and it serialises via the upstream card_game + // serializers so playback never re-deals from the seed. + let session_recording = game.0.recording(); // Recording freezes on win, so the move that triggered the // win condition is the last one in the list. Storing the // index explicitly lets the playback UI read the WIN MOVE // position directly instead of re-deriving it on every render. - let win_move_index = recording.moves.len().checked_sub(1); + let win_move_index = session_recording.len().checked_sub(1); let replay = Replay::new( game.0.seed, game.0.draw_mode(), @@ -1074,7 +1079,7 @@ pub fn record_replay_on_win( ev.time_seconds, ev.score, Utc::now().date_naive(), - recording.moves.clone(), + session_recording, ) .with_win_move_index(win_move_index); let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else { diff --git a/solitaire_engine/src/game_plugin/tests.rs b/solitaire_engine/src/game_plugin/tests.rs index 0e88099..bf4af0c 100644 --- a/solitaire_engine/src/game_plugin/tests.rs +++ b/solitaire_engine/src/game_plugin/tests.rs @@ -853,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() { let mut app = test_app(7654); app.insert_resource(ReplayPath(Some(path.clone()))); - // Push two recorded instructions manually so we can verify they - // survive the freeze/save round-trip without having to drive a - // real win. Both are `RotateStock` — the only instruction - // constructible without the runtime-only `klondike` pile-stack - // types (which the engine intentionally does not depend on); the - // round-trip shape is identical for any instruction variant. - { - let mut recording = app.world_mut().resource_mut::(); - recording.moves.push(KlondikeInstruction::RotateStock); - recording.moves.push(KlondikeInstruction::RotateStock); - } + // Drive two real draws so the *session* history (the source the + // freeze now serialises from, via `GameState::recording()`) holds + // two instructions. `RotateStock` is the only instruction the + // engine can drive without the runtime-only `klondike` pile-stack + // types; the round-trip shape is identical for any variant. + app.world_mut().write_message(DrawRequestEvent); + app.update(); + app.world_mut().write_message(DrawRequestEvent); + app.update(); // Fire the win event the engine emits when the last foundation // completes — `record_replay_on_win` listens for it. @@ -895,9 +893,15 @@ fn replay_recording_freezes_into_replay_on_game_won() { loaded.time_seconds, 250, "time_seconds must come from the win event" ); - assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); - assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock)); - assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock)); + let instructions = loaded.recording.instructions(); + assert_eq!(instructions.len(), 2, "every recorded move must round-trip"); + assert!(matches!(instructions[0], KlondikeInstruction::RotateStock)); + assert!(matches!(instructions[1], KlondikeInstruction::RotateStock)); + assert_eq!( + loaded.win_move_index, + Some(1), + "win move index must point at the last recorded instruction", + ); #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index 0049e70..92a7b28 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String { /// `▌ MOVE LOG · COMPLETE` in `Completed`. pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { match state { - ReplayPlaybackState::Playing { replay, cursor, .. } => { - format!( - "\u{258C} MOVE LOG \u{00B7} {}/{}", - cursor, - replay.moves.len() - ) + ReplayPlaybackState::Playing { moves, cursor, .. } => { + format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len()) } ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), ReplayPlaybackState::Inactive => String::new(), @@ -135,7 +131,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { } /// Pure helper — formats the kth-most-recently-applied move's row -/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`, +/// text. `k = 1` is the active row (`moves[cursor - 1]`, /// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above /// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), /// and so on. @@ -147,14 +143,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { /// for k=1 and k=2 only, k=3 returns empty). /// - The move list is shorter than expected (defensive guard). pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { - let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { + let ReplayPlaybackState::Playing { moves, cursor, .. } = state else { return String::new(); }; if k == 0 || k > *cursor { return String::new(); } let zero_idx = *cursor - k; - let Some(m) = replay.moves.get(zero_idx) else { + let Some(m) = moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor - k + 1; @@ -162,7 +158,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St } /// Pure helper — formats the kth-NEXT move's row text. `k = 1` -/// is the move that will apply next (`replay.moves[cursor]`, +/// is the move that will apply next (`moves[cursor]`, /// displayed as `cursor + 1`); `k = 2` is the move after that, /// and so on. /// @@ -174,14 +170,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St /// replay — late in the move list, the trailing next rows /// stay empty). pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { - let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { + let ReplayPlaybackState::Playing { moves, cursor, .. } = state else { return String::new(); }; if k == 0 { return String::new(); } let zero_idx = *cursor + k - 1; - let Some(m) = replay.moves.get(zero_idx) else { + let Some(m) = moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor + k; diff --git a/solitaire_engine/src/replay_overlay/mod.rs b/solitaire_engine/src/replay_overlay/mod.rs index aaa3fc0..ab3b475 100644 --- a/solitaire_engine/src/replay_overlay/mod.rs +++ b/solitaire_engine/src/replay_overlay/mod.rs @@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow { /// Marker on a "next move" row below the active row. `offset` /// is the 1-based distance forward from the active row: /// `offset = 1` is the move that will apply next -/// (`replay.moves[cursor]`, displayed as `cursor + 1`), +/// (`moves[cursor]`, displayed as `cursor + 1`), /// `offset = 2` is the one after that, and so on. Up to /// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. /// @@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str { /// `win_move_index >= total` (defensive — shouldn't happen) doesn't /// position the marker outside the track. fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option { - let ReplayPlaybackState::Playing { replay, .. } = state else { + let ReplayPlaybackState::Playing { replay, moves, .. } = state else { return None; }; let idx = replay.win_move_index?; - let total = replay.moves.len(); + let total = moves.len(); if total == 0 { return None; } diff --git a/solitaire_engine/src/replay_overlay/tests.rs b/solitaire_engine/src/replay_overlay/tests.rs index fbd18e4..a9d93f9 100644 --- a/solitaire_engine/src/replay_overlay/tests.rs +++ b/solitaire_engine/src/replay_overlay/tests.rs @@ -1,7 +1,7 @@ use super::*; use chrono::NaiveDate; use solitaire_core::{DrawStockConfig, game_state::GameMode}; -use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; +use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau}; use solitaire_core::{Rank, Suit}; use solitaire_data::Replay; @@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay { 120, 1_000, NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), - (0..move_count) - .map(|_| KlondikeInstruction::RotateStock) - .collect(), + SessionRecording::from_instructions_unchecked( + 42, + DrawStockConfig::DrawOne, + (0..move_count).map(|_| KlondikeInstruction::RotateStock), + ), ) } @@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); @@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(overlay_root_count(&mut app), 1); @@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(5), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( @@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(3), - cursor: 1, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false), ); app.update(); assert_eq!(overlay_root_count(&mut app), 1); @@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(7), - cursor: 7, - secs_to_next: 0.0, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false), ); app.update(); assert_eq!(banner_text(&mut app), "\u{258C} replay"); @@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() { assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0); assert_eq!( - scrub_pct(&ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }), + scrub_pct(&ReplayPlaybackState::playing( + synthetic_replay(10), + 0, + 0.5, + false, + )), 0.0, ); assert_eq!( - scrub_pct(&ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }), + scrub_pct(&ReplayPlaybackState::playing( + synthetic_replay(10), + 5, + 0.5, + false, + )), 50.0, ); assert_eq!( - scrub_pct(&ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 10, - secs_to_next: 0.5, - paused: false, - }), + scrub_pct(&ReplayPlaybackState::playing( + synthetic_replay(10), + 10, + 0.5, + false, + )), 100.0, ); } @@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() { // Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always // uses this date so the assertion is stable. assert_eq!( - format_game_caption(&ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }), + format_game_caption(&ReplayPlaybackState::playing( + synthetic_replay(10), + 5, + 0.5, + false, + )), Some("GAME #2026-122".to_string()), ); @@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() { let mut early_january = synthetic_replay(10); early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date"); assert_eq!( - format_game_caption(&ReplayPlaybackState::Playing { - replay: early_january, - cursor: 0, - secs_to_next: 0.5, - paused: false, - }), + format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)), Some("GAME #2026-005".to_string()), ); } @@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(game_caption_text(&mut app), "GAME #2026-122"); @@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8), - cursor: 2, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ); app.update(); assert_eq!( @@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8), - cursor: 6, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false), ); app.update(); assert_eq!( @@ -497,12 +449,7 @@ fn win_move_marker_pct_is_none_for_completed() { fn win_move_marker_pct_is_none_when_replay_lacks_field() { // Synthetic replay constructor leaves win_move_index as None // (legacy / pre-`ab857bb` path). - let state = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }; + let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!(win_move_marker_pct(&state), None); } @@ -511,12 +458,12 @@ fn win_move_marker_pct_is_some_at_correct_position() { // 10 moves, win at index 9 → marker sits at 90 % of the track. // Matches the recording semantic: cursor reaches the marker // exactly when the about-to-apply move IS the win move. - let state = ReplayPlaybackState::Playing { - replay: synthetic_replay(10).with_win_move_index(Some(9)), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }; + let state = ReplayPlaybackState::playing( + synthetic_replay(10).with_win_move_index(Some(9)), + 0, + 0.5, + false, + ); assert_eq!(win_move_marker_pct(&state), Some(90.0)); } @@ -524,12 +471,12 @@ fn win_move_marker_pct_is_some_at_correct_position() { fn win_move_marker_pct_clamps_to_track_bounds() { // Defensive: if a malformed replay carried `win_move_index >= // total`, the marker must still sit on the track, not past it. - let state = ReplayPlaybackState::Playing { - replay: synthetic_replay(5).with_win_move_index(Some(99)), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }; + let state = ReplayPlaybackState::playing( + synthetic_replay(5).with_win_move_index(Some(99)), + 0, + 0.5, + false, + ); assert_eq!(win_move_marker_pct(&state), Some(100.0)); } @@ -538,12 +485,12 @@ fn marker_spawned_when_replay_has_win_move_index() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8).with_win_move_index(Some(7)), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing( + synthetic_replay(8).with_win_move_index(Some(7)), + 0, + 0.5, + false, + ), ); app.update(); assert_eq!( @@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() { // Default constructor → win_move_index: None (legacy replay). set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ); app.update(); assert_eq!( @@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8).with_win_move_index(Some(7)), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing( + synthetic_replay(8).with_win_move_index(Some(7)), + 0, + 0.5, + false, + ), ); app.update(); assert_eq!(win_marker_count(&mut app), 1); @@ -608,12 +550,12 @@ fn win_move_marker_carries_hc_background_marker() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8).with_win_move_index(Some(7)), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing( + synthetic_replay(8).with_win_move_index(Some(7)), + 0, + 0.5, + false, + ), ); app.update(); @@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( @@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(scrub_notch_count(&mut app), 5); @@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( @@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(scrub_notch_label_count(&mut app), 5); @@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( @@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); @@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(keybind_footer_count(&mut app), 1); @@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() { // Default constructor → win_move_index: None. set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ); app.update(); assert_eq!( @@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() { /// `Inactive` → empty. #[test] fn format_move_log_header_covers_state_branches() { - let playing = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }; + let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); assert_eq!( format_move_log_header(&playing), "\u{258C} MOVE LOG \u{00B7} 3/10" @@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() { /// `replay.moves[N - 1]` and the row reads `"N | ..."`. #[test] fn format_active_move_row_handles_cursor_zero_and_positive() { - let cursor_zero = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }; + let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!( format_active_move_row(&cursor_zero), "", "cursor=0 means no move applied yet; row stays empty", ); - let cursor_three = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }; + let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // synthetic_replay produces all StockClicks, so the body // is "stock cycle". The displayed index is 3 (cursor), // matching the most-recently-applied move at moves[2]. @@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( @@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(8), - cursor: 2, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ); app.update(); assert_eq!( @@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( @@ -1271,12 +1123,7 @@ fn move_log_active_row_repaints_on_cursor_advance() { // Advance cursor to 2 (most-recently-applied move is moves[1]). set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 2, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ); app.update(); assert_eq!( @@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() { /// early in a replay don't paint stale text. #[test] fn format_kth_recent_row_handles_in_range_and_out_of_range() { - let state_at_three = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }; + let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // k=1 → active (most recent applied). cursor=3 → display=3. assert_eq!( format_kth_recent_row(&state_at_three, 1), @@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); assert_eq!( @@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); @@ -1401,12 +1233,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() { // Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor). set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 2, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ); app.update(); assert_eq!( @@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() { // Advance to cursor=5 — both offsets now have history. set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); assert_eq!( @@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String { /// alongside in-range correctness. #[test] fn format_kth_next_row_handles_in_range_and_out_of_range() { - let state_at_three = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }; + let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // k=1 → moves[3], display=4 assert_eq!( format_kth_next_row(&state_at_three, 1), @@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); assert_eq!( @@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); @@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 9, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false), ); app.update(); assert_eq!( @@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); @@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 3, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); @@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() { /// dropping it has to also update this test. #[test] fn active_row_format_includes_focus_prefix() { - let state = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 5, - secs_to_next: 0.5, - paused: false, - }; + let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false); let row = format_active_move_row(&state); assert!( row.starts_with('\u{25B6}'), "active-row format must start with ▶ focus marker; got {row:?}", ); // Cursor=0 still returns empty, never just the prefix. - let cursor_zero = ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }; + let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!( format_active_move_row(&cursor_zero), "", @@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() { let mut app = headless_app(); set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(10), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(move_log_panel_count(&mut app), 1); @@ -1728,21 +1505,11 @@ fn unique_button(app: &mut App) -> Entity { } fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { - ReplayPlaybackState::Playing { - replay: synthetic_replay(replay_len), - cursor, - secs_to_next: 0.5, - paused: true, - } + ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true) } fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { - ReplayPlaybackState::Playing { - replay: synthetic_replay(replay_len), - cursor, - secs_to_next: 0.5, - paused: false, - } + ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false) } #[test] @@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(5), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( @@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() { set_state( &mut app, - ReplayPlaybackState::Playing { - replay: synthetic_replay(5), - cursor: 0, - secs_to_next: 0.5, - paused: false, - }, + ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( diff --git a/solitaire_engine/src/replay_overlay/update.rs b/solitaire_engine/src/replay_overlay/update.rs index 53b1529..9400b0d 100644 --- a/solitaire_engine/src/replay_overlay/update.rs +++ b/solitaire_engine/src/replay_overlay/update.rs @@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip( // any). `cursor` is the index of the *next* move to apply, so // the most-recently-applied move sits at `cursor - 1`. let dest_pile = match state.as_ref() { - ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => { + ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => { // The destination pile is recoverable directly from the // instruction — no live state needed. `RotateStock` has no // destination (the chip hides over the stock pile). - match &replay.moves[cursor - 1] { + match &moves[cursor - 1] { KlondikeInstruction::DstFoundation(dst) => { Some(KlondikePile::Foundation(dst.foundation)) } diff --git a/solitaire_engine/src/replay_playback.rs b/solitaire_engine/src/replay_playback.rs index 0deb460..a4e60ce 100644 --- a/solitaire_engine/src/replay_playback.rs +++ b/solitaire_engine/src/replay_playback.rs @@ -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, + /// 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, + /// 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, ) -> 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::(); 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], + ), ) } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 8c4eeb4..f84ed43 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -1939,7 +1939,11 @@ mod tests { time_seconds, 0, date, - vec![], + solitaire_core::SessionRecording::from_instructions_unchecked( + 1, + solitaire_core::DrawStockConfig::DrawOne, + [], + ), ); r.share_url = share_url; r diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 55d03af..cf9e5cf 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -335,7 +335,11 @@ fn push_replay_on_win( ev.time_seconds, ev.score, Utc::now().date_naive(), - recording.moves.clone(), + // The live session is the authoritative recording; it + // serialises via the upstream card_game serializers so web + // playback rebuilds the exact deal instead of re-dealing + // the seed. + game.0.recording(), ); let provider = provider.0.clone(); let rt = rt.0.clone(); @@ -621,7 +625,11 @@ mod tests { 60, 500, chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"), - vec![], + solitaire_core::SessionRecording::from_instructions_unchecked( + 7, + DrawStockConfig::DrawOne, + [], + ), ); let history = ReplayHistory { schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION, diff --git a/solitaire_server/src/replays.rs b/solitaire_server/src/replays.rs index 6769962..c368d32 100644 --- a/solitaire_server/src/replays.rs +++ b/solitaire_server/src/replays.rs @@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser}; const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"]; const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"]; -fn validate_header(h: &ReplayHeader) -> Result<(), AppError> { +/// Extract the mode label from the client's serde representation. +/// +/// Unit `GameMode` variants serialise as plain strings (`"Classic"`), +/// but data-carrying variants serialise as single-key objects +/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those +/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`. +fn mode_label(mode: &serde_json::Value) -> Result { + if let Some(s) = mode.as_str() { + return Ok(s.to_string()); + } + if let Some(obj) = mode.as_object() + && obj.len() == 1 + && let Some(key) = obj.keys().next() + { + return Ok(key.clone()); + } + Err(AppError::BadRequest(format!( + "invalid mode '{mode}'; expected a mode string or single-variant object" + ))) +} + +fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> { if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) { return Err(AppError::BadRequest(format!( "invalid draw_mode '{}'; expected one of {:?}", h.draw_mode, KNOWN_DRAW_MODES ))); } - if !KNOWN_MODES.contains(&h.mode.as_str()) { + if !KNOWN_MODES.contains(&mode) { return Err(AppError::BadRequest(format!( - "invalid mode '{}'; expected one of {:?}", - h.mode, KNOWN_MODES + "invalid mode '{mode}'; expected one of {KNOWN_MODES:?}" ))); } if h.time_seconds <= 0 || h.time_seconds > 86_400 { @@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> { struct ReplayHeader { seed: u64, draw_mode: String, - mode: String, + /// Kept as raw JSON: unit variants arrive as strings, data-carrying + /// variants (`GameMode::Difficulty(level)`) as single-key objects. + /// [`mode_label`] projects both onto the label stored in the `mode` + /// column. + mode: serde_json::Value, time_seconds: i64, final_score: i64, recorded_at: String, @@ -126,7 +150,8 @@ pub async fn upload( let header: ReplayHeader = serde_json::from_value(payload.clone()) .map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?; - validate_header(&header)?; + let mode = mode_label(&header.mode)?; + validate_header(&header, &mode)?; let id = Uuid::new_v4().to_string(); let received_at = Utc::now().to_rfc3339(); @@ -144,7 +169,7 @@ pub async fn upload( user.user_id, seed_i64, header.draw_mode, - header.mode, + mode, header.time_seconds, header.final_score, header.recorded_at, @@ -158,7 +183,7 @@ pub async fn upload( // beats their existing best. Only classic mode counts for the leaderboard. // Use `received_at` (server-computed) rather than `header.recorded_at` // (client-supplied) so clients cannot spoof the timestamp. - if header.mode == "Classic" { + if mode == "Classic" { sqlx::query!( r#"UPDATE leaderboard SET best_score = ?, diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js index 9a24aee..06fb148 100644 --- a/solitaire_server/web/game.js +++ b/solitaire_server/web/game.js @@ -445,33 +445,30 @@ function showWin(s) { submitReplay(s); } -function buildReplayPayload(s) { - if (!game || !s) return null; - let moves; +function buildReplayPayload() { + if (!game) return null; + // The wasm side assembles the entire schema-v4 payload (including the + // session recording and the u64 seed, which JS numbers can't hold); + // JS only supplies the wall-clock time and today's date. try { - moves = game.replay_moves(); - if (!Array.isArray(moves) || moves.length === 0) return null; + const json = game.replay_export( + Math.max(1, elapsedSecs), + new Date().toISOString().slice(0, 10), + ); + const payload = JSON.parse(json); + if (!Array.isArray(payload?.recording?.instructions) + || payload.recording.instructions.length === 0) return null; + return payload; } catch (e) { console.warn("fs: replay export failed", e); return null; } - return { - schema_version: 2, - seed: Math.round(game.seed()), - draw_mode: drawThree ? "DrawThree" : "DrawOne", - mode: "Classic", - time_seconds: Math.max(1, elapsedSecs), - final_score: s.score, - recorded_at: new Date().toISOString().slice(0, 10), - moves, - win_move_index: moves.length - 1, - }; } async function submitReplay(s) { const token = localStorage.getItem('fs_token'); if (!token || !game) return; - const payload = buildReplayPayload(s); + const payload = buildReplayPayload(); if (!payload) return; try { await fetch('/api/replays', { @@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = { }, replayPayload() { if (!game) return null; - return buildReplayPayload(snap ?? game.state()); + return buildReplayPayload(); }, runAutoplay(options) { return runDebugAutoplay(options); diff --git a/solitaire_server/web/play.html b/solitaire_server/web/play.html index 9c33444..8a76e08 100644 --- a/solitaire_server/web/play.html +++ b/solitaire_server/web/play.html @@ -159,16 +159,17 @@ function buildReplayPayload() { if (!game) return null; + // Schema v4: the wasm side assembles the full payload, + // including the session recording and the u64 seed. try { - const moves = game.replay_moves(); - if (!Array.isArray(moves) || moves.length === 0) return null; - return { - schema_version: 2, - seed: Math.round(game.seed()), - draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne", - mode: "Classic", - moves, - }; + const json = game.replay_export( + 1, + new Date().toISOString().slice(0, 10), + ); + const payload = JSON.parse(json); + if (!Array.isArray(payload?.recording?.instructions) + || payload.recording.instructions.length === 0) return null; + return payload; } catch { return null; } } diff --git a/solitaire_server/web/replay.js b/solitaire_server/web/replay.js index 4ae8f10..5b38f5c 100644 --- a/solitaire_server/web/replay.js +++ b/solitaire_server/web/replay.js @@ -121,7 +121,18 @@ function resetPlayer() { playInterval = null; btnPlay.textContent = "▶ Play"; } - player = new ReplayPlayer(replayJson); + // Old replays (schema < 4) are rejected by the wasm player with a + // descriptive error — surface it instead of leaving a dead board. + try { + player = new ReplayPlayer(replayJson); + } catch (e) { + captionEl.textContent = `Cannot play this replay: ${e}`; + btnStep.disabled = true; + btnPlay.disabled = true; + btnPrev.disabled = true; + btnRestart.disabled = true; + return; + } btnPrev.disabled = true; btnRestart.disabled = true; btnStep.disabled = false; diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 311a75a..6b70957 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -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, + pub recording: SessionRecording, + #[serde(default)] + pub win_move_index: Option, } /// 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 { 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 { + 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 { + 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 = 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 {