//! Routes game-request events to `solitaire_core::GameState` and emits //! state-change notifications. //! //! Game state persistence: on startup the plugin attempts to restore an //! in-progress game from `game_state.json`. On app exit the current state is //! written back (unless the game is won). On a win or new-game request the //! file is deleted so the next launch starts fresh. use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use bevy::prelude::*; use chrono::Utc; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; 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, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, }; use crate::font_plugin::FontResource; use crate::resources::{DragState, GameStateResource, SyncStatusResource}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header, ButtonVariant, }; use crate::ui_theme; // --------------------------------------------------------------------------- // Task #57 — Confirm-new-game dialog // --------------------------------------------------------------------------- /// Marker on the confirm-new-game modal root node. #[derive(Component, Debug)] pub struct ConfirmNewGameScreen; // --------------------------------------------------------------------------- // Task #58 — Game-over overlay // --------------------------------------------------------------------------- /// Marker on the game-over overlay root node. #[derive(Component, Debug)] pub struct GameOverScreen; /// System set for `GamePlugin`'s state-mutating systems. Downstream plugins /// that read the resulting `StateChangedEvent` should schedule themselves /// `.after(GameMutation)` so updates propagate within a single frame. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct GameMutation; /// Persistence path for the in-progress game state file. `None` disables I/O. #[derive(Resource, Debug, Clone)] pub struct GameStatePath(pub Option); /// 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); /// In-memory accumulator for [`ReplayMove`] entries during the current /// game. Cleared on every new-game start; frozen into a [`Replay`] and /// flushed to disk by [`record_replay_on_win`] when the player wins. /// /// Recording captures only successful state-mutating events the player /// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is /// intentionally not recorded — see [`solitaire_data::replay`] for the /// design rationale. #[derive(Resource, Debug, Default, Clone)] pub struct RecordingReplay { /// Ordered list of moves applied so far this game. pub moves: Vec, } impl RecordingReplay { /// Reset the recording. Called on every `NewGameRequestEvent` so a /// fresh deal starts with an empty move list. pub fn clear(&mut self) { self.moves.clear(); } } /// Registers game resources, events, and the systems that route user intent /// (events) into mutations on `GameState`. pub struct GamePlugin; impl GamePlugin { /// Plugin with no persistence. Use in headless tests to avoid touching the /// real `game_state.json` on disk. pub fn headless() -> Self { Self } } impl Plugin for GamePlugin { fn build(&self, app: &mut App) { let path = game_state_file_path(); // Restore any saved in-progress game, falling back to a fresh deal. let initial_state = path .as_deref() .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(history_path)) .init_resource::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( handle_new_game, handle_draw, handle_move, handle_undo, ) .chain() .in_set(GameMutation), ) .add_systems(Update, check_no_moves.after(GameMutation)) .add_systems(Update, record_replay_on_win.after(GameMutation)) .add_systems(Update, handle_confirm_input.after(GameMutation)) .add_systems(Update, handle_confirm_button_input.after(GameMutation)) .add_systems(Update, handle_game_over_input.after(GameMutation)) .add_systems(Update, handle_game_over_button_input.after(GameMutation)) .init_resource::() .add_systems(Update, tick_elapsed_time) .add_systems(Update, auto_save_game_state) .add_systems(Last, save_game_state_on_exit); } } /// Pure, testable helper. Updates `elapsed_seconds` and drains the /// fractional accumulator into whole-second ticks. No-op when `is_won`. pub fn advance_elapsed( elapsed_seconds: &mut u64, accumulator: &mut f32, delta_secs: f32, is_won: bool, ) { if is_won { return; } *accumulator += delta_secs; while *accumulator >= 1.0 { *elapsed_seconds = elapsed_seconds.saturating_add(1); *accumulator -= 1.0; } } /// Increment `GameState.elapsed_seconds` once per real-world second while /// the game is in progress (not won) and not paused. Stops counting on /// win so the final time reflects how long the player took to solve the /// deal; stops while the pause overlay is open. fn tick_elapsed_time( time: Res