diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 31abc1c..0f76bf4 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -99,8 +99,11 @@ pub use stats::{StatsExt, StatsSnapshot}; pub mod storage; pub use storage::{ - cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from, - load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path, + cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at, + game_state_file_path, load_game_state_from, load_stats, load_stats_from, + load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to, + save_stats, save_stats_to, save_time_attack_session_to, stats_file_path, + time_attack_session_path, time_attack_session_with_now, TimeAttackSession, }; pub mod achievements; diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index c4eceae..478de25 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -6,7 +6,9 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION}; use crate::stats::StatsSnapshot; @@ -14,6 +16,7 @@ use crate::stats::StatsSnapshot; const APP_DIR_NAME: &str = "solitaire_quest"; const STATS_FILE_NAME: &str = "stats.json"; const GAME_STATE_FILE_NAME: &str = "game_state.json"; +const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json"; /// Returns the platform-specific path to `stats.json`, or `None` if /// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). @@ -139,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Time Attack session (mode-specific sibling of game_state.json) +// --------------------------------------------------------------------------- +// +// `GameState` carries `mode: GameMode`, so an in-progress Zen / Challenge / +// Classic / TimeAttack deal is already round-tripped through `game_state.json` +// — closing the window mid-deal in any of those modes restores the deal on +// next launch. Time Attack adds a 10-minute session window and a per-session +// win counter that live OUTSIDE `GameState` (in `TimeAttackResource` on the +// engine side), so they are NOT covered by the game-state save/load. This +// sibling file persists just that extra session-level state. +// +// The Bevy plugin layer (`solitaire_engine::time_attack_plugin`) is the only +// caller. The file lives next to `game_state.json` in the same data dir and +// is written using the same `.tmp` → rename atomic-write contract that the +// rest of `storage.rs` uses. + +/// Persisted state for an in-progress Time Attack session. +/// +/// Fields mirror the live `TimeAttackResource` minus the `active` flag (the +/// presence of the file *is* the active flag — a missing file means no +/// session in progress). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TimeAttackSession { + /// Seconds remaining in the 10-minute window when the save was written. + pub remaining_secs: f32, + /// Wins accumulated during the session so far. + pub wins: u32, + /// Wall-clock instant the save was written, as unix seconds. Used at + /// load time to detect whether the session window expired in real + /// time while the app was closed and to decrement `remaining_secs` + /// by the real elapsed time so the resumed session reflects how + /// long the window has actually been running. + pub saved_at_unix_secs: u64, +} + +/// Returns the platform-specific path to `time_attack_session.json`, or +/// `None` if `dirs::data_dir()` is unavailable. +pub fn time_attack_session_path() -> Option { + dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME)) +} + +/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s +/// `.tmp` → rename contract. +pub fn save_time_attack_session_to(path: &Path, session: &TimeAttackSession) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(session).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 Time Attack session from `path`, decrementing `remaining_secs` +/// by the wall-clock time elapsed between the save and now. +/// +/// Returns `None` when: +/// - the file is missing or unreadable, +/// - the JSON is corrupt / malformed, or +/// - the session window expired during the time the app was closed +/// (`saved_at_unix_secs + remaining_secs <= now_unix_secs`). +/// +/// The `now_unix_secs` parameter is injectable so unit tests can simulate +/// arbitrary wall-clock gaps without touching the real system clock. The +/// public companion [`load_time_attack_session_from`] resolves "now" from +/// `SystemTime::now()`. +pub fn load_time_attack_session_from_at( + path: &Path, + now_unix_secs: u64, +) -> Option { + let data = fs::read(path).ok()?; + let session: TimeAttackSession = serde_json::from_slice(&data).ok()?; + // Compute wall-clock elapsed seconds since the save was written. + // Saturating subtraction guards against a clock that moved backwards + // (rare, but possible across NTP corrections or VM clock drift). + let elapsed = now_unix_secs.saturating_sub(session.saved_at_unix_secs); + let remaining = session.remaining_secs - elapsed as f32; + if remaining <= 0.0 { + return None; + } + Some(TimeAttackSession { + remaining_secs: remaining, + wins: session.wins, + saved_at_unix_secs: session.saved_at_unix_secs, + }) +} + +/// Load a Time Attack session from `path`, using `SystemTime::now()` as +/// the reference for the wall-clock-elapsed adjustment. +/// +/// See [`load_time_attack_session_from_at`] for the rules under which +/// the call returns `None` (missing file, corrupt JSON, expired window). +pub fn load_time_attack_session_from(path: &Path) -> Option { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + load_time_attack_session_from_at(path, now) +} + +/// Delete the Time Attack session file (called on session end, on session +/// start, or on game completion). Silently ignores `NotFound` errors. +pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Convenience helper for callers that want to stamp a session with the +/// current wall-clock time. Equivalent to constructing the struct +/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`. +pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + TimeAttackSession { + remaining_secs, + wins, + saved_at_unix_secs: now, + } +} + /// Inner helper: delete `*.json.tmp` entries inside `dir`. /// /// Per-file errors (already deleted, permission denied) are silently ignored. @@ -387,4 +515,190 @@ mod tests { let loaded = load_stats_from(&stats_path); assert_eq!(loaded, StatsSnapshot::default()); } + + // ----------------------------------------------------------------------- + // Time Attack session persistence + // + // Documents the contract that closing the window mid-Time-Attack does + // NOT lose the 10-minute window or the running win count. Classic / + // Zen / Challenge are covered by `game_state.json` because their entire + // mid-deal state lives in `GameState.mode` + `GameState.piles`; Time + // Attack additionally needs the session timer + wins counter, both of + // which live in `TimeAttackResource` on the engine side and are NOT + // part of `GameState`. This sibling file persists exactly that. + // ----------------------------------------------------------------------- + + fn ta_path(name: &str) -> PathBuf { + env::temp_dir().join(format!("solitaire_test_ta_{name}.json")) + } + + /// Round-trip a session that was saved "just now" (zero wall-clock + /// elapsed). All three persisted fields must come back unchanged. + #[test] + fn time_attack_session_round_trips_through_save_and_load() { + let path = ta_path("round_trip"); + let _ = fs::remove_file(&path); + + // Use a fixed unix timestamp so the load step (which receives the + // SAME timestamp as "now") sees zero wall-clock elapsed. + let saved_at: u64 = 1_800_000_000; + let session = TimeAttackSession { + remaining_secs: 240.0, + wins: 3, + saved_at_unix_secs: saved_at, + }; + save_time_attack_session_to(&path, &session).expect("save"); + + let loaded = load_time_attack_session_from_at(&path, saved_at) + .expect("session must load when not yet expired"); + assert!( + (loaded.remaining_secs - 240.0).abs() < 0.01, + "remaining_secs must be unchanged when no wall-clock time has passed; got {}", + loaded.remaining_secs, + ); + assert_eq!(loaded.wins, 3, "wins must round-trip"); + assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip"); + + let _ = fs::remove_file(&path); + } + + /// A session whose window expired entirely between launches must be + /// discarded on load — the caller starts fresh rather than resuming a + /// dead session. + #[test] + fn time_attack_session_discarded_when_expired_between_launches() { + let path = ta_path("expired"); + let _ = fs::remove_file(&path); + + // Saved 20 minutes ago with 240 s remaining — long expired. + let saved_at: u64 = 1_800_000_000; + let session = TimeAttackSession { + remaining_secs: 240.0, + wins: 5, + saved_at_unix_secs: saved_at, + }; + save_time_attack_session_to(&path, &session).expect("save"); + + // 20 minutes (1200 s) later → 240 - 1200 = -960 s remaining. + let now = saved_at + 1200; + assert!( + load_time_attack_session_from_at(&path, now).is_none(), + "an expired session must return None so the player starts fresh", + ); + + let _ = fs::remove_file(&path); + } + + /// The `remaining_secs` returned at load time must be the persisted + /// value minus the wall-clock seconds that elapsed while the app was + /// closed. + #[test] + fn time_attack_session_remaining_secs_decremented_by_real_elapsed() { + let path = ta_path("decremented"); + let _ = fs::remove_file(&path); + + let saved_at: u64 = 1_800_000_000; + let session = TimeAttackSession { + remaining_secs: 240.0, + wins: 2, + saved_at_unix_secs: saved_at, + }; + save_time_attack_session_to(&path, &session).expect("save"); + + // 60 s elapsed in real time → expect 180 s remaining. + let now = saved_at + 60; + let loaded = load_time_attack_session_from_at(&path, now) + .expect("session must still load — 180 s left"); + assert!( + (loaded.remaining_secs - 180.0).abs() < 5.0, + "remaining_secs ≈ 180 ± 5 s after a 60 s wall-clock gap; got {}", + loaded.remaining_secs, + ); + assert_eq!(loaded.wins, 2, "wins must survive the elapsed adjustment"); + + let _ = fs::remove_file(&path); + } + + /// Atomic-write contract — `.tmp` must not be left behind after + /// `save_time_attack_session_to` returns. + #[test] + fn time_attack_session_save_is_atomic() { + let path = ta_path("atomic"); + let session = TimeAttackSession { + remaining_secs: 100.0, + wins: 0, + saved_at_unix_secs: 1_800_000_000, + }; + save_time_attack_session_to(&path, &session).expect("save"); + let tmp = path.with_extension("json.tmp"); + assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); + let _ = fs::remove_file(&path); + } + + /// Loading from a path that does not exist must return `None`, not + /// panic. + #[test] + fn time_attack_session_missing_file_returns_none() { + let path = ta_path("missing_xyz"); + let _ = fs::remove_file(&path); + assert!(load_time_attack_session_from_at(&path, 0).is_none()); + } + + /// Loading from a corrupt / partially-written file must return `None`, + /// not surface a deserialiser error. + #[test] + fn time_attack_session_corrupt_file_returns_none() { + let path = ta_path("corrupt"); + fs::write(&path, b"not valid json!!!").expect("write"); + assert!(load_time_attack_session_from_at(&path, 0).is_none()); + let _ = fs::remove_file(&path); + } + + /// `delete_time_attack_session_at` removes the file when it exists + /// and returns `Ok(())` when it does not. + #[test] + fn time_attack_session_delete_handles_present_and_absent() { + let path = ta_path("delete"); + let session = TimeAttackSession { + remaining_secs: 50.0, + wins: 0, + saved_at_unix_secs: 1_800_000_000, + }; + save_time_attack_session_to(&path, &session).expect("save"); + assert!(path.exists()); + delete_time_attack_session_at(&path).expect("delete"); + assert!(!path.exists()); + // Second delete on the now-absent file must succeed. + delete_time_attack_session_at(&path).expect("missing-file delete is ok"); + } + + /// A session whose `saved_at_unix_secs` is in the future (e.g. the + /// system clock moved backward across NTP correction) must NOT be + /// rejected as expired. Saturating subtraction must clamp the + /// "elapsed" value to zero. + #[test] + fn time_attack_session_handles_clock_running_backwards() { + let path = ta_path("clock_backwards"); + let _ = fs::remove_file(&path); + + let saved_at: u64 = 1_800_000_000; + let session = TimeAttackSession { + remaining_secs: 60.0, + wins: 1, + saved_at_unix_secs: saved_at, + }; + save_time_attack_session_to(&path, &session).expect("save"); + + // "now" is BEFORE the saved time — should not crash, should not expire. + let now_in_past = saved_at - 100; + let loaded = load_time_attack_session_from_at(&path, now_in_past) + .expect("clock-backwards must not discard the session"); + assert!( + (loaded.remaining_secs - 60.0).abs() < 0.01, + "remaining_secs must clamp elapsed to 0 when clock ran backwards; got {}", + loaded.remaining_secs, + ); + + let _ = fs::remove_file(&path); + } } diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index 709d892..013195d 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -3,9 +3,33 @@ //! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the //! counter and auto-deals a fresh game. When the timer expires the session //! ends and `TimeAttackEndedEvent` fires. +//! +//! ## Persistence +//! +//! Classic / Zen / Challenge mid-deals already round-trip through +//! `game_state.json` (the file carries `mode: GameMode`, so the deal *and* +//! its mode flag both survive a window close). Time Attack additionally +//! has session-level state — the 10-minute window remaining and the running +//! win counter — that lives in [`TimeAttackResource`], not in `GameState`. +//! That extra state is persisted to the sibling file +//! `time_attack_session.json` via [`solitaire_data::TimeAttackSession`] so +//! closing the window mid-Time-Attack does not lose the session. +//! +//! The file is written periodically (every ~30 real seconds, mirroring the +//! game-state auto-save cadence) and on `AppExit`. It is deleted on session +//! end, on a fresh session start, and on quit-to-menu. Load happens once at +//! plugin startup; if the persisted window expired during the time the app +//! was closed, the file is treated as missing. + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use bevy::prelude::*; use solitaire_core::game_state::GameMode; +use solitaire_data::{ + delete_time_attack_session_at, load_time_attack_session_from, save_time_attack_session_to, + time_attack_session_path, TimeAttackSession, +}; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::events::{ @@ -33,12 +57,52 @@ pub struct TimeAttackEndedEvent { pub wins: u32, } +/// Real-world seconds between Time Attack session-state auto-saves. +/// +/// Mirrors the game-state auto-save cadence in `game_plugin::AUTO_SAVE_INTERVAL_SECS` +/// so a crash loses at most ~30 s of session-timer progress. +const TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS: f32 = 30.0; + +/// Persistence path for `time_attack_session.json`. `None` disables I/O +/// (used in headless tests so they don't touch the real data dir). +#[derive(Resource, Debug, Clone)] +pub struct TimeAttackSessionPath(pub Option); + +/// Accumulated real-world seconds since the last Time Attack session save. +/// Exposed as a `Resource` so tests can pre-seed it past the threshold without +/// needing to control `Time::delta_secs()` (mirrors `game_plugin::AutoSaveTimer`). +#[derive(Resource, Default)] +pub struct TimeAttackAutoSaveTimer(pub f32); + /// Implements the 10-minute Time Attack mode: counts down the session timer, tracks wins per session, and fires `TimeAttackEndedEvent` when time expires. pub struct TimeAttackPlugin; +impl TimeAttackPlugin { + /// Plugin variant with persistence disabled. Use in headless tests to + /// avoid touching the real `time_attack_session.json` on disk. + pub fn headless() -> Self { + Self + } +} + impl Plugin for TimeAttackPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + let path = time_attack_session_path(); + // Restore any saved session that hasn't yet expired in real time. + // A missing file or an expired window both yield `None`, in which + // case the resource keeps its default (inactive) value. + let initial_session = path + .as_deref() + .and_then(load_time_attack_session_from) + .map_or_else(TimeAttackResource::default, |s| TimeAttackResource { + active: true, + remaining_secs: s.remaining_secs, + wins: s.wins, + }); + + app.insert_resource(initial_session) + .insert_resource(TimeAttackSessionPath(path)) + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -49,10 +113,13 @@ impl Plugin for TimeAttackPlugin { handle_start_time_attack_request.before(GameMutation), ) .add_systems(Update, advance_time_attack) - .add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation)); + .add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation)) + .add_systems(Update, auto_save_time_attack_session) + .add_systems(Last, save_time_attack_session_on_exit); } } +#[allow(clippy::too_many_arguments)] fn handle_start_time_attack_request( keys: Res>, mut requests: MessageReader, @@ -60,6 +127,8 @@ fn handle_start_time_attack_request( mut session: ResMut, mut new_game: MessageWriter, mut info_toast: MessageWriter, + path: Option>, + mut auto_save_timer: ResMut, ) { // Either T or the HUD Modes-popover "Time Attack" row triggers this. let button_clicked = requests.read().count() > 0; @@ -77,6 +146,18 @@ fn handle_start_time_attack_request( remaining_secs: TIME_ATTACK_DURATION_SECS, wins: 0, }; + // Reset the auto-save accumulator so the first save lands a full + // interval from now, not immediately because of an old residual value + // left over from a previous session. + auto_save_timer.0 = 0.0; + // Delete any leftover persisted session file from a prior run so the + // fresh window starts at exactly TIME_ATTACK_DURATION_SECS rather than + // resuming whatever the disk happened to hold. Failures here are + // logged but never fatal. + if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) + && let Err(e) = delete_time_attack_session_at(p) { + warn!("time_attack_session: failed to delete stale session: {e}"); + } new_game.write(NewGameRequestEvent { seed: None, mode: Some(GameMode::TimeAttack), @@ -89,6 +170,7 @@ fn advance_time_attack( mut session: ResMut, mut ended: MessageWriter, paused: Option>, + path: Option>, ) { if !session.active { return; @@ -102,6 +184,12 @@ fn advance_time_attack( session.active = false; session.remaining_secs = 0.0; ended.write(TimeAttackEndedEvent { wins }); + // Session ended naturally — delete the persisted file so the next + // launch sees no in-progress session. + if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) + && let Err(e) = delete_time_attack_session_at(p) { + warn!("time_attack_session: failed to delete on expiry: {e}"); + } } } @@ -124,6 +212,80 @@ fn auto_deal_on_time_attack_win( } } +/// Returns the current Unix-seconds wall-clock time, falling back to 0 if +/// the system time predates the epoch (impossible under any sane clock, +/// but the fallback keeps the function infallible). +fn current_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Periodically persists the live `TimeAttackResource` to +/// `time_attack_session.json` every 30 real-world seconds while a session +/// is active. The accumulator uses real-clock delta so it keeps ticking +/// even if the in-game timer is paused — the goal is "if the OS kills the +/// process now, how much do we lose?" and pause does not change that. +fn auto_save_time_attack_session( + time: Res