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:
root
2026-04-27 00:17:47 +00:00
parent 20db4b312a
commit 00f0383867
6 changed files with 303 additions and 10 deletions
+2 -2
View File
@@ -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;
+146 -1
View File
@@ -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");
}
}