Files
Ferrous-Solitaire/solitaire_data/src/replay.rs
T
funman300 4cb4212829
Test / test (pull_request) Successful in 36m34s
fix(replay): store the deal via upstream card_game serializers (schema v4)
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

859 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Win-game replay recording + storage.
//!
//! When a player wins, the engine freezes the in-memory recording into a
//! [`Replay`] and persists it to `<data_dir>/ferrous_solitaire/latest_replay.json`
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
//! action that loads it via [`load_latest_replay_from`] so the player can
//! revisit (or, in a future build, watch the engine re-execute) the path
//! they took to victory.
//!
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
//! the upstream `card_game` session serialisation, which stores the dealt
//! board **explicitly** plus the ordered instruction list. `Undo` is not
//! recorded: a replay represents the canonical path the player ultimately
//! took to win, so backed-out missteps simply do not appear (the session
//! pops them from its history).
//!
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
//! `mode` remain as presentation/indexing metadata only.
//!
//! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same
//! effect on the same starting deal.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
/// Maximum number of recent winning replays the rolling history retains.
///
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
/// the oldest entry is dropped so the file never grows unbounded. The
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
/// the Stats overlay's replay selector — older wins age out silently.
pub const REPLAY_HISTORY_CAP: usize = 8;
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
/// returns `None` for older files (the player simply sees an empty
/// history rather than a half-loaded broken one). Bumping
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
/// [`Replay`] payloads inside an otherwise-current history.
///
/// History:
/// - v1 (current): initial release of the rolling history wrapper.
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
/// Default value for [`ReplayHistory::schema_version`] when deserialising
/// files that pre-date the field. Any value other than
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
/// to return `None`.
fn history_schema_v0() -> u32 {
0
}
/// Save-file schema version for [`Replay`]. Increment when the on-disk
/// representation changes incompatibly so [`load_latest_replay_from`] can
/// reject older formats and the player simply has no replay rather than
/// seeing a broken one.
///
/// History:
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
/// were stored directly as upstream `KlondikeInstruction` (compact
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. The starting deal was still
/// rebuilt from the seed at playback time.
/// - v4 (current): the bare `moves` list was replaced by a
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
/// `card_game` session serialisation carrying the dealt board explicitly
/// plus the instruction list. Playback no longer re-deals from the seed,
/// so replays survive RNG/upstream upgrades that change the seed→deal
/// mapping (which invalidated every v3 replay). 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
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
/// causes [`load_latest_replay_from`] to return `None`.
fn schema_v0() -> u32 {
0
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed via
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
/// which rebuilds the recorded deal directly and returns the instruction
/// list to step through. The presentation fields
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")]
pub schema_version: u32,
/// Seed the recorded game was originally dealt from. Presentation /
/// indexing metadata only — playback rebuilds the board from
/// [`recording`](Self::recording), never by re-dealing this seed.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawStockConfig,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
/// "Replay (2:14 win on 2026-05-02)" caption.
pub time_seconds: u64,
/// Final score at the moment of the win.
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// The dealt board plus the ordered instruction list, serialised via the
/// upstream `card_game` session serializers. Self-contained: playback
/// needs nothing else to reproduce the game move-for-move.
pub recording: SessionRecording,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
/// backend, the upload failed, or the replay pre-dates v0.19.0
/// share-link persistence. `#[serde(default)]` keeps older
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)]
pub share_url: Option<String>,
/// Index into the [`recording`](Self::recording)'s instruction list
/// of the move that triggered the win condition (i.e. completed the
/// last foundation pile).
///
/// For replays recorded by the live engine this is always
/// `Some(recording.len() - 1)` because recording freezes on win — but
/// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that
/// might capture post-win state.
///
/// `None` for replays loaded from disk that pre-date this field.
/// `#[serde(default)]` keeps older `latest_replay.json` /
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
/// field, not a schema-breaking change.
///
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
/// (B-2 screen-takeover redesign) when present.
#[serde(default)]
pub win_move_index: Option<usize>,
}
impl Replay {
/// Construct a fresh replay with the current schema version. The
/// caller fills in the recorded fields; this is the canonical
/// constructor used by the engine on win.
///
/// [`win_move_index`](Self::win_move_index) and
/// [`share_url`](Self::share_url) default to `None` — the engine
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
/// recording site to set the former, and `sync_plugin` writes the
/// latter directly when the upload task resolves.
pub fn new(
seed: u64,
draw_mode: DrawStockConfig,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
recording: SessionRecording,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
seed,
draw_mode,
mode,
time_seconds,
final_score,
recorded_at,
recording,
share_url: None,
win_move_index: None,
}
}
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
/// Returns `self` so the recording site can chain it onto
/// [`Replay::new`]:
///
/// ```ignore
/// 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
/// the WIN MOVE marker's scrub-bar position.
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
self.win_move_index = idx;
self
}
}
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/ferrous_solitaire/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
///
/// `replays[0]` is always the most recent win; the Stats overlay's
/// replay selector defaults to that entry and surfaces the older
/// entries behind a small chooser so the player can revisit a memorable
/// game even after a more recent win.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayHistory {
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
#[serde(default = "history_schema_v0")]
pub schema_version: u32,
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
/// older entries drop off when the cap is hit.
pub replays: Vec<Replay>,
}
impl Default for ReplayHistory {
/// An empty history at the current schema version. Used by callers
/// that need a starting point before the first winning replay has
/// ever been recorded.
fn default() -> Self {
Self {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: Vec::new(),
}
}
}
impl ReplayHistory {
/// Returns the most recent replay (`replays[0]`), or `None` when the
/// history is empty. Convenience used by the Stats overlay's default
/// selector position.
pub fn most_recent(&self) -> Option<&Replay> {
self.replays.first()
}
/// Returns the number of replays currently retained.
pub fn len(&self) -> usize {
self.replays.len()
}
/// Returns `true` when no replays have been recorded yet.
pub fn is_empty(&self) -> bool {
self.replays.is_empty()
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
#[deprecated(
note = "single-slot replay storage replaced by the rolling history at \
replay_history_path(); kept for the one-shot legacy migration \
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
/// rename contract that the rest of `storage.rs` uses.
///
/// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk.
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration.")]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(replay).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`Replay`] from `path`, returning `None` when the file is
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
/// other than [`REPLAY_SCHEMA_VERSION`].
///
/// Schema-mismatch is treated as "no replay" so the player just sees the
/// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code.
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration.")]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?;
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return None;
}
Some(replay)
}
/// Save a [`ReplayHistory`] atomically to `path` using the standard
/// `.tmp` → rename contract.
///
/// The on-disk encoding is pretty-printed JSON; the file is intended to
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
/// hundred move records at most) so the readability tradeoff is fine.
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
///
/// Individual [`Replay`] entries inside an otherwise-current history are
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
/// entries are silently dropped so a future bump of the inner replay
/// schema does not corrupt the wrapper.
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
let data = fs::read(path).ok()?;
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
return None;
}
let filtered: Vec<Replay> = history
.replays
.into_iter()
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
.collect();
Some(ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: filtered,
})
}
/// Append `replay` to the front of the rolling history at `path`,
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
/// and persist the updated history atomically.
///
/// If `path` has no existing history (missing file, corrupt, or
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
/// starting point so the new replay is always saved. The returned
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(path: &Path, replay: Replay) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
history.replays.insert(0, replay);
if history.replays.len() > REPLAY_HISTORY_CAP {
history.replays.truncate(REPLAY_HISTORY_CAP);
}
save_replay_history_to(path, &history)?;
Ok(history)
}
/// One-shot migration from the legacy single-slot
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
/// `history_path`.
///
/// Behaviour matrix:
/// - `history_path` already exists → no-op (the rolling history wins).
/// - `history_path` is absent and `latest_path` is absent → no-op.
/// - `history_path` is absent and `latest_path` exists with a valid
/// replay → seed a fresh history with that one replay and write it.
/// - `history_path` is absent and `latest_path` exists but is corrupt /
/// schema-mismatched → write an empty history (we know the player is
/// on the new build and shouldn't keep being prompted to migrate).
///
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
/// this helper — keep it for one release as a safety net so a player
/// rolling back to the previous build doesn't lose their last winning
/// replay. The deletion is planned for the release after this one.
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
if history_path.exists() {
// Rolling history is authoritative once it exists.
return;
}
if !latest_path.exists() {
return;
}
// Use the deprecated loader directly — the migration is the one
// place we still consult the legacy file shape on purpose.
#[allow(deprecated)]
let legacy = load_latest_replay_from(latest_path);
let history = match legacy {
Some(replay) => ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay],
},
None => ReplayHistory::default(),
};
if let Err(e) = save_replay_history_to(history_path, &history) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!("replay: failed to migrate legacy latest_replay.json into rolling history: {e}",);
}
}
#[cfg(test)]
// The legacy single-slot tests still exercise `save_latest_replay_to` /
// `load_latest_replay_from` on purpose — they're the round-trip
// guardrails for the migration source format.
#[allow(deprecated)]
mod tests {
use super::*;
use klondike::{
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
KlondikePileStack, Tableau,
};
use std::env;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
}
fn sample_replay() -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawStockConfig::DrawThree,
GameMode::Classic,
134,
5_120,
date,
SessionRecording::from_instructions_unchecked(
12345,
DrawStockConfig::DrawThree,
[
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
),
)
}
/// A non-trivial replay with mixed move kinds must round-trip
/// byte-identically through `save_latest_replay_to` /
/// `load_latest_replay_from`. Catches any future field that forgets
/// `Serialize`/`Deserialize` or breaks the on-disk format.
#[test]
fn replay_round_trips_through_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let replay = sample_replay();
save_latest_replay_to(&path, &replay).expect("save");
let loaded = load_latest_replay_from(&path).expect("load must succeed");
assert_eq!(loaded, replay, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older schema (or a pre-`schema_version`
/// build) must be rejected. We write a minimal v0 fixture and assert
/// that `load_latest_replay_from` returns `None` so the player gets
/// a clean "no replay" state instead of a broken one.
#[test]
fn replay_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
// if the rest of the JSON parses cleanly, the version gate must
// reject it.
let v0_json = r#"{
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_latest_replay_from(&path).is_none(),
"v0 replay must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Backwards-compat: a `Replay` record persisted before v0.19.0
/// share-link persistence carries no `share_url` field on disk.
/// `#[serde(default)]` must let it deserialise cleanly with
/// `share_url == None`, so existing players don't see their
/// rolling history wiped on the v0.19.0 update.
#[test]
fn replay_loads_when_share_url_field_is_absent() {
// Build a current-schema JSON object, then strip the optional
// fields to simulate a file written before they existed.
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
let obj = value.as_object_mut().expect("replay serialises as object");
obj.remove("share_url");
obj.remove("win_move_index");
let parsed: Replay = serde_json::from_value(value)
.expect("replay JSON without optional fields must still deserialise");
assert!(
parsed.share_url.is_none(),
"missing share_url field must default to None",
);
assert!(
parsed.win_move_index.is_none(),
"missing win_move_index field must default to None",
);
}
/// Atomic-write contract — `.tmp` must not be left behind after
/// `save_latest_replay_to` returns. Mirrors the same check that
/// guards `save_game_state_to` in `storage.rs`.
#[test]
fn replay_save_is_atomic() {
let path = tmp_path("atomic");
let _ = fs::remove_file(&path);
save_latest_replay_to(&path, &sample_replay()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
/// Loading from a path that does not exist must return `None`, not
/// panic or surface an `Err`.
#[test]
fn replay_missing_file_returns_none() {
let path = tmp_path("missing_xyz");
let _ = fs::remove_file(&path);
assert!(load_latest_replay_from(&path).is_none());
}
/// Loading from a corrupt / partially-written file must return
/// `None`, not surface a deserialiser error to the engine.
#[test]
fn replay_corrupt_file_returns_none() {
let path = tmp_path("corrupt");
fs::write(&path, b"not valid json!!!").expect("write");
assert!(load_latest_replay_from(&path).is_none());
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// ReplayHistory — rolling list of recent wins
// -----------------------------------------------------------------------
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
/// assert ordering / identity without writing a deep equality match.
fn replay_with_id(id: i32) -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
id,
date,
SessionRecording::from_instructions_unchecked(
id as u64,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
)
}
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
/// the on-disk file (and the in-memory mirror returned by the helper)
/// stays bounded so the user's data dir never grows unbounded.
#[test]
fn append_replay_to_history_caps_at_eight() {
let path = tmp_path("history_cap");
let _ = fs::remove_file(&path);
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned =
append_replay_to_history(&path, replay_with_id(i)).expect("append must succeed");
}
assert_eq!(
last_returned.replays.len(),
REPLAY_HISTORY_CAP,
"history must be capped at REPLAY_HISTORY_CAP entries",
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned
.replays
.iter()
.map(|r| r.final_score)
.collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
"newest entries must survive, oldest must age out",
);
// The on-disk file must agree with the returned in-memory copy.
let loaded = load_replay_history_from(&path).expect("load must succeed");
assert_eq!(loaded, last_returned, "disk must mirror returned history");
let _ = fs::remove_file(&path);
}
/// `append_replay_to_history` must place new entries at index 0 so
/// the Stats overlay's default selector (most recent) lands on the
/// just-saved replay.
#[test]
fn append_replay_inserts_at_front() {
let path = tmp_path("history_front");
let _ = fs::remove_file(&path);
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![3, 2, 1],
"history must be reverse-chronological (newest first)",
);
let _ = fs::remove_file(&path);
}
/// On first launch with the new code, a pre-existing
/// `latest_replay.json` must seed the new rolling history so the
/// player doesn't lose their last winning replay across the upgrade.
#[test]
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
let latest = tmp_path("legacy_migrate_latest");
let history = tmp_path("legacy_migrate_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(
!history.exists(),
"history file must not exist pre-migration"
);
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history).expect("post-migration history must load");
assert_eq!(
loaded.replays.len(),
1,
"history must hold exactly the legacy entry"
);
assert_eq!(
loaded.replays[0], legacy_replay,
"entry must equal the legacy replay"
);
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(
latest.exists(),
"legacy file must NOT be deleted by migration"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// When the rolling history file already exists, the migration must
/// be a no-op — we never want to overwrite the player's accumulated
/// history with a stale single-slot legacy entry.
#[test]
fn migrate_is_noop_when_history_already_exists() {
let latest = tmp_path("legacy_noop_latest");
let history = tmp_path("legacy_noop_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
let pre_existing = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(42)],
};
save_replay_history_to(&history, &pre_existing).expect("seed history");
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(
loaded, pre_existing,
"existing history must not be overwritten"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// A populated [`ReplayHistory`] must round-trip byte-identically
/// through `save_replay_history_to` / `load_replay_history_from`.
#[test]
fn replay_history_round_trips_through_save_and_load() {
let path = tmp_path("history_round_trip");
let _ = fs::remove_file(&path);
let history = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
};
save_replay_history_to(&path, &history).expect("save");
let loaded = load_replay_history_from(&path).expect("load");
assert_eq!(loaded, history, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older history schema must be rejected so the
/// player sees a clean empty history rather than a half-loaded one.
#[test]
fn replay_history_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("history_legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
let v0_json = r#"{
"replays": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_replay_history_from(&path).is_none(),
"v0 history must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Atomic-write contract for the rolling history — `.tmp` must not be
/// left behind after `save_replay_history_to` returns.
#[test]
fn replay_history_save_is_atomic() {
let path = tmp_path("history_atomic");
let _ = fs::remove_file(&path);
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// win_move_index — additive optional field for the WIN MOVE marker
// -----------------------------------------------------------------------
#[test]
fn replay_new_defaults_win_move_index_to_none() {
let r = sample_replay();
assert_eq!(r.win_move_index, None);
}
#[test]
fn with_win_move_index_sets_value() {
let r = sample_replay().with_win_move_index(Some(3));
assert_eq!(r.win_move_index, Some(3));
}
#[test]
fn with_win_move_index_accepts_none() {
// Passing None through the builder is a valid no-op — useful for
// tests / synthetic replays that don't care about the marker.
let r = sample_replay().with_win_move_index(None);
assert_eq!(r.win_move_index, None);
}
#[test]
fn replay_with_win_move_index_round_trips_on_disk() {
let path = tmp_path("win_move_index_round_trip");
let _ = fs::remove_file(&path);
let original = sample_replay().with_win_move_index(Some(3));
save_latest_replay_to(&path, &original).expect("save");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, Some(3));
assert_eq!(loaded, original);
let _ = fs::remove_file(&path);
}
/// Older replay files written before this field was added must still
/// load — `#[serde(default)]` keeps `win_move_index` optional and
/// defaults missing fields to `None`. This is the contract that lets
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
#[test]
fn replay_without_win_move_index_loads_with_none() {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Current-schema replay JSON with the win_move_index field stripped —
// the additive field must still default to None.
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
value
.as_object_mut()
.expect("replay serialises as object")
.remove("win_move_index");
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
let _ = fs::remove_file(&path);
}
}