Compare commits

...

4 Commits

Author SHA1 Message Date
funman300 c60d465711 docs(changelog): cut 0.43.3 — replay schema v4
Android Release / build-apk (push) Successful in 7m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:57:04 -07:00
funman300 ce2b29f5df Merge pull request 'fix(replay): store the deal via upstream card_game serializers (schema v4)' (#170) from fix/replay-schema-v4-session-recording into master
Build and Deploy / build-and-push (push) Successful in 11m32s
Web E2E / web-e2e (push) Failing after 8m58s
Test / test (push) Successful in 38m3s
2026-07-10 16:56:33 +00:00
funman300 4cb4212829 fix(replay): store the deal via upstream card_game serializers (schema v4)
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>
2026-07-10 09:10:38 -07:00
funman300 fce0266b47 Merge pull request 'chore: delete dead code approved from the PR #166 sweep' (#169) from chore/dead-code-removals into master
Build and Deploy / build-and-push (push) Successful in 11m15s
Web E2E / web-e2e (push) Successful in 9m32s
Android Release / build-apk (push) Successful in 5m18s
Test / test (push) Successful in 36m51s
2026-07-09 23:46:47 +00:00
21 changed files with 770 additions and 616 deletions
+17
View File
@@ -6,6 +6,23 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.43.3] — 2026-07-10
### Fixed
- **Replays are now self-contained (schema v4).** A replay stores the dealt
board itself via the upstream `card_game` session serializers instead of
re-dealing from the seed at playback time, so replays survive RNG and
upstream upgrades that change the seed→deal mapping — the failure that had
silently broken every stored replay. The web player and web game now
exchange the full payload through the wasm layer (the old JS path hardcoded
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
`Math.round`), and the replay viewer reports unplayable old-format replays
in the caption instead of dying silently. Pre-v4 replays are rejected by a
version gate; local histories repopulate with new wins. (#170)
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
## [0.42.0] — 2026-07-06 ## [0.42.0] — 2026-07-06
### Added ### Added
Generated
+1
View File
@@ -7333,6 +7333,7 @@ dependencies = [
"proptest", "proptest",
"rand 0.10.1", "rand 0.10.1",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
+1
View File
@@ -10,6 +10,7 @@ test-support = []
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
serde_json = { workspace = true }
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
+195
View File
@@ -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 { impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode. /// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self { 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. /// Whether the player draws one or three cards from the stock per turn.
/// Derived from the underlying session config (set once at deal time). /// Derived from the underlying session config (set once at deal time).
pub fn draw_mode(&self) -> DrawStockConfig { pub fn draw_mode(&self) -> DrawStockConfig {
@@ -1512,4 +1634,77 @@ mod tests {
assert!(easy.is_err()); assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_)))); 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);
}
} }
+3 -1
View File
@@ -20,7 +20,9 @@ pub use klondike::{
// Solvability check API (delegates to `card_game::Session::solve`); replaces the // Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module. // 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 // Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet). // later phase — nothing outside solitaire_core consumes these yet).
+77 -69
View File
@@ -12,22 +12,24 @@
//! carries any other version so older replays are silently dropped instead //! carries any other version so older replays are silently dropped instead
//! of crashing the loader. //! of crashing the loader.
//! //!
//! The recording is intentionally minimal — only the //! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that //! the upstream `card_game` session serialisation, which stores the dealt
//! successfully advanced the game. `Undo` is **not** recorded: a replay //! board **explicitly** plus the ordered instruction list. `Undo` is not
//! represents the canonical path the player ultimately took to win, so //! recorded: a replay represents the canonical path the player ultimately
//! backed-out missteps simply do not appear in the move list. The starting //! took to win, so backed-out missteps simply do not appear (the session
//! deal is not stored either — the [`seed`](Replay::seed) + //! pops them from its history).
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient //!
//! for `GameState::new_with_mode` to rebuild the identical layout. //! 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. //! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile; //! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current //! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same //! stock state during playback, so the same input always produces the same
//! effect on the same starting deal. Runtime-only pile-position types are //! effect on the same starting deal.
//! never serialised — the instruction itself serialises via its compact
//! upstream serde representation.
use std::fs; use std::fs;
use std::io; use std::io;
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; 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 LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.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 /// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input. /// than the player's atomic input.
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant. /// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves /// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
/// are now stored directly as upstream /// were stored directly as upstream `KlondikeInstruction` (compact
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact /// int serde); `StockClick` became `RotateStock`. Pile-position types are
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are /// runtime-only and are never serialised. The starting deal was still
/// runtime-only and are never serialised. v1/v2 files fail to deserialise /// rebuilt from the seed at playback time.
/// and are discarded by the loader. /// - v4 (current): the bare `moves` list was replaced by a
pub const REPLAY_SCHEMA_VERSION: u32 = 3; /// [`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). v1v3 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 /// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`] /// 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. /// A complete recording of a single winning game.
/// ///
/// Replays are reconstructed by rebuilding a fresh /// Replays are reconstructed via
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the /// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
/// [`moves`](Self::moves) in order. The presentation fields /// 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), /// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as /// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)". /// "Replay (2:14 win on 2026-05-02)".
@@ -105,8 +114,9 @@ pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`]. /// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")] #[serde(default = "schema_v0")]
pub schema_version: u32, pub schema_version: u32,
/// Seed used for the deal — replay rasterises the deck via /// Seed the recorded game was originally dealt from. Presentation /
/// `GameState::new_with_mode(seed, draw_mode, mode)`. /// indexing metadata only — playback rebuilds the board from
/// [`recording`](Self::recording), never by re-dealing this seed.
pub seed: u64, pub seed: u64,
/// Draw mode the recorded game was played in. /// Draw mode the recorded game was played in.
pub draw_mode: DrawStockConfig, pub draw_mode: DrawStockConfig,
@@ -119,11 +129,10 @@ pub struct Replay {
pub final_score: i32, pub final_score: i32,
/// ISO-8601 date the win was recorded. /// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate, pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is the atomic /// The dealt board plus the ordered instruction list, serialised via the
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player /// upstream `card_game` session serializers. Self-contained: playback
/// issued, replayable against a fresh `GameState` constructed from the /// needs nothing else to reproduce the game move-for-move.
/// seed via `GameState::apply_instruction`. pub recording: SessionRecording,
pub moves: Vec<KlondikeInstruction>,
/// Public share URL for this replay on the active sync backend, set /// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload /// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only /// task resolves. `None` when the player won on a local-only
@@ -133,11 +142,12 @@ pub struct Replay {
/// [`REPLAY_SCHEMA_VERSION`]. /// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)] #[serde(default)]
pub share_url: Option<String>, pub share_url: Option<String>,
/// Index into [`moves`](Self::moves) of the move that triggered /// Index into the [`recording`](Self::recording)'s instruction list
/// the win condition (i.e. completed the last foundation pile). /// 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 /// 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 /// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each /// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that /// time, and to leave room for future recording semantics that
@@ -172,7 +182,7 @@ impl Replay {
time_seconds: u64, time_seconds: u64,
final_score: i32, final_score: i32,
recorded_at: NaiveDate, recorded_at: NaiveDate,
moves: Vec<KlondikeInstruction>, recording: SessionRecording,
) -> Self { ) -> Self {
Self { Self {
schema_version: REPLAY_SCHEMA_VERSION, schema_version: REPLAY_SCHEMA_VERSION,
@@ -182,7 +192,7 @@ impl Replay {
time_seconds, time_seconds,
final_score, final_score,
recorded_at, recorded_at,
moves, recording,
share_url: None, share_url: None,
win_move_index: None, win_move_index: None,
} }
@@ -193,7 +203,7 @@ impl Replay {
/// [`Replay::new`]: /// [`Replay::new`]:
/// ///
/// ```ignore /// ```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 /// `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 { mod tests {
use super::*; use super::*;
use klondike::{ use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau, DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
KlondikePileStack, Tableau,
}; };
use std::env; use std::env;
@@ -447,7 +458,10 @@ mod tests {
134, 134,
5_120, 5_120,
date, date,
vec![ SessionRecording::from_instructions_unchecked(
12345,
DrawStockConfig::DrawThree,
[
KlondikeInstruction::RotateStock, KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau { KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock, src: KlondikePileStack::Stock,
@@ -459,6 +473,7 @@ mod tests {
foundation: Foundation::Foundation1, foundation: Foundation::Foundation1,
}), }),
], ],
),
) )
} }
@@ -518,25 +533,22 @@ mod tests {
/// rolling history wiped on the v0.19.0 update. /// rolling history wiped on the v0.19.0 update.
#[test] #[test]
fn replay_loads_when_share_url_field_is_absent() { fn replay_loads_when_share_url_field_is_absent() {
let pre_v019_json = format!( // Build a current-schema JSON object, then strip the optional
r#"{{ // fields to simulate a file written before they existed.
"schema_version": {schema}, let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
"seed": 1, let obj = value.as_object_mut().expect("replay serialises as object");
"draw_mode": "DrawOne", obj.remove("share_url");
"mode": "Classic", obj.remove("win_move_index");
"time_seconds": 60, let parsed: Replay = serde_json::from_value(value)
"final_score": 100, .expect("replay JSON without optional fields must still deserialise");
"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");
assert!( assert!(
parsed.share_url.is_none(), parsed.share_url.is_none(),
"missing share_url field must default to 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 /// Atomic-write contract — `.tmp` must not be left behind after
@@ -588,7 +600,11 @@ mod tests {
60, 60,
id, id,
date, 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 path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
// Hand-rolled minimal current-schema replay JSON with no // Current-schema replay JSON with the win_move_index field stripped —
// win_move_index field — the additive field must still default to None. // the additive field must still default to None.
let no_field = format!( let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
r#"{{ value
"schema_version": {schema}, .as_object_mut()
"seed": 1, .expect("replay serialises as object")
"draw_mode": "DrawOne", .remove("win_move_index");
"mode": "Classic", fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load"); let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None); assert_eq!(loaded.win_move_index, None);
+18 -32
View File
@@ -1343,7 +1343,9 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode}; use solitaire_core::{
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
};
use solitaire_data::Replay; use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState` /// Headless app variant that injects a default `ReplayPlaybackState`
@@ -1364,7 +1366,11 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), 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 // Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`. // `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing { *app.world_mut().resource_mut::<ReplayPlaybackState>() =
replay: dummy_replay(), ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
assert!( assert!(
!cinephile_unlocked(&app), !cinephile_unlocked(&app),
@@ -1442,12 +1444,8 @@ mod tests {
fn cinephile_does_not_unlock_on_stop_button_abort() { fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app(); let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing { *app.world_mut().resource_mut::<ReplayPlaybackState>() =
replay: dummy_replay(), ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
// Direct Playing → Inactive — the path the Stop button takes via // Direct Playing → Inactive — the path the Stop button takes via
@@ -1473,12 +1471,8 @@ mod tests {
let mut app = cinephile_app(); let mut app = cinephile_app();
// First completion cycle to unlock. // First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing { *app.world_mut().resource_mut::<ReplayPlaybackState>() =
replay: dummy_replay(), ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
@@ -1496,12 +1490,8 @@ mod tests {
// Second cycle: Inactive → Playing → Completed once more. // Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing { *app.world_mut().resource_mut::<ReplayPlaybackState>() =
replay: dummy_replay(), ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
@@ -1520,12 +1510,8 @@ mod tests {
fn cinephile_fires_once_across_completed_linger() { fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app(); let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing { *app.world_mut().resource_mut::<ReplayPlaybackState>() =
replay: dummy_replay(), ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
+7 -2
View File
@@ -1062,11 +1062,16 @@ pub fn record_replay_on_win(
if recording.moves.is_empty() { if recording.moves.is_empty() {
continue; 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 // Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the // win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE // index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render. // 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( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode(), game.0.draw_mode(),
@@ -1074,7 +1079,7 @@ pub fn record_replay_on_win(
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
Utc::now().date_naive(), Utc::now().date_naive(),
recording.moves.clone(), session_recording,
) )
.with_win_move_index(win_move_index); .with_win_move_index(win_move_index);
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else { let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
+18 -14
View File
@@ -853,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
let mut app = test_app(7654); let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone()))); app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded instructions manually so we can verify they // Drive two real draws so the *session* history (the source the
// survive the freeze/save round-trip without having to drive a // freeze now serialises from, via `GameState::recording()`) holds
// real win. Both are `RotateStock` the only instruction // two instructions. `RotateStock` is the only instruction the
// constructible without the runtime-only `klondike` pile-stack // engine can drive without the runtime-only `klondike` pile-stack
// types (which the engine intentionally does not depend on); the // types; the round-trip shape is identical for any variant.
// round-trip shape is identical for any instruction variant. app.world_mut().write_message(DrawRequestEvent);
{ app.update();
let mut recording = app.world_mut().resource_mut::<RecordingReplay>(); app.world_mut().write_message(DrawRequestEvent);
recording.moves.push(KlondikeInstruction::RotateStock); app.update();
recording.moves.push(KlondikeInstruction::RotateStock);
}
// Fire the win event the engine emits when the last foundation // Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it. // 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, loaded.time_seconds, 250,
"time_seconds must come from the win event" "time_seconds must come from the win event"
); );
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); let instructions = loaded.recording.instructions();
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock)); assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock)); 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"))] #[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
+8 -12
View File
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
/// `▌ MOVE LOG · COMPLETE` in `Completed`. /// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state { match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => { ReplayPlaybackState::Playing { moves, cursor, .. } => {
format!( format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
} }
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(), 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 /// 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 /// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), /// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on. /// 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). /// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard). /// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { 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(); return String::new();
}; };
if k == 0 || k > *cursor { if k == 0 || k > *cursor {
return String::new(); return String::new();
} }
let zero_idx = *cursor - k; 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(); return String::new();
}; };
let display_idx = *cursor - k + 1; 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` /// 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, /// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on. /// 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 /// replay — late in the move list, the trailing next rows
/// stay empty). /// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { 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(); return String::new();
}; };
if k == 0 { if k == 0 {
return String::new(); return String::new();
} }
let zero_idx = *cursor + k - 1; 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(); return String::new();
}; };
let display_idx = *cursor + k; let display_idx = *cursor + k;
+3 -3
View File
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
/// Marker on a "next move" row below the active row. `offset` /// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row: /// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next /// `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 /// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. /// [`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 /// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track. /// position the marker outside the track.
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> { fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let ReplayPlaybackState::Playing { replay, .. } = state else { let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
return None; return None;
}; };
let idx = replay.win_move_index?; let idx = replay.win_move_index?;
let total = replay.moves.len(); let total = moves.len();
if total == 0 { if total == 0 {
return None; return None;
} }
+109 -352
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; 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_core::{Rank, Suit};
use solitaire_data::Replay; use solitaire_data::Replay;
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
120, 120,
1_000, 1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count) SessionRecording::from_instructions_unchecked(
.map(|_| KlondikeInstruction::RotateStock) 42,
.collect(), DrawStockConfig::DrawOne,
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
),
) )
} }
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(overlay_root_count(&mut app), 1); assert_eq!(overlay_root_count(&mut app), 1);
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(overlay_root_count(&mut app), 1); assert_eq!(overlay_root_count(&mut app), 1);
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(banner_text(&mut app), "\u{258C} replay"); 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::Inactive), 0.0);
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing { scrub_pct(&ReplayPlaybackState::playing(
replay: synthetic_replay(10), synthetic_replay(10),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}), )),
0.0, 0.0,
); );
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing { scrub_pct(&ReplayPlaybackState::playing(
replay: synthetic_replay(10), synthetic_replay(10),
cursor: 5, 5,
secs_to_next: 0.5, 0.5,
paused: false, false,
}), )),
50.0, 50.0,
); );
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing { scrub_pct(&ReplayPlaybackState::playing(
replay: synthetic_replay(10), synthetic_replay(10),
cursor: 10, 10,
secs_to_next: 0.5, 0.5,
paused: false, false,
}), )),
100.0, 100.0,
); );
} }
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always // Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
// uses this date so the assertion is stable. // uses this date so the assertion is stable.
assert_eq!( assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing { format_game_caption(&ReplayPlaybackState::playing(
replay: synthetic_replay(10), synthetic_replay(10),
cursor: 5, 5,
secs_to_next: 0.5, 0.5,
paused: false, false,
}), )),
Some("GAME #2026-122".to_string()), Some("GAME #2026-122".to_string()),
); );
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
let mut early_january = synthetic_replay(10); let mut early_january = synthetic_replay(10);
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date"); early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
assert_eq!( assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing { format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
replay: early_january,
cursor: 0,
secs_to_next: 0.5,
paused: false,
}),
Some("GAME #2026-005".to_string()), Some("GAME #2026-005".to_string()),
); );
} }
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(game_caption_text(&mut app), "GAME #2026-122"); 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(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
replay: synthetic_replay(8),
cursor: 6,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( 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() { fn win_move_marker_pct_is_none_when_replay_lacks_field() {
// Synthetic replay constructor leaves win_move_index as None // Synthetic replay constructor leaves win_move_index as None
// (legacy / pre-`ab857bb` path). // (legacy / pre-`ab857bb` path).
let state = ReplayPlaybackState::Playing { let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!(win_move_marker_pct(&state), None); 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. // 10 moves, win at index 9 → marker sits at 90 % of the track.
// Matches the recording semantic: cursor reaches the marker // Matches the recording semantic: cursor reaches the marker
// exactly when the about-to-apply move IS the win move. // exactly when the about-to-apply move IS the win move.
let state = ReplayPlaybackState::Playing { let state = ReplayPlaybackState::playing(
replay: synthetic_replay(10).with_win_move_index(Some(9)), synthetic_replay(10).with_win_move_index(Some(9)),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}; );
assert_eq!(win_move_marker_pct(&state), Some(90.0)); 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() { fn win_move_marker_pct_clamps_to_track_bounds() {
// Defensive: if a malformed replay carried `win_move_index >= // Defensive: if a malformed replay carried `win_move_index >=
// total`, the marker must still sit on the track, not past it. // total`, the marker must still sit on the track, not past it.
let state = ReplayPlaybackState::Playing { let state = ReplayPlaybackState::playing(
replay: synthetic_replay(5).with_win_move_index(Some(99)), synthetic_replay(5).with_win_move_index(Some(99)),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}; );
assert_eq!(win_move_marker_pct(&state), Some(100.0)); 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(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(
replay: synthetic_replay(8).with_win_move_index(Some(7)), synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}, ),
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
// Default constructor → win_move_index: None (legacy replay). // Default constructor → win_move_index: None (legacy replay).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(
replay: synthetic_replay(8).with_win_move_index(Some(7)), synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}, ),
); );
app.update(); app.update();
assert_eq!(win_marker_count(&mut app), 1); 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(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(
replay: synthetic_replay(8).with_win_move_index(Some(7)), synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0, 0,
secs_to_next: 0.5, 0.5,
paused: false, false,
}, ),
); );
app.update(); app.update();
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(scrub_notch_count(&mut app), 5); assert_eq!(scrub_notch_count(&mut app), 5);
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(scrub_notch_label_count(&mut app), 5); assert_eq!(scrub_notch_label_count(&mut app), 5);
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(keybind_footer_count(&mut app), 1); 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. // Default constructor → win_move_index: None.
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
/// `Inactive` → empty. /// `Inactive` → empty.
#[test] #[test]
fn format_move_log_header_covers_state_branches() { fn format_move_log_header_covers_state_branches() {
let playing = ReplayPlaybackState::Playing { let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_move_log_header(&playing), format_move_log_header(&playing),
"\u{258C} MOVE LOG \u{00B7} 3/10" "\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 | ..."`. /// `replay.moves[N - 1]` and the row reads `"N | ..."`.
#[test] #[test]
fn format_active_move_row_handles_cursor_zero_and_positive() { fn format_active_move_row_handles_cursor_zero_and_positive() {
let cursor_zero = ReplayPlaybackState::Playing { let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_active_move_row(&cursor_zero), format_active_move_row(&cursor_zero),
"", "",
"cursor=0 means no move applied yet; row stays empty", "cursor=0 means no move applied yet; row stays empty",
); );
let cursor_three = ReplayPlaybackState::Playing { let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// synthetic_replay produces all StockClicks, so the body // synthetic_replay produces all StockClicks, so the body
// is "stock cycle". The displayed index is 3 (cursor), // is "stock cycle". The displayed index is 3 (cursor),
// matching the most-recently-applied move at moves[2]. // matching the most-recently-applied move at moves[2].
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( 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]). // Advance cursor to 2 (most-recently-applied move is moves[1]).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
/// early in a replay don't paint stale text. /// early in a replay don't paint stale text.
#[test] #[test]
fn format_kth_recent_row_handles_in_range_and_out_of_range() { fn format_kth_recent_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::Playing { let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// k=1 → active (most recent applied). cursor=3 → display=3. // k=1 → active (most recent applied). cursor=3 → display=3.
assert_eq!( assert_eq!(
format_kth_recent_row(&state_at_three, 1), 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(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); 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). // Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
// Advance to cursor=5 — both offsets now have history. // Advance to cursor=5 — both offsets now have history.
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
/// alongside in-range correctness. /// alongside in-range correctness.
#[test] #[test]
fn format_kth_next_row_handles_in_range_and_out_of_range() { fn format_kth_next_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::Playing { let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// k=1 → moves[3], display=4 // k=1 → moves[3], display=4
assert_eq!( assert_eq!(
format_kth_next_row(&state_at_three, 1), 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(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
replay: synthetic_replay(10),
cursor: 9,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
/// dropping it has to also update this test. /// dropping it has to also update this test.
#[test] #[test]
fn active_row_format_includes_focus_prefix() { fn active_row_format_includes_focus_prefix() {
let state = ReplayPlaybackState::Playing { let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
};
let row = format_active_move_row(&state); let row = format_active_move_row(&state);
assert!( assert!(
row.starts_with('\u{25B6}'), row.starts_with('\u{25B6}'),
"active-row format must start with ▶ focus marker; got {row:?}", "active-row format must start with ▶ focus marker; got {row:?}",
); );
// Cursor=0 still returns empty, never just the prefix. // Cursor=0 still returns empty, never just the prefix.
let cursor_zero = ReplayPlaybackState::Playing { let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_active_move_row(&cursor_zero), format_active_move_row(&cursor_zero),
"", "",
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(move_log_panel_count(&mut app), 1); assert_eq!(move_log_panel_count(&mut app), 1);
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
} }
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: true,
}
} }
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: false,
}
} }
#[test] #[test]
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::Playing { ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
// any). `cursor` is the index of the *next* move to apply, so // any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`. // the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() { 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 // The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no // instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile). // destination (the chip hides over the stock pile).
match &replay.moves[cursor - 1] { match &moves[cursor - 1] {
KlondikeInstruction::DstFoundation(dst) => { KlondikeInstruction::DstFoundation(dst) => {
Some(KlondikePile::Foundation(dst.foundation)) Some(KlondikePile::Foundation(dst.foundation))
} }
+68 -32
View File
@@ -1,11 +1,12 @@
//! In-engine replay playback core. //! In-engine replay playback core.
//! //!
//! When the player clicks "Watch replay" on the Stats overlay, the live //! 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` / //! game state is reset to the recorded deal — rebuilt directly from
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a //! `replay.recording` via `GameState::from_recording`, never by
//! steady cadence — firing the canonical [`MoveRequestEvent`] / //! re-dealing the seed — and the engine ticks through the recording's
//! [`DrawRequestEvent`] for each one. The existing animation pipeline //! instruction list at a steady cadence, firing the canonical
//! plays back identically to a live game. //! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
//! existing animation pipeline plays back identically to a live game.
//! //!
//! ## Public surface //! ## 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 /// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event /// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`KlondikeInstruction`]. /// 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 /// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by /// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
/// [`auto_clear_completed_replay`]) before returning to /// [`auto_clear_completed_replay`]) before returning to
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
#[default] #[default]
Inactive, Inactive,
/// A replay is currently being played back. The overlay reads /// 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. /// indicator and `cursor` for the numerator.
Playing { Playing {
/// The replay being played back. Owned so the state is the /// The replay being played back. Owned so the state is the
/// only place playback metadata lives — no separate resource /// only place playback metadata lives — no separate resource
/// needed. /// needed. Boxed: the v4 recording embeds the dealt board and
replay: Replay, /// would otherwise dwarf the other variants
/// Index of the next move to apply, in `[0, replay.moves.len()]`. /// (`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, cursor: usize,
/// Seconds remaining until the next move is dispatched. /// Seconds remaining until the next move is dispatched.
secs_to_next: f32, secs_to_next: f32,
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
} }
impl 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. /// Returns `true` when a replay is currently being played back.
pub fn is_playing(&self) -> bool { pub fn is_playing(&self) -> bool {
matches!(self, Self::Playing { .. }) matches!(self, Self::Playing { .. })
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
/// the total is no longer available in `Completed`. /// the total is no longer available in `Completed`.
pub fn progress(&self) -> Option<(usize, usize)> { pub fn progress(&self) -> Option<(usize, usize)> {
match self { match self {
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())), Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
Self::Inactive | Self::Completed => None, Self::Inactive | Self::Completed => None,
} }
} }
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
) { ) {
use solitaire_core::game_state::GameState; 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)); commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading // 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 // unusually short setting; subsequent ticks read the live setting
// every frame via [`tick_replay_playback`]. // every frame via [`tick_replay_playback`].
**state = ReplayPlaybackState::Playing { **state = ReplayPlaybackState::Playing {
replay, replay: Box::new(replay),
moves,
cursor: 0, cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS, secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false, paused: false,
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
draws_writer: &mut MessageWriter<DrawRequestEvent>, draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool { ) -> bool {
let ReplayPlaybackState::Playing { let ReplayPlaybackState::Playing {
replay, moves,
cursor, cursor,
paused: true, paused: true,
.. ..
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
else { else {
return false; return false;
}; };
if *cursor >= replay.moves.len() { if *cursor >= moves.len() {
return false; return false;
} }
let instruction = replay.moves[*cursor]; let instruction = moves[*cursor];
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer); dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
*cursor += 1; *cursor += 1;
true true
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown /// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
/// expires, fires the canonical event for the move at `cursor`, /// expires, fires the canonical event for the move at `cursor`,
/// increments `cursor`, and resets `secs_to_next`. When `cursor` /// increments `cursor`, and resets `secs_to_next`. When `cursor`
/// reaches `replay.moves.len()`, transitions to /// reaches `moves.len()`, transitions to
/// [`ReplayPlaybackState::Completed`]. /// [`ReplayPlaybackState::Completed`].
/// ///
/// The advance loop is a `while`, not an `if`, so coarse time steps /// 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; let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing { if let ReplayPlaybackState::Playing {
replay, moves,
cursor, cursor,
secs_to_next, secs_to_next,
paused, paused,
..
} = state.as_mut() } = state.as_mut()
{ {
// While paused, the cursor and the timer freeze together — // While paused, the cursor and the timer freeze together —
@@ -397,8 +423,8 @@ fn tick_replay_playback(
// path. // path.
if !*paused { if !*paused {
*secs_to_next -= dt; *secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < moves.len() {
let instruction = replay.moves[*cursor]; let instruction = moves[*cursor];
dispatch_instruction( dispatch_instruction(
instruction, instruction,
*cursor, *cursor,
@@ -410,7 +436,7 @@ fn tick_replay_playback(
*secs_to_next += interval; *secs_to_next += interval;
} }
if *cursor >= replay.moves.len() { if *cursor >= moves.len() {
transition_to_completed = true; transition_to_completed = true;
} }
} }
@@ -562,8 +588,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy; use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::KlondikeInstruction; use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use std::time::Duration; use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and /// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -612,11 +637,11 @@ mod tests {
60, 60,
500, 500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ SessionRecording::from_instructions_unchecked(
KlondikeInstruction::RotateStock, 12345,
KlondikeInstruction::RotateStock, DrawStockConfig::DrawOne,
KlondikeInstruction::RotateStock, [KlondikeInstruction::RotateStock; 3],
], ),
) )
} }
@@ -673,11 +698,14 @@ mod tests {
let state = app.world().resource::<ReplayPlaybackState>(); let state = app.world().resource::<ReplayPlaybackState>();
match state { match state {
ReplayPlaybackState::Playing { ReplayPlaybackState::Playing {
cursor, replay: r, .. cursor,
replay: r,
moves,
..
} => { } => {
assert_eq!(*cursor, 0); assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed); assert_eq!(r.seed, replay.seed);
assert_eq!(r.moves.len(), 3); assert_eq!(moves.len(), 3);
} }
other => panic!("expected Playing, got {other:?}"), other => panic!("expected Playing, got {other:?}"),
} }
@@ -778,7 +806,11 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), 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); start_playback(&mut app, one_move);
app.update(); app.update();
@@ -887,7 +919,11 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![KlondikeInstruction::RotateStock; 10], SessionRecording::from_instructions_unchecked(
7,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock; 10],
),
) )
} }
+5 -1
View File
@@ -1939,7 +1939,11 @@ mod tests {
time_seconds, time_seconds,
0, 0,
date, date,
vec![], solitaire_core::SessionRecording::from_instructions_unchecked(
1,
solitaire_core::DrawStockConfig::DrawOne,
[],
),
); );
r.share_url = share_url; r.share_url = share_url;
r r
+10 -2
View File
@@ -335,7 +335,11 @@ fn push_replay_on_win(
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
Utc::now().date_naive(), 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 provider = provider.0.clone();
let rt = rt.0.clone(); let rt = rt.0.clone();
@@ -621,7 +625,11 @@ mod tests {
60, 60,
500, 500,
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"), chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
vec![], solitaire_core::SessionRecording::from_instructions_unchecked(
7,
DrawStockConfig::DrawOne,
[],
),
); );
let history = ReplayHistory { let history = ReplayHistory {
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION, schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
+33 -8
View File
@@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"]; const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"]; 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<String, AppError> {
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()) { if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"invalid draw_mode '{}'; expected one of {:?}", "invalid draw_mode '{}'; expected one of {:?}",
h.draw_mode, KNOWN_DRAW_MODES h.draw_mode, KNOWN_DRAW_MODES
))); )));
} }
if !KNOWN_MODES.contains(&h.mode.as_str()) { if !KNOWN_MODES.contains(&mode) {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"invalid mode '{}'; expected one of {:?}", "invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
h.mode, KNOWN_MODES
))); )));
} }
if h.time_seconds <= 0 || h.time_seconds > 86_400 { if h.time_seconds <= 0 || h.time_seconds > 86_400 {
@@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
struct ReplayHeader { struct ReplayHeader {
seed: u64, seed: u64,
draw_mode: String, 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, time_seconds: i64,
final_score: i64, final_score: i64,
recorded_at: String, recorded_at: String,
@@ -126,7 +150,8 @@ pub async fn upload(
let header: ReplayHeader = serde_json::from_value(payload.clone()) let header: ReplayHeader = serde_json::from_value(payload.clone())
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?; .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 id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339(); let received_at = Utc::now().to_rfc3339();
@@ -144,7 +169,7 @@ pub async fn upload(
user.user_id, user.user_id,
seed_i64, seed_i64,
header.draw_mode, header.draw_mode,
header.mode, mode,
header.time_seconds, header.time_seconds,
header.final_score, header.final_score,
header.recorded_at, header.recorded_at,
@@ -158,7 +183,7 @@ pub async fn upload(
// beats their existing best. Only classic mode counts for the leaderboard. // beats their existing best. Only classic mode counts for the leaderboard.
// Use `received_at` (server-computed) rather than `header.recorded_at` // Use `received_at` (server-computed) rather than `header.recorded_at`
// (client-supplied) so clients cannot spoof the timestamp. // (client-supplied) so clients cannot spoof the timestamp.
if header.mode == "Classic" { if mode == "Classic" {
sqlx::query!( sqlx::query!(
r#"UPDATE leaderboard r#"UPDATE leaderboard
SET best_score = ?, SET best_score = ?,
+15 -18
View File
@@ -445,33 +445,30 @@ function showWin(s) {
submitReplay(s); submitReplay(s);
} }
function buildReplayPayload(s) { function buildReplayPayload() {
if (!game || !s) return null; if (!game) return null;
let moves; // 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 { try {
moves = game.replay_moves(); const json = game.replay_export(
if (!Array.isArray(moves) || moves.length === 0) return null; 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) { } catch (e) {
console.warn("fs: replay export failed", e); console.warn("fs: replay export failed", e);
return null; 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) { async function submitReplay(s) {
const token = localStorage.getItem('fs_token'); const token = localStorage.getItem('fs_token');
if (!token || !game) return; if (!token || !game) return;
const payload = buildReplayPayload(s); const payload = buildReplayPayload();
if (!payload) return; if (!payload) return;
try { try {
await fetch('/api/replays', { await fetch('/api/replays', {
@@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = {
}, },
replayPayload() { replayPayload() {
if (!game) return null; if (!game) return null;
return buildReplayPayload(snap ?? game.state()); return buildReplayPayload();
}, },
runAutoplay(options) { runAutoplay(options) {
return runDebugAutoplay(options); return runDebugAutoplay(options);
+10 -9
View File
@@ -159,16 +159,17 @@
function buildReplayPayload() { function buildReplayPayload() {
if (!game) return null; if (!game) return null;
// Schema v4: the wasm side assembles the full payload,
// including the session recording and the u64 seed.
try { try {
const moves = game.replay_moves(); const json = game.replay_export(
if (!Array.isArray(moves) || moves.length === 0) return null; 1,
return { new Date().toISOString().slice(0, 10),
schema_version: 2, );
seed: Math.round(game.seed()), const payload = JSON.parse(json);
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne", if (!Array.isArray(payload?.recording?.instructions)
mode: "Classic", || payload.recording.instructions.length === 0) return null;
moves, return payload;
};
} catch { return null; } } catch { return null; }
} }
+11
View File
@@ -121,7 +121,18 @@ function resetPlayer() {
playInterval = null; playInterval = null;
btnPlay.textContent = "▶ Play"; btnPlay.textContent = "▶ Play";
} }
// 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); 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; btnPrev.disabled = true;
btnRestart.disabled = true; btnRestart.disabled = true;
btnStep.disabled = false; btnStep.disabled = false;
+146 -46
View File
@@ -26,16 +26,21 @@ use solitaire_core::{
DrawStockConfig, DrawStockConfig,
game_state::{GameMode, GameState}, game_state::{GameMode, GameState},
}; };
use solitaire_core::{KlondikeInstruction, KlondikePile}; use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
use wasm_bindgen::prelude::*; 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 /// `recording` is the upstream `card_game` session serialisation
/// move-currency `solitaire_core` persists. A stock click is /// (`{config, initial_state, instructions}`): the dealt board is stored
/// `KlondikeInstruction::RotateStock`; a card move is a /// explicitly, so playback rebuilds the exact deal instead of re-dealing
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are /// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
/// runtime-only and intentionally not part of the wire format. /// an RNG or upstream upgrade changed the seed→deal mapping.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay { pub struct Replay {
#[serde(default)] #[serde(default)]
@@ -46,7 +51,9 @@ pub struct Replay {
pub time_seconds: u64, pub time_seconds: u64,
pub final_score: i32, pub final_score: i32,
pub recorded_at: NaiveDate, pub recorded_at: NaiveDate,
pub moves: Vec<KlondikeInstruction>, pub recording: SessionRecording,
#[serde(default)]
pub win_move_index: Option<usize>,
} }
/// JS-friendly snapshot of a `GameState` at a particular replay step. /// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -125,10 +132,20 @@ impl ReplayPlayer {
pub fn from_json(replay_json: &str) -> Result<Self, String> { pub fn from_json(replay_json: &str) -> Result<Self, String> {
let replay: Replay = let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?; 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 { Ok(Self {
game, game,
moves: replay.moves, moves,
step_idx: 0, step_idx: 0,
}) })
} }
@@ -510,6 +527,36 @@ impl SolitaireGame {
self.game.instruction_history() self.game.instruction_history()
} }
/// Builds the complete schema-v4 replay upload payload for the live
/// game as a JSON string, ready to `POST /api/replays` verbatim.
///
/// The JS layer must not assemble this payload itself: the recording
/// serialises through the upstream `card_game` serializers, and the
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
/// silently corrupts it).
///
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
/// supplies it because the wasm build has no reliable local clock.
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
let recorded_at: NaiveDate = recorded_at
.parse()
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
let recording = self.game.recording();
let win_move_index = recording.len().checked_sub(1);
let replay = Replay {
schema_version: REPLAY_SCHEMA_VERSION,
seed: self.game.seed,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
time_seconds,
final_score: self.game.score(),
recorded_at,
recording,
win_move_index,
};
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
}
fn debug_snapshot_native(&self) -> DebugSnapshot { fn debug_snapshot_native(&self) -> DebugSnapshot {
let legal_moves = self.legal_moves_native(); let legal_moves = self.legal_moves_native();
let invariants = invariant_report_for_game(&self.game, &legal_moves); 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())) serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
} }
/// Complete schema-v4 replay payload for the live game, as a JSON
/// string ready to `POST /api/replays` verbatim. See
/// [`Self::replay_export_native`] for why JS must not assemble the
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
/// would demand a `BigInt`).
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
self.replay_export_native(u64::from(time_seconds), &recorded_at)
.map_err(|e| JsValue::from_str(&e))
}
/// Returns all currently-legal debug moves as a JS array. /// Returns all currently-legal debug moves as a JS array.
/// ///
/// Includes [`DebugMove::StockClick`] when stock interaction is legal. /// Includes [`DebugMove::StockClick`] when stock interaction is legal.
@@ -897,44 +955,25 @@ mod tests {
"progressed game must export a non-empty replay move list" "progressed game must export a non-empty replay move list"
); );
let moves_json = match serde_json::to_value(&exported_moves) { let replay_json = match game.replay_export_native(120, "2026-06-01") {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
assert!(
moves_json.is_array(),
"exported replay moves must serialise as a JSON array"
);
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed,
Err(err) => {
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
}
};
assert_eq!(
parsed_back, exported_moves,
"replay move JSON must round-trip through KlondikeInstruction"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
Some(date) => date,
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 3,
seed,
draw_mode,
mode: GameMode::Classic,
time_seconds: 120,
final_score: game.game.score(),
recorded_at,
moves: exported_moves,
};
let replay_json = match serde_json::to_string(&replay) {
Ok(json) => json, 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) { let mut player = match ReplayPlayer::from_json(&replay_json) {
Ok(value) => value, 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] #[test]
fn debug_api_autonomous_seed_batch_smoke() { fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 { for seed in 0_u64..128_u64 {