//! Time Attack mode runtime: 10-minute countdown wrapped around back-to-back //! `GameMode::TimeAttack` games. Pressing **T** starts a session (gated by //! 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::{ GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartTimeAttackRequestEvent, }; use crate::game_plugin::GameMutation; use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; /// Length of a Time Attack session in real-world seconds (10 minutes). pub const TIME_ATTACK_DURATION_SECS: f32 = 600.0; /// Session state for an in-progress Time Attack run. Not persisted. #[derive(Resource, Debug, Clone, Default)] pub struct TimeAttackResource { pub active: bool, pub remaining_secs: f32, pub wins: u32, } /// Fired when the Time Attack timer expires. The summary toast in /// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe. #[derive(Message, Debug, Clone, Copy)] 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) { 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::() .add_message::() .add_message::() .add_systems( Update, 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_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, progress: Res, 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; if !keys.just_pressed(KeyCode::KeyT) && !button_clicked { return; } if progress.0.level < CHALLENGE_UNLOCK_LEVEL { info_toast.write(InfoToastEvent(format!( "Time Attack unlocks at level {CHALLENGE_UNLOCK_LEVEL}" ))); return; } *session = TimeAttackResource { active: true, 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), confirmed: false, }); } fn advance_time_attack( time: Res