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
+15
View File
@@ -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}");
}
}
}
}
}