feat(data,engine): rolling replay history (last 8 wins)

Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.

Storage layer

solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.

Engine integration

game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.

Stats UI

LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 22:32:37 +00:00
parent 02ababa65f
commit 13a8a012ee
5 changed files with 665 additions and 59 deletions
+405
View File
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest";
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
@@ -138,17 +166,87 @@ impl Replay {
}
}
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/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 `dirs::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> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(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)?;
@@ -168,6 +266,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "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()?;
@@ -177,7 +280,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
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 std::env;
@@ -294,4 +514,189 @@ mod tests {
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,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
)
}
/// 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);
}
}