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<PileType,Pile> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PileType, Pile>` 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<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||||
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||
entries.serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<HashMap<PileType, Pile>, 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<PileType, Pile>,
|
||||
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<PileType, Pile>,
|
||||
pub draw_mode: DrawMode,
|
||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<GameState> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>);
|
||||
|
||||
/// 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::<DragState>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.add_event::<MoveRequestEvent>()
|
||||
@@ -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<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
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<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut won: EventWriter<GameWonEvent>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
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<AppExit>,
|
||||
game: Res<GameStateResource>,
|
||||
path: Res<GameStatePath>,
|
||||
) {
|
||||
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::<GameStateResource>()
|
||||
@@ -196,6 +258,7 @@ mod tests {
|
||||
fn plugin_inserts_game_state_resource() {
|
||||
let app = test_app(1);
|
||||
assert!(app.world().get_resource::<GameStateResource>().is_some());
|
||||
assert!(app.world().get_resource::<GameStatePath>().is_some());
|
||||
assert!(app.world().get_resource::<DragState>().is_some());
|
||||
assert!(app.world().get_resource::<SyncStatusResource>().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::<GameStateResource>().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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<ButtonInput<KeyCode>>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user