From 00f0383867d6c256e8fe046fc940bcd334d57105 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 00:17:47 +0000 Subject: [PATCH] feat(engine): in-progress game state persistence Save game_state.json on app exit and on pause open so players can resume interrupted sessions. Delete the file on win, loss, or new-game start. Restore the saved game on launch if it exists and isn't won. - solitaire_core: add pile_map_serde module so HashMap round-trips through JSON (serialized as Vec of pairs) - solitaire_data: add game_state_file_path, load_game_state_from, save_game_state_to, delete_game_state_at with 8 new unit tests - solitaire_engine/GamePlugin: restore saved game on startup, expose GameStatePath resource, save on AppExit, delete on new-game and win - solitaire_engine/PausePlugin: save on pause open (guards against OS-level kills while the overlay is showing) Co-Authored-By: Claude Sonnet 4.6 --- solitaire_core/src/game_state.rs | 20 ++++ solitaire_data/src/lib.rs | 4 +- solitaire_data/src/storage.rs | 147 ++++++++++++++++++++++++++- solitaire_engine/src/game_plugin.rs | 125 +++++++++++++++++++++-- solitaire_engine/src/lib.rs | 2 +- solitaire_engine/src/pause_plugin.rs | 15 +++ 6 files changed, 303 insertions(+), 10 deletions(-) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 64c966c..a275bdf 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -9,6 +9,24 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score const MAX_UNDO_STACK: usize = 64; +/// Serialize `HashMap` as a `Vec` of `(key, value)` pairs so +/// that JSON (which requires string map keys) round-trips correctly. +mod pile_map_serde { + use std::collections::HashMap; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use crate::pile::{Pile, PileType}; + + pub fn serialize(map: &HashMap, s: S) -> Result { + let entries: Vec<(&PileType, &Pile)> = map.iter().collect(); + entries.serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?; + Ok(entries.into_iter().collect()) + } +} + /// Whether cards are drawn one at a time or three at a time from the stock. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { @@ -37,6 +55,7 @@ pub enum GameMode { /// Snapshot of game state used for undo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct StateSnapshot { + #[serde(with = "pile_map_serde")] piles: HashMap, score: i32, move_count: u32, @@ -45,6 +64,7 @@ struct StateSnapshot { /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GameState { + #[serde(with = "pile_map_serde")] pub piles: HashMap, pub draw_mode: DrawMode, /// Top-level mode (Classic / Zen). Defaults to Classic for backwards diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index fc4ce29..c61d043 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -61,8 +61,8 @@ pub use stats::{StatsExt, StatsSnapshot}; pub mod storage; pub use storage::{ - cleanup_orphaned_tmp_files, load_stats, load_stats_from, save_stats, save_stats_to, - stats_file_path, + 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, }; pub mod achievements; diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 12c9596..ca1d041 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -1,4 +1,4 @@ -//! Atomic file I/O for `StatsSnapshot` persistence. +//! Atomic file I/O for persisted game data. //! //! All saves go through `filename.json.tmp` → `rename()` so a crash or power //! loss during a write never corrupts the saved data. @@ -7,10 +7,13 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use solitaire_core::game_state::GameState; + 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"; /// Returns the platform-specific path to `stats.json`, or `None` if /// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). @@ -58,6 +61,54 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> { save_stats_to(&path, stats) } +// --------------------------------------------------------------------------- +// In-progress game state +// --------------------------------------------------------------------------- + +/// Returns the platform-specific path to `game_state.json`, or `None` if +/// `dirs::data_dir()` is unavailable. +pub fn game_state_file_path() -> Option { + dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME)) +} + +/// Load an in-progress `GameState` from `path`. Returns `None` if the file is +/// missing, corrupt, or represents a finished game. +pub fn load_game_state_from(path: &Path) -> Option { + let data = fs::read(path).ok()?; + let gs: GameState = serde_json::from_slice(&data).ok()?; + if gs.is_won { + None + } else { + Some(gs) + } +} + +/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` +/// because a completed game should not be resumed. +pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> { + if gs.is_won { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(gs).map_err(io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json.as_bytes())?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// Delete the game state file (called on win, loss, or new-game start). +/// Silently ignores `NotFound` errors. +pub fn delete_game_state_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), + } +} + /// Remove any leftover `*.json.tmp` files in the app data directory. /// /// These can be left behind if the process crashes between the write and rename @@ -187,4 +238,98 @@ mod tests { // The function is allowed to succeed whether or not the dir exists. assert!(result.is_ok()); } + + // ----------------------------------------------------------------------- + // game_state persistence tests + // ----------------------------------------------------------------------- + + fn gs_path(name: &str) -> PathBuf { + env::temp_dir().join(format!("solitaire_test_gs_{name}.json")) + } + + #[test] + fn game_state_round_trip() { + use solitaire_core::game_state::{DrawMode, GameState}; + let path = gs_path("round_trip"); + let _ = fs::remove_file(&path); + + let gs = GameState::new(12345, DrawMode::DrawOne); + save_game_state_to(&path, &gs).expect("save"); + + let loaded = load_game_state_from(&path).expect("load"); + assert_eq!(loaded.seed, gs.seed); + assert_eq!(loaded.draw_mode, gs.draw_mode); + assert!(!loaded.is_won); + } + + #[test] + fn load_game_state_missing_file_returns_none() { + let path = gs_path("missing_xyz"); + let _ = fs::remove_file(&path); + assert!(load_game_state_from(&path).is_none()); + } + + #[test] + fn load_game_state_corrupt_file_returns_none() { + let path = gs_path("corrupt"); + fs::write(&path, b"not valid json!!!").expect("write"); + assert!(load_game_state_from(&path).is_none()); + } + + #[test] + fn save_game_state_skips_won_games() { + use solitaire_core::game_state::{DrawMode, GameState}; + let path = gs_path("won_skip"); + let _ = fs::remove_file(&path); + + let mut gs = GameState::new(99, DrawMode::DrawOne); + gs.is_won = true; + save_game_state_to(&path, &gs).expect("save should be no-op, not error"); + assert!(!path.exists(), "should not have written a file for a won game"); + } + + #[test] + fn load_game_state_ignores_won_games() { + use solitaire_core::game_state::{DrawMode, GameState}; + let path = gs_path("won_load"); + let _ = fs::remove_file(&path); + + // Write a won game directly (bypassing save_game_state_to's guard). + let mut gs = GameState::new(77, DrawMode::DrawOne); + gs.is_won = true; + let json = serde_json::to_string_pretty(&gs).unwrap(); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json.as_bytes()).unwrap(); + fs::rename(&tmp, &path).unwrap(); + + assert!(load_game_state_from(&path).is_none()); + } + + #[test] + fn delete_game_state_removes_file() { + use solitaire_core::game_state::{DrawMode, GameState}; + let path = gs_path("delete"); + let gs = GameState::new(1, DrawMode::DrawOne); + save_game_state_to(&path, &gs).expect("save"); + assert!(path.exists()); + delete_game_state_at(&path).expect("delete"); + assert!(!path.exists()); + } + + #[test] + fn delete_game_state_missing_file_is_ok() { + let path = gs_path("delete_missing"); + let _ = fs::remove_file(&path); + assert!(delete_game_state_at(&path).is_ok()); + } + + #[test] + fn save_game_state_is_atomic() { + use solitaire_core::game_state::{DrawMode, GameState}; + let path = gs_path("atomic"); + let gs = GameState::new(55, DrawMode::DrawThree); + save_game_state_to(&path, &gs).expect("save"); + let tmp = path.with_extension("json.tmp"); + assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); + } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index cd80d33..312693f 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -1,10 +1,18 @@ //! 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 solitaire_core::game_state::{DrawMode, GameState}; +use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state_from, + save_game_state_to}; use crate::events::{ DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, @@ -18,16 +26,33 @@ use crate::resources::{DragState, GameStateResource, SyncStatusResource}; #[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); + /// 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) { - app.insert_resource(GameStateResource(GameState::new( - seed_from_system_time(), - DrawMode::DrawOne, - ))) + 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)); + + app.insert_resource(GameStateResource(initial_state)) + .insert_resource(GameStatePath(path)) .init_resource::() .init_resource::() .add_event::() @@ -50,7 +75,8 @@ impl Plugin for GamePlugin { .chain() .in_set(GameMutation), ) - .add_systems(Update, tick_elapsed_time); + .add_systems(Update, tick_elapsed_time) + .add_systems(Last, save_game_state_on_exit); } } @@ -106,6 +132,7 @@ fn handle_new_game( mut game: ResMut, mut changed: EventWriter, settings: Option>, + path: Option>, ) { for ev in new_game.read() { let seed = ev.seed.unwrap_or_else(seed_from_system_time); @@ -118,6 +145,12 @@ fn handle_new_game( .unwrap_or_else(|| game.0.draw_mode.clone()); let mode = ev.mode.unwrap_or(game.0.mode); game.0 = GameState::new_with_mode(seed, draw_mode, mode); + // Delete any previously saved in-progress state — this is a fresh game. + if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) { + if let Err(e) = delete_game_state_at(p) { + warn!("game_state: failed to delete saved game: {e}"); + } + } changed.send(StateChangedEvent); } } @@ -142,6 +175,7 @@ fn handle_move( mut game: ResMut, mut changed: EventWriter, mut won: EventWriter, + path: Option>, ) { for ev in moves.read() { let was_won = game.0.is_won; @@ -153,6 +187,12 @@ fn handle_move( score: game.0.score, time_seconds: game.0.elapsed_seconds, }); + // Delete the saved state — a won game should not be resumed. + if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) { + if let Err(e) = delete_game_state_at(p) { + warn!("game_state: failed to delete on win: {e}"); + } + } } } Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count), @@ -175,16 +215,38 @@ fn handle_undo( } } +/// Last-schedule system: persists the current game state on `AppExit` so the +/// player can resume where they left off. Won games are not saved (the +/// `save_game_state_to` helper skips them). Blocking on exit is acceptable +/// because the game loop is already shutting down. +fn save_game_state_on_exit( + mut exit_events: EventReader, + game: Res, + path: Res, +) { + if exit_events.is_empty() { + return; + } + exit_events.clear(); + let Some(p) = path.0.as_deref() else { return }; + if let Err(e) = save_game_state_to(p, &game.0) { + warn!("game_state: failed to save on exit: {e}"); + } +} + #[cfg(test)] mod tests { use super::*; use solitaire_core::pile::PileType; /// Build a minimal headless `App` with just `GamePlugin` installed. - /// Overrides the default random seed so tests are deterministic. + /// Disables persistence and overrides the seed so tests are deterministic + /// and don't touch `~/.local/share/solitaire_quest/game_state.json`. fn test_app(seed: u64) -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(GamePlugin); + // Disable I/O — tests must not touch the real game state file. + app.insert_resource(GameStatePath(None)); // Override the system-time seed with a known value. app.world_mut() .resource_mut::() @@ -196,6 +258,7 @@ mod tests { fn plugin_inserts_game_state_resource() { let app = test_app(1); assert!(app.world().get_resource::().is_some()); + assert!(app.world().get_resource::().is_some()); assert!(app.world().get_resource::().is_some()); assert!(app.world().get_resource::().is_some()); } @@ -332,4 +395,54 @@ mod tests { let mut reader = events.get_cursor(); assert!(reader.read(events).next().is_none()); } + + // ----------------------------------------------------------------------- + // Persistence tests + // ----------------------------------------------------------------------- + + fn tmp_gs_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("engine_test_gs_{name}.json")) + } + + /// save_game_state_on_exit writes to disk when AppExit fires. + #[test] + fn exit_saves_game_state() { + use solitaire_data::load_game_state_from; + + let path = tmp_gs_path("exit_save"); + let _ = std::fs::remove_file(&path); + + let mut app = test_app(7); + // Point persistence at our temp file. + app.insert_resource(GameStatePath(Some(path.clone()))); + // Override the seed so we can verify it was written. + app.world_mut().resource_mut::().0 = + GameState::new(7654, DrawMode::DrawOne); + + app.world_mut().send_event(AppExit::Success); + app.update(); + + let loaded = load_game_state_from(&path).expect("file should exist after exit"); + assert_eq!(loaded.seed, 7654); + + let _ = std::fs::remove_file(&path); + } + + /// new_game_request deletes any previously saved state file. + #[test] + fn new_game_deletes_saved_state() { + use solitaire_data::save_game_state_to; + + let path = tmp_gs_path("new_game_delete"); + // Pre-create a saved file. + save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap(); + assert!(path.exists()); + + let mut app = test_app(1); + app.insert_resource(GameStatePath(Some(path.clone()))); + app.world_mut().send_event(NewGameRequestEvent { seed: Some(2), mode: None }); + app.update(); + + assert!(!path.exists(), "saved file should be deleted after new game"); + } } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 72ace50..26d6012 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -38,7 +38,7 @@ pub use events::{ AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, }; -pub use game_plugin::{GameMutation, GamePlugin}; +pub use game_plugin::{GameMutation, GamePlugin, GameStatePath}; pub use help_plugin::{HelpPlugin, HelpScreen}; pub use input_plugin::InputPlugin; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 552fb2b..105a46e 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -11,6 +11,10 @@ //! input-blocking on top if desired. use bevy::prelude::*; +use solitaire_data::save_game_state_to; + +use crate::game_plugin::GameStatePath; +use crate::resources::GameStateResource; /// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`. #[derive(Resource, Debug, Default)] @@ -34,6 +38,8 @@ fn toggle_pause( keys: Res>, mut paused: ResMut, screens: Query>, + game: Option>, + path: Option>, ) { if !keys.just_pressed(KeyCode::Escape) { return; @@ -44,6 +50,15 @@ fn toggle_pause( } else { spawn_pause_screen(&mut commands); paused.0 = true; + // Persist the current game state whenever the player opens the pause + // overlay so an OS-level kill still leaves a resumable save. + if let (Some(g), Some(p)) = (game, path) { + if let Some(disk_path) = p.0.as_deref() { + if let Err(e) = save_game_state_to(disk_path, &g.0) { + warn!("game_state: failed to save on pause: {e}"); + } + } + } } }