diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 04f84f3..00d5916 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -155,7 +155,10 @@ pub mod sync_client; pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient}; pub mod replay; +#[allow(deprecated)] +pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; pub use replay::{ - latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove, - REPLAY_SCHEMA_VERSION, + append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay, + replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove, + REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, }; diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index ac1f88b..51f1eb8 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -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 +/// `/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, +} + +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 { 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 { + 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 { 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 { 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 { + 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 = 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 { + 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 = 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 = 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); + } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 05a9af8..b6d2954 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -13,8 +13,12 @@ use bevy::prelude::*; use chrono::Utc; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; -use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path, - load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove}; +use solitaire_data::{ + append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from, + migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove, +}; +#[allow(deprecated)] +use solitaire_data::latest_replay_path; use crate::events::{ CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent, @@ -54,7 +58,15 @@ pub struct GameMutation; #[derive(Resource, Debug, Clone)] pub struct GameStatePath(pub Option); -/// Persistence path for the most recent winning replay. `None` disables I/O. +/// Persistence path for the rolling [`solitaire_data::ReplayHistory`] +/// file (`replays.json`). `None` disables I/O — used by tests and on +/// minimal Linux containers without `dirs::data_dir()`. +/// +/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the +/// history at this path via +/// [`solitaire_data::append_replay_to_history`], capping at +/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows +/// unbounded. #[derive(Resource, Debug, Clone)] pub struct ReplayPath(pub Option); @@ -101,9 +113,27 @@ impl Plugin for GamePlugin { .and_then(load_game_state_from) .unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)); + // One-shot migration from the legacy single-slot + // `latest_replay.json` to the rolling history at `replays.json`. + // Runs at plugin construction so the player's last winning + // replay from a pre-history build is the first entry of the + // new history file. The legacy file is intentionally left in + // place for one release as a safety net (see + // `migrate_legacy_latest_replay` doc comment). + let history_path = replay_history_path(); + if let (Some(legacy), Some(history)) = + ( + #[allow(deprecated)] + latest_replay_path(), + history_path.as_ref(), + ) + { + migrate_legacy_latest_replay(&legacy, history); + } + app.insert_resource(GameStateResource(initial_state)) .insert_resource(GameStatePath(path)) - .insert_resource(ReplayPath(latest_replay_path())) + .insert_resource(ReplayPath(history_path)) .init_resource::() .init_resource::() .init_resource::() @@ -557,14 +587,15 @@ fn handle_undo( /// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into /// a [`Replay`] tagged with the deal seed/mode, the win's score and -/// elapsed time, and today's date — then persist it atomically to -/// `/solitaire_quest/latest_replay.json` (or to whichever path -/// `ReplayPath` carries; tests inject a temp path). +/// elapsed time, and today's date — then append it to the rolling +/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries +/// (tests inject a temp path). /// -/// Only the most recent winning replay is retained — the existing file is -/// overwritten. The recording buffer is left intact after the win so a -/// subsequent state-change does not erase the move list before the save -/// completes; it gets cleared on the next `NewGameRequestEvent`. +/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`] +/// entries; older wins age out automatically when the cap is hit. The +/// recording buffer is left intact after the win so a subsequent +/// state-change does not erase the move list before the save completes; +/// it gets cleared on the next `NewGameRequestEvent`. pub fn record_replay_on_win( mut wins: MessageReader, game: Res, @@ -597,8 +628,8 @@ pub fn record_replay_on_win( // to inspect it without going through the disk. continue; }; - if let Err(e) = save_latest_replay_to(p, &replay) { - warn!("replay: failed to save winning replay: {e}"); + if let Err(e) = append_replay_to_history(p, replay) { + warn!("replay: failed to append winning replay to history: {e}"); } } } @@ -1946,11 +1977,13 @@ mod tests { } /// On `GameWonEvent`, the recording is frozen into a `Replay` and - /// persisted. We point `ReplayPath` at a temp file, fake a win, and - /// load the file back to assert the metadata + move list match. + /// appended to the rolling [`solitaire_data::ReplayHistory`]. We + /// point `ReplayPath` at a temp file, fake a win, and load the + /// history back to assert the just-saved entry sits at the front + /// with the metadata + move list intact. #[test] fn replay_recording_freezes_into_replay_on_game_won() { - use solitaire_data::load_latest_replay_from; + use solitaire_data::load_replay_history_from; let path = std::env::temp_dir().join("engine_test_replay_freeze.json"); let _ = std::fs::remove_file(&path); @@ -1978,8 +2011,14 @@ mod tests { }); app.update(); - let loaded = load_latest_replay_from(&path) + let history = load_replay_history_from(&path) .expect("a winning replay must be persisted to ReplayPath"); + assert_eq!( + history.replays.len(), + 1, + "fresh history must contain exactly the just-recorded win", + ); + let loaded = &history.replays[0]; assert_eq!(loaded.seed, 7654, "seed must match the live game state"); assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured"); assert_eq!(loaded.final_score, 4321, "final_score must come from the win event"); @@ -1998,6 +2037,53 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// Successive `GameWonEvent`s must accumulate in the rolling + /// history rather than overwriting one another. Pre-cap, every win + /// joins the front of `history.replays`. + #[test] + fn replay_recording_appends_to_history_across_wins() { + use solitaire_data::load_replay_history_from; + + let path = std::env::temp_dir().join("engine_test_replay_history_append.json"); + let _ = std::fs::remove_file(&path); + + let mut app = test_app(11); + app.insert_resource(ReplayPath(Some(path.clone()))); + + // First win. + { + let mut recording = app.world_mut().resource_mut::(); + recording.moves.clear(); + recording.moves.push(ReplayMove::StockClick); + } + app.world_mut().write_message(GameWonEvent { + score: 100, + time_seconds: 60, + }); + app.update(); + + // Second win — different score so we can distinguish. + { + let mut recording = app.world_mut().resource_mut::(); + recording.moves.clear(); + recording.moves.push(ReplayMove::StockClick); + recording.moves.push(ReplayMove::StockClick); + } + app.world_mut().write_message(GameWonEvent { + score: 200, + time_seconds: 120, + }); + app.update(); + + let history = load_replay_history_from(&path).expect("history must exist"); + assert_eq!(history.replays.len(), 2, "both wins must be retained"); + // Newest first — second win lands at index 0. + assert_eq!(history.replays[0].final_score, 200); + assert_eq!(history.replays[1].final_score, 100); + + let _ = std::fs::remove_file(&path); + } + /// `GameWonEvent` with an empty recording must NOT touch disk. /// Without this guard, parallel-plugin tests that synthesise /// win events for XP / streak / weekly-goal logic (without diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index f80774c..3f8b038 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -133,7 +133,8 @@ pub use selection_plugin::{ }; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use stats_plugin::{ - format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource, + format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton, + ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen, StatsUpdate, WatchReplayButton, }; pub use sync_plugin::{SyncPlugin, SyncProviderResource}; diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 4746354..9e221e5 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -11,8 +11,8 @@ use std::path::PathBuf; use bevy::input::ButtonInput; use bevy::prelude::*; use solitaire_data::{ - latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path, - PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS, + load_replay_history_from, load_stats_from, replay_history_path, save_stats_to, + stats_file_path, PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS, }; use crate::auto_complete_plugin::AutoCompleteState; @@ -58,30 +58,57 @@ pub struct StatsScreen; #[derive(Component, Debug)] pub struct StatsCell; -/// Resource holding the most recently loaded winning [`Replay`], if any. +/// Resource holding the rolling [`ReplayHistory`] of recent winning +/// replays. /// -/// Populated from `/solitaire_quest/latest_replay.json` at -/// startup and refreshed in-place whenever the engine writes a new -/// winning replay (the path the Stats UI calls into is unchanged so a -/// re-open of the modal sees the latest record). +/// Populated from `/solitaire_quest/replays.json` at startup +/// and refreshed in-place whenever the engine writes a new winning +/// replay so the Stats overlay's selector always reflects the current +/// on-disk history. /// -/// The Stats overlay reads this to decide whether to render the -/// "Watch replay" call-to-action or the "No replay recorded yet" -/// caption. +/// `replays[0]` is the most recent win — the Stats overlay's selector +/// defaults to that entry and lets the player step backwards through +/// up to [`solitaire_data::REPLAY_HISTORY_CAP`] older entries. #[derive(Resource, Debug, Default, Clone)] -pub struct LatestReplayResource(pub Option); +pub struct ReplayHistoryResource(pub ReplayHistory); -/// Persistence path for the latest winning replay file. `None` disables -/// I/O — used by tests and by `StatsPlugin::headless`. +/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`. +/// +/// `0` is the most recent win and is the default on every modal open. +/// The Prev / Next chips wrap-around within the bounds of the current +/// history so the selector is always sat on a valid replay (or on `0` +/// when the history is empty — the chips paint disabled in that case). +#[derive(Resource, Debug, Default, Clone, Copy)] +pub struct SelectedReplayIndex(pub usize); + +/// Persistence path for the rolling replay history file +/// (`replays.json`). `None` disables I/O — used by tests and by +/// `StatsPlugin::headless`. #[derive(Resource, Debug, Clone)] pub struct LatestReplayPath(pub Option); /// Marker on the "Watch replay" button inside the Stats modal. Clicking -/// it currently fires an [`InfoToastEvent`] indicating playback ships -/// in a future build — see [`handle_watch_replay_button`]. +/// it starts in-engine playback of the selected replay — see +/// [`handle_watch_replay_button`]. #[derive(Component, Debug)] pub struct WatchReplayButton; +/// Marker on the selector's "Previous replay" chip — steps the +/// selection backwards (toward older replays) within +/// [`ReplayHistoryResource`]. +#[derive(Component, Debug)] +pub struct ReplayPrevButton; + +/// Marker on the selector's "Next replay" chip — steps the selection +/// forwards (toward more recent replays). +#[derive(Component, Debug)] +pub struct ReplayNextButton; + +/// Marker on the selector's `"Replay N / M"` caption text node so the +/// repaint system can rewrite the label as the selection changes. +#[derive(Component, Debug)] +pub struct ReplaySelectorCaption; + /// Marker component on each per-mode bests row in the stats overlay. /// /// One row per supported [`solitaire_core::game_state::GameMode`] (Classic, @@ -123,14 +150,16 @@ impl Plugin for StatsPlugin { // Replay file lives next to stats.json — when the StatsPlugin // is in headless mode (storage_path = None), we mirror that // policy and disable replay I/O too. Otherwise resolve the - // platform-default path via `latest_replay_path()`. - let replay_path = self.storage_path.as_ref().and(latest_replay_path()); - let initial_replay = replay_path + // platform-default path via `replay_history_path()`. + let replay_path = self.storage_path.as_ref().and(replay_history_path()); + let initial_history = replay_path .as_deref() - .and_then(load_latest_replay_from); + .and_then(load_replay_history_from) + .unwrap_or_default(); app.insert_resource(StatsResource(loaded)) .insert_resource(StatsStoragePath(self.storage_path.clone())) - .insert_resource(LatestReplayResource(initial_replay)) + .insert_resource(ReplayHistoryResource(initial_history)) + .init_resource::() .insert_resource(LatestReplayPath(replay_path)) .add_message::() .add_message::() @@ -160,19 +189,25 @@ impl Plugin for StatsPlugin { .add_systems(Update, handle_stats_close_button) .add_systems( Update, - refresh_latest_replay_on_win.after(GameMutation), + refresh_replay_history_on_win.after(GameMutation), ) - .add_systems(Update, handle_watch_replay_button); + .add_systems(Update, handle_watch_replay_button) + .add_systems( + Update, + (handle_replay_selector_buttons, repaint_replay_selector_caption).chain(), + ); } } -/// After a win, the engine has just persisted a fresh winning replay. -/// Re-load it so the next time the player opens the Stats overlay, the -/// "Watch replay" call-to-action reflects the most recent victory -/// rather than an older session. -fn refresh_latest_replay_on_win( +/// After a win, the engine has just appended a fresh winning replay to +/// the rolling history file. Re-load it so the next time the player +/// opens the Stats overlay the selector reflects the new entry, and +/// reset [`SelectedReplayIndex`] to `0` so the default selection is the +/// just-recorded win. +fn refresh_replay_history_on_win( mut wins: MessageReader, - mut latest: ResMut, + mut history: ResMut, + mut selected: ResMut, path: Res, ) { // Only re-load when at least one win actually fired. @@ -182,28 +217,34 @@ fn refresh_latest_replay_on_win( let Some(p) = path.0.as_deref() else { return; }; - latest.0 = load_latest_replay_from(p); + history.0 = load_replay_history_from(p).unwrap_or_default(); + // Snap the selector back to the most recent win — that's the one + // the player just earned. + selected.0 = 0; } /// Click handler for the "Watch replay" button. /// -/// Starts in-engine replay playback when the Watch Replay button is -/// pressed. If no replay has been recorded yet, surfaces an -/// [`InfoToastEvent`] instead. The playback path resets the live -/// game to the recorded deal and ticks through the move list via -/// [`crate::replay_playback`]; the [`crate::replay_overlay`] banner -/// surfaces while playback runs. +/// Starts in-engine replay playback for the currently-selected entry in +/// [`ReplayHistoryResource`] (per [`SelectedReplayIndex`]). If the +/// history is empty or the selector points past the end (defensive +/// guard), surfaces an [`InfoToastEvent`] instead. The playback path +/// resets the live game to the recorded deal and ticks through the +/// move list via [`crate::replay_playback`]; the +/// [`crate::replay_overlay`] banner surfaces while playback runs. fn handle_watch_replay_button( mut commands: Commands, buttons: Query<&Interaction, (With, Changed)>, - latest: Res, + history: Res, + selected: Res, playback: Option>, mut toast: MessageWriter, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } - match (&latest.0, playback) { + let chosen = history.0.replays.get(selected.0); + match (chosen, playback) { (Some(replay), Some(mut playback)) => { crate::replay_playback::start_replay_playback( &mut commands, @@ -227,6 +268,74 @@ fn handle_watch_replay_button( } } +/// Click handler for the Prev / Next chips on the Stats overlay's +/// replay selector. Steps [`SelectedReplayIndex`] within the bounds of +/// the current [`ReplayHistoryResource`]; selection wraps so the +/// chooser is always sat on a valid replay. +/// +/// No-op when the history is empty — the selector chips paint disabled +/// in that case but a defensive bounds check here keeps things tidy if +/// the click somehow lands. +fn handle_replay_selector_buttons( + prev: Query<&Interaction, (With, Changed)>, + next: Query<&Interaction, (With, Changed)>, + history: Res, + mut selected: ResMut, +) { + let len = history.0.replays.len(); + if len == 0 { + return; + } + let prev_pressed = prev.iter().any(|i| *i == Interaction::Pressed); + let next_pressed = next.iter().any(|i| *i == Interaction::Pressed); + if prev_pressed { + // Step toward older replays — wrap to the oldest when at the + // newest (index 0). + selected.0 = if selected.0 == 0 { len - 1 } else { selected.0 - 1 }; + } + if next_pressed { + // Step toward more recent replays — wrap to the newest when at + // the oldest. + selected.0 = (selected.0 + 1) % len; + } +} + +/// Live-update the `"Replay N / M"` caption text as the selector +/// changes. The caption sits next to the Prev / Next chips above the +/// Watch button so the player can see at a glance which replay they're +/// about to watch. +fn repaint_replay_selector_caption( + history: Res, + selected: Res, + mut q: Query<&mut Text, With>, +) { + if !history.is_changed() && !selected.is_changed() { + return; + } + for mut text in &mut q { + **text = replay_selector_caption(selected.0, history.0.replays.len()); + } +} + +/// Pure helper: render the selector caption shown next to the Prev / +/// Next chips. Returns `"No replays"` when the history is empty, +/// otherwise `"Replay {1-based index} / {total}"`. +/// +/// `index` is zero-based as it's stored in [`SelectedReplayIndex`]. +/// The display flips it to a one-based ordinal so "Replay 1" reads as +/// "the most recent win" — matching the mental model the chooser +/// surfaces. +pub fn replay_selector_caption(index: usize, total: usize) -> String { + if total == 0 { + return "No replays".to_string(); + } + // Defensive clamp — the caller is supposed to keep `index` in + // range, but a stale selector after a cap-driven truncation + // shouldn't crash the renderer. + let one_based = index.min(total.saturating_sub(1)) + 1; + format!("Replay {one_based} / {total}") +} + /// Pure helper: render a one-line caption for a [`Replay`] suitable /// for the Stats overlay button label and the "Replay loaded" toast. /// @@ -376,7 +485,8 @@ fn toggle_stats_screen( progress: Option>, time_attack: Option>, font_res: Option>, - latest_replay: Res, + latest_replay: Res, + selected_index: Res, screens: Query>, ) { let button_clicked = requests.read().count() > 0; @@ -386,13 +496,14 @@ fn toggle_stats_screen( if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else { + let selected = latest_replay.0.replays.get(selected_index.0); spawn_stats_screen( &mut commands, &stats.0, progress.as_deref().map(|p| &p.0), time_attack.as_deref(), font_res.as_deref(), - latest_replay.0.as_ref(), + selected, ); } }