//! 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 bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use chrono::Utc; use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use solitaire_core::pile::PileType; use solitaire_core::solver::{try_solve, SolverConfig, SolverResult}; 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, SOLVER_DEAL_RETRY_CAP, }; #[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); /// Holds the saved-on-disk in-progress game between plugin build and /// the player's answer to the "Continue or start a new game?" prompt. /// /// Some(game) at startup means a previously-saved game existed and had /// real moves on it. The restore-prompt modal swaps it into /// `GameStateResource` if the player picks Continue, or drops it (and /// lets `handle_new_game` clean up the disk file) on New Game. None for /// first-launch installs and for save files that contain a fresh deal /// with no moves yet — there's nothing meaningful to "continue" there. #[derive(Resource, Debug, Default)] pub struct PendingRestoredGame(pub Option); /// Marker on the "Welcome back — Continue or start a new game?" modal /// scrim. Despawning the scrim cascades to the card and children, so a /// single `commands.entity(scrim).despawn()` tears the modal down. #[derive(Component, Debug)] pub struct RestorePromptScreen; /// Marker on the modal's primary "Continue" button. #[derive(Component, Debug)] pub struct RestoreContinueButton; /// Marker on the modal's secondary "New game" button. #[derive(Component, Debug)] pub struct RestoreNewGameButton; /// 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(); // Try to load any saved in-progress game. We don't want to // silently restore a half-played game on launch — the player // should get to decide between continuing and starting fresh. // So: if there IS a saved game with progress and it isn't // already won, hold it in `PendingRestoredGame` and let the // restore-prompt modal swap it into `GameStateResource` if // the player picks Continue. Otherwise put it directly into // `GameStateResource` (existing behaviour for un-played / // won deals which there's nothing to ask about). let saved = path.as_deref().and_then(load_game_state_from); let prompt_worthy = saved .as_ref() .is_some_and(|g| g.move_count > 0 && !g.is_won); let (initial_state, pending_restore) = if prompt_worthy { ( GameState::new(seed_from_system_time(), DrawMode::DrawOne), saved, ) } else { ( saved.unwrap_or_else(|| { GameState::new(seed_from_system_time(), DrawMode::DrawOne) }), None, ) }; // 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)) .insert_resource(PendingRestoredGame(pending_restore)) .init_resource::() .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, poll_pending_new_game_seed.before(GameMutation), ) .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)) // Restore prompt: spawn the modal once the splash is gone, // route Continue / New Game intents back into the existing // GameMutation flow. .add_systems(Update, spawn_restore_prompt_if_pending) .add_systems(Update, handle_restore_prompt.before(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), not paused, and the launch / /// mode-picker Home modal isn't covering the board. 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; stops while Home /// is up so the timer doesn't tick under the picker before the player /// has actually committed to a deal. fn tick_elapsed_time( time: Res