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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>);
|
||||
|
||||
/// 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<PathBuf>);
|
||||
|
||||
@@ -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::<RecordingReplay>()
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
@@ -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
|
||||
/// `<data_dir>/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<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -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::<RecordingReplay>();
|
||||
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::<RecordingReplay>();
|
||||
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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 `<data_dir>/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 `<data_dir>/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<Replay>);
|
||||
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<PathBuf>);
|
||||
|
||||
/// 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::<SelectedReplayIndex>()
|
||||
.insert_resource(LatestReplayPath(replay_path))
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
@@ -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<GameWonEvent>,
|
||||
mut latest: ResMut<LatestReplayResource>,
|
||||
mut history: ResMut<ReplayHistoryResource>,
|
||||
mut selected: ResMut<SelectedReplayIndex>,
|
||||
path: Res<LatestReplayPath>,
|
||||
) {
|
||||
// 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<WatchReplayButton>, Changed<Interaction>)>,
|
||||
latest: Res<LatestReplayResource>,
|
||||
history: Res<ReplayHistoryResource>,
|
||||
selected: Res<SelectedReplayIndex>,
|
||||
playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<ReplayPrevButton>, Changed<Interaction>)>,
|
||||
next: Query<&Interaction, (With<ReplayNextButton>, Changed<Interaction>)>,
|
||||
history: Res<ReplayHistoryResource>,
|
||||
mut selected: ResMut<SelectedReplayIndex>,
|
||||
) {
|
||||
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<ReplayHistoryResource>,
|
||||
selected: Res<SelectedReplayIndex>,
|
||||
mut q: Query<&mut Text, With<ReplaySelectorCaption>>,
|
||||
) {
|
||||
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<Res<ProgressResource>>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
latest_replay: Res<LatestReplayResource>,
|
||||
latest_replay: Res<ReplayHistoryResource>,
|
||||
selected_index: Res<SelectedReplayIndex>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user