Files
Ferrous-Solitaire/solitaire_engine/src/game_plugin/mod.rs
T
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00

1382 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 chrono::Utc;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
Replay, SOLVER_DEAL_RETRY_CAP, 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,
};
use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
#[cfg(target_os = "android")]
const NO_MOVES_MSG: &str = "No moves available — tap the stock to draw or start a new game";
#[cfg(not(target_os = "android"))]
const NO_MOVES_MSG: &str = "No moves available — press D to draw or N for a new game";
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
spawn_modal_button, spawn_modal_header,
};
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;
/// System set for every writer of [`crate::events::NewGameRequestEvent`].
///
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
/// mode pickers). Their relative append order within a frame is
/// meaningless — consumers drain the whole queue — so members are
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
/// set; do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct NewGameRequestWriters;
/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters;
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters;
/// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);
/// 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<PathBuf>);
/// 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<GameState>);
/// 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 [`KlondikeInstruction`] 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. Each entry is the atomic player input as a
/// [`KlondikeInstruction`] (a stock click is
/// [`KlondikeInstruction::RotateStock`]); pile-position types are
/// runtime-only and never persisted.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
/// Ordered list of instructions applied so far this game.
pub moves: Vec<KlondikeInstruction>,
}
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(), DrawStockConfig::DrawOne),
saved,
)
} else {
(
saved.unwrap_or_else(|| {
GameState::new(seed_from_system_time(), DrawStockConfig::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::<RecordingReplay>()
.init_resource::<PendingNewGameSeed>()
.init_resource::<DragState>()
.init_resource::<SyncStatusResource>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
// add_message is idempotent; SettingsPlugin also registers this.
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
.add_systems(
Update,
poll_pending_new_game_seed
.before(GameMutation)
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
)
.add_systems(
Update,
(handle_new_game, handle_draw, handle_move, handle_undo)
.chain()
.in_set(GameMutation),
)
.add_systems(
Update,
check_no_moves
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals)
.in_set(InfoToastWriters)
.ambiguous_with(InfoToastWriters),
)
.add_systems(Update, record_replay_on_win.after(GameMutation))
.add_systems(
Update,
(
handle_confirm_input,
handle_confirm_button_input,
handle_game_over_input,
handle_game_over_button_input,
)
.after(GameMutation)
.before(crate::ui_focus::FocusKeys)
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters)
.in_set(UndoRequestWriters)
.ambiguous_with(UndoRequestWriters),
)
// Restore prompt: spawn the modal once the splash is gone,
// route Continue / New Game intents back into the existing
// GameMutation flow.
// All pre-mutation game-state writers are chained: elapsed
// time ticks first, settings sync next, then the restore prompt —
// a deterministic spine instead of three unordered ResMut holders
// (ambiguity burn-down, #143).
.add_systems(
Update,
(
tick_elapsed_time,
sync_settings_to_game,
spawn_restore_prompt_if_pending,
handle_restore_prompt
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
)
.chain()
.after(crate::settings_plugin::SettingsMutation)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>()
.add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit);
}
}
/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
///
/// This covers two cases that the new-game path misses:
/// 1. The initial settings load at startup: saves on disk default to `false`
/// but `Settings` defaults to `true`; the event fires once when the
/// settings file is first read.
/// 2. A user toggling the setting mid-session in the Settings panel.
fn sync_settings_to_game(
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
mut game: ResMut<GameStateResource>,
) {
for ev in events.read() {
game.0.take_from_foundation = ev.0.take_from_foundation;
}
}
/// 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 no blocking modal
/// (Home picker or first-run onboarding) is covering the board. Stops
/// counting on win so the final time reflects how long the player took;
/// stops while the pause overlay is open; stops while Home is up so the
/// timer doesn't tick before the player commits to a deal; stops while
/// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial.
///
/// On Android the first frame after the app is resumed from background
/// can carry a very large `delta_secs` equal to the entire suspension
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
/// `Suspended` so that frame's delta is dropped instead of applied.
#[allow(clippy::too_many_arguments)]
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
mut lifecycle: MessageReader<AppLifecycle>,
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
*skip_next_delta = true;
}
}
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !onboarding_screens.is_empty() {
return;
}
if *skip_next_delta {
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won();
advance_elapsed(
&mut game.0.elapsed_seconds,
&mut accumulator,
time.delta_secs(),
is_won,
);
}
fn seed_from_system_time() -> u64 {
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
/// - `Ok(Some(_))` — winnable (provably solvable); accept.
/// - `Err(_)` — inconclusive (budget exceeded, no proof either way);
/// accept (we treat "we don't know" as winnable so the toggle never
/// silently drops a player into the retry cap).
/// - `Ok(None)` — provably dead; try the next seed.
///
/// If every seed in the retry window is provably dead (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
///
/// In-flight async work for "Winnable deals only" seed selection.
///
/// `handle_new_game` writes here when it needs the solver to vet a deal;
/// `poll_pending_new_game_seed` reads from here, polls the task, and
/// re-emits a `NewGameRequestEvent` with the chosen seed once the task
/// completes. The desktop client's UI never blocks on the worst-case
/// 50 × ~120 ms solver runs that can pile up on pathological deals.
///
/// At most one task is ever in flight: a fresh new-game request while
/// a previous task is still running drops the previous task (Bevy's
/// `Task` `Drop` cancels it cooperatively at the next await point) and
/// queues the new one.
#[derive(Resource, Default)]
pub struct PendingNewGameSeed {
/// `Some` while a solver-vetted seed is being computed.
inner: Option<PendingSeedTask>,
}
/// One in-flight winnable-seed search plus the request fields that
/// would have flowed through `handle_new_game` synchronously. The
/// poll system replays them on a synthetic `NewGameRequestEvent` once
/// the task completes — `seed: Some(...)` skips the solver branch on
/// the second pass so we don't loop.
struct PendingSeedTask {
handle: Task<u64>,
mode: Option<GameMode>,
confirmed: bool,
}
/// Update system: poll the in-flight winnable-seed search. When the
/// task resolves, emit a synthetic `NewGameRequestEvent` carrying the
/// chosen seed. Ordered `.before(GameMutation)` so `handle_new_game`
/// picks up the synthetic event on the same frame, completing the
/// new-game flow without a one-frame visual lag.
fn poll_pending_new_game_seed(
mut pending: ResMut<PendingNewGameSeed>,
mut new_game_writer: MessageWriter<NewGameRequestEvent>,
) {
let Some(p) = pending.inner.as_mut() else {
return;
};
let Some(seed) = future::block_on(future::poll_once(&mut p.handle)) else {
return;
};
let mode = p.mode;
let confirmed = p.confirmed;
pending.inner = None;
new_game_writer.write(NewGameRequestEvent {
seed: Some(seed),
mode,
confirmed,
});
}
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawStockConfig) -> u64 {
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
) {
// Winnable (`Ok(Some)`) or inconclusive (`Err`) → accept as
// "probably winnable"; only a proven dead deal (`Ok(None)`) retries.
Ok(Some(_)) | Err(_) => return seed,
Ok(None) => {
seed = seed.wrapping_add(1);
}
}
}
// Retry cap exhausted — accept the latest tried seed rather than
// recurring forever.
seed
}
#[allow(clippy::too_many_arguments)]
fn handle_new_game(
mut commands: Commands,
mut new_game: MessageReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut recording: ResMut<RecordingReplay>,
mut pending_seed: ResMut<PendingNewGameSeed>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
layout: Option<Res<crate::layout::LayoutResource>>,
mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
scrims: Query<(), With<ModalScrim>>,
) {
for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog.
// A game is "active" when moves have been made and it is not yet won.
let needs_confirm = game.0.move_count() > 0 && !game.0.is_won();
// Skip confirmation if a ConfirmNewGameScreen already exists (prevents
// duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the
// modal would be respawned the frame after the despawn flushes.
// Also skip if any other modal scrim is currently open (global guard).
let confirm_already_open = !confirm_screens.is_empty();
if needs_confirm && !confirm_already_open && !ev.confirmed {
if !scrims.is_empty() {
return;
}
// Despawn any stale game-over overlay before showing confirm dialog.
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
continue;
}
// Despawn confirm and game-over overlays before starting the new game.
for entity in &confirm_screens {
commands.entity(entity).despawn();
}
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
// Drop any in-flight winnable-seed search now that we've
// committed to acting on a new request. Its result was for
// the previous user intent — the new request supersedes it
// regardless of which branch we take below (synchronous
// explicit-seed deal vs. another async solver search).
pending_seed.inner = None;
let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
// Prefer the draw mode from Settings when starting a fresh game.
// Fall back to the current game's draw mode in headless/test contexts
// where SettingsPlugin is not installed.
let draw_mode = settings
.as_ref()
.map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to
// "Winnable deals only" AND this is a random Classic deal
// (no caller-supplied seed), reject deals the solver can
// prove unwinnable and try the next seed. Capped at
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
// hang the main thread — if every attempt is rejected we
// fall through to the latest tried seed.
//
// **Scope** — the retry deliberately skips:
// - Daily challenges and challenge-mode seeds (caller passes
// `ev.seed = Some(...)` so the player gets the same deal as
// everyone else).
// - Replays (the replay's own seed is authoritative).
// - Any other explicit seed request — the player asked for
// that seed; honour it.
let winnable_only = settings.as_ref().is_some_and(|s| s.0.winnable_deals_only);
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
let task = AsyncComputeTaskPool::get()
.spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
pending_seed.inner = Some(PendingSeedTask {
handle: task,
mode: ev.mode,
confirmed: ev.confirmed,
});
// Skip the rest of the new-game flow; the polling system
// will re-emit a synthetic event with a chosen seed once
// the task resolves.
continue;
}
let chosen_seed = initial_seed;
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
if let Some(s) = settings.as_ref() {
game.0.take_from_foundation = s.0.take_from_foundation;
}
// Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again.
recording.clear();
// 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())
&& let Err(e) = delete_game_state_at(p)
{
warn!("game_state: failed to delete saved game: {e}");
}
// Snap every existing card sprite to the stock position before the
// deal animation starts. Without this the per-card slide tween reads
// each card's previous-game Transform as its source, which lets a
// careful observer track origin points to deduce where face-down
// cards came from. Funnelling all sprites through the deck position
// hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout.0.pile_positions.get(&KlondikePile::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
tx.translation.y = stock.y;
}
}
changed.write(StateChangedEvent);
}
}
/// Marker on the primary "New game" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmYesButton;
/// Marker on the secondary "Cancel" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmNoButton;
/// Spawns the confirm-new-game modal using the standard `ui_modal`
/// primitive — uniform scrim, centred card, real buttons with hover /
/// press states.
///
/// Shown when the player requests a new game while moves have been made
/// and the game is not yet won. The original `NewGameRequestEvent` is
/// stored on the scrim entity so `handle_confirm_input` /
/// `handle_confirm_button` can replay it with the same seed / mode on
/// confirmation.
///
/// Replaces a bespoke layout that used plain `Text` labels for "Yes (Y)"
/// and "No (N)" — those were not real Button entities, so the player
/// had no hover / press feedback and the modal felt like a debug panel
/// (the user's smoke-test "#2 complaint").
/// Update-schedule system: once the splash overlay is gone and there's
/// a pending restored game waiting for the player's answer, spawn the
/// "Welcome back — Continue or start a new game?" modal. Idempotent —
/// the existing `RestorePromptScreen` query gates against duplicate
/// spawns if Update fires before the player clicks.
fn spawn_restore_prompt_if_pending(
mut commands: Commands,
pending: Res<PendingRestoredGame>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
existing: Query<(), With<RestorePromptScreen>>,
font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) {
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
return;
}
if !scrims.is_empty() {
return;
}
spawn_modal(
&mut commands,
RestorePromptScreen,
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "Welcome back", font_res.as_deref());
spawn_modal_body_text(
card,
"You have an in-progress game. Continue where you left off, or start a new one?",
ui_theme::TEXT_SECONDARY,
font_res.as_deref(),
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
RestoreNewGameButton,
"New game",
Some("N"),
ButtonVariant::Secondary,
font_res.as_deref(),
);
spawn_modal_button(
actions,
RestoreContinueButton,
"Continue",
Some("Enter"),
ButtonVariant::Primary,
font_res.as_deref(),
);
});
},
);
}
/// Click handlers + keyboard shortcuts for the restore prompt.
///
/// Continue (Enter / C) — swaps the saved game into `GameStateResource`
/// and writes a `StateChangedEvent` so card sprites resync to the
/// restored layout.
/// New game (N) — drops the saved game and writes
/// `NewGameRequestEvent { confirmed: true }`. The existing
/// `handle_new_game` flow takes over: deletes `game_state.json`, deals
/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips
/// the abandon-current-game confirm dialog (the player has already
/// confirmed by clicking New game here).
#[allow(clippy::too_many_arguments)]
fn handle_restore_prompt(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<Entity, With<RestorePromptScreen>>,
continue_buttons: Query<&Interaction, (With<RestoreContinueButton>, Changed<Interaction>)>,
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
mut pending: ResMut<PendingRestoredGame>,
mut game: ResMut<GameStateResource>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut changed: MessageWriter<StateChangedEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
) {
if screens.is_empty() {
return;
}
// Esc maps to Continue rather than New Game so a stray dismiss
// press preserves the saved game — the data-preserving default is
// the safer fallback when a player hits Esc reflexively to "close
// this dialog" without reading it.
let key_continue = keys.as_ref().is_some_and(|k| {
k.just_pressed(KeyCode::Enter)
|| k.just_pressed(KeyCode::KeyC)
|| k.just_pressed(KeyCode::Escape)
});
let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN));
let click_continue = continue_buttons.iter().any(|i| *i == Interaction::Pressed);
let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed);
let resolved = if key_continue || click_continue {
if let Some(restored) = pending.0.take() {
game.0 = restored;
// Patch setting that serialized with the old core default of `false`.
if let Some(s) = settings.as_ref() {
game.0.take_from_foundation = s.0.take_from_foundation;
}
changed.write(StateChangedEvent);
}
for entity in &screens {
commands.entity(entity).despawn();
}
true
} else if key_new || click_new {
pending.0 = None;
for entity in &screens {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: true,
});
true
} else {
false
};
// The player has just made an explicit launch-time choice (continue
// saved game, or start a fresh deal). Suppress the launch-time Home
// auto-show so it doesn't pop on top of the resolution they picked.
// `M` still re-opens the picker on demand.
if resolved && let Some(ref mut shown) = launch_home_shown {
shown.0 = true;
}
}
fn spawn_confirm_dialog(
commands: &mut Commands,
original_request: NewGameRequestEvent,
font_res: Option<&FontResource>,
) {
let scrim = spawn_modal(
commands,
ConfirmNewGameScreen,
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "Abandon current game?", font_res);
spawn_modal_body_text(
card,
"Your progress will be lost.",
ui_theme::TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ConfirmNoButton,
"Cancel",
Some("Esc"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
ConfirmYesButton,
"New game",
Some("Y"),
ButtonVariant::Primary,
font_res,
);
});
},
);
// Attach the original request to the scrim so handle_confirm_input
// and handle_confirm_button can read it on confirmation.
commands
.entity(scrim)
.insert(OriginalNewGameRequest(original_request));
}
/// Carries the original `NewGameRequestEvent` on the confirm overlay so
/// `handle_confirm_input` can replay it with the same seed / mode.
#[derive(Component, Debug, Clone, Copy)]
struct OriginalNewGameRequest(NewGameRequestEvent);
/// Handles keyboard input while `ConfirmNewGameScreen` is open.
///
/// `Y` or `Enter` confirms: despawns the overlay and fires `NewGameRequestEvent`.
/// `N` or `Escape` cancels: despawns the overlay without starting a new game.
fn handle_confirm_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.single() else {
return;
};
let Some(keys) = keys else {
return;
};
let confirmed = keys.just_pressed(KeyCode::KeyY) || keys.just_pressed(KeyCode::Enter);
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
if confirmed {
commands.entity(entity).despawn();
// Set `confirmed: true` so handle_new_game skips the dialog spawn
// and goes straight to the start-game branch. Without this flag the
// modal would respawn the frame after the despawn flushes (because
// confirm_screens is empty by then) and the new game would never
// actually start.
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
confirmed: true,
});
} else if cancelled {
commands.entity(entity).despawn();
}
}
/// Mouse / touch counterpart to `handle_confirm_input`. Reads
/// `Changed<Interaction>` on the modal's `ConfirmYesButton` /
/// `ConfirmNoButton` so the modal closes and (on confirm) starts a new
/// game whether the player uses the keyboard accelerator or clicks.
///
/// This is the system that closes the user's #2 smoke-test complaint:
/// previously the dialog had only `Text::new("Yes (Y)")` labels — not
/// real button entities — so clicks did nothing and the only path
/// through the modal was the keyboard.
#[allow(clippy::too_many_arguments)]
fn handle_confirm_button_input(
mut commands: Commands,
yes_buttons: Query<&Interaction, (With<ConfirmYesButton>, Changed<Interaction>)>,
no_buttons: Query<&Interaction, (With<ConfirmNoButton>, Changed<Interaction>)>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.single() else {
return;
};
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
if confirmed {
commands.entity(entity).despawn();
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
confirmed: true,
});
} else if cancelled {
commands.entity(entity).despawn();
}
}
fn handle_draw(
mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut flipped: MessageWriter<CardFlippedEvent>,
mut recording: ResMut<RecordingReplay>,
) {
for _ in draws.read() {
// Capture which cards are about to be drawn (top of the stock pile)
// so we can fire flip events after they land face-up in the waste.
// Only relevant when stock is non-empty; a recycle moves waste back to
// stock face-down, so no flip events are needed in that case.
let drawn_cards: Vec<solitaire_core::Card> = {
let stock = game.0.stock_cards();
if stock.is_empty() {
Vec::new()
} else {
let draw_count = match game.0.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let n = stock.len();
let take = n.min(draw_count);
stock[n - take..].iter().map(|c| c.0.clone()).collect()
}
};
match game.0.draw() {
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for card in drawn_cards {
flipped.write(CardFlippedEvent(card));
}
// Record the atomic player input. Whether the engine
// resolves this to a draw or a waste→stock recycle is
// a deterministic function of stock state at the time
// the click happens — re-executing on the same starting
// deal produces the same effect, so the input alone is
// sufficient to recover the move on playback.
recording.moves.push(KlondikeInstruction::RotateStock);
changed.write(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle_move(
mut moves: MessageReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
mut recording: ResMut<RecordingReplay>,
path: Option<Res<GameStatePath>>,
) {
for ev in moves.read() {
let was_won = game.0.is_won();
// Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile.
let source_cards = pile_cards(&game.0, &ev.from);
let flip_candidate = {
let n = source_cards.len();
if n > ev.count {
let c = &source_cards[n - ev.count - 1];
if !c.1 { Some(c.0.clone()) } else { None }
} else {
None
}
};
match game.0.move_cards(ev.from, ev.to, ev.count) {
Ok(()) => {
// Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below.
// `move_cards` resolved the pile coordinates to a
// `KlondikeInstruction` and pushed it onto the session
// history; recover that exact instruction from the tail
// (no clone — the instruction is `Copy`). Pile-position
// types are runtime-only, so we persist the instruction
// rather than the (from, to, count) triple.
if let Some(instruction) =
game.0.session().history().last().map(|s| *s.instruction())
{
recording.moves.push(instruction);
}
// Fire flip event if the candidate card is now face-up.
if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from)
.last()
.is_some_and(|c| c.0 == fcard && c.1)
{
flipped.write(CardFlippedEvent(fcard));
}
// If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit
// flourish event. Drives a brief decorative scale-pulse on
// the King + a golden tint on the foundation marker plus a
// short audio ping. Purely a UI / audio cue — does not
// cross `solitaire_sync` and is not persisted.
if let KlondikePile::Foundation(slot) = ev.to
&& let Some(slot) = foundation_slot(slot)
&& game.0.pile(ev.to).len() == 13
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.0.suit())
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent);
if !was_won && game.0.is_won() {
won.write(GameWonEvent {
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())
&& 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
),
}
}
}
fn handle_undo(
mut undos: MessageReader<UndoRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
use solitaire_core::error::MoveError;
for _ in undos.read() {
match game.0.undo() {
Ok(()) => {
changed.write(StateChangedEvent);
}
Err(MoveError::UndoStackEmpty) => {
toast.write(InfoToastEvent("Nothing to undo".to_string()));
}
Err(e) => warn!("undo rejected: {e}"),
}
}
}
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then append it to the rolling
/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
/// (tests inject a temp path).
///
/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
/// entries; older wins age out automatically when the cap is hit. The
/// recording buffer is left intact after the win so a subsequent
/// state-change does not erase the move list before the save completes;
/// it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
recording: Res<RecordingReplay>,
path: Option<Res<ReplayPath>>,
) {
for ev in wins.read() {
// Skip persistence when the recording is empty. This guards
// against unrelated tests in other plugins that synthesise a
// `GameWonEvent` (e.g. to exercise XP / streak / weekly goal
// logic) without driving any actual moves — those wins should
// not silently overwrite the developer's real replay file.
// A real win always has at least one recorded `Move`.
if recording.moves.is_empty() {
continue;
}
// Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render.
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
Utc::now().date_naive(),
recording.moves.clone(),
)
.with_win_move_index(win_move_index);
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
// No persistence path configured (e.g. tests / minimal Linux
// containers without dirs::data_dir). The in-memory replay
// is still available via the resource for callers that want
// to inspect it without going through the disk.
continue;
};
if let Err(e) = append_replay_to_history(p, replay) {
warn!("replay: failed to append winning replay to history: {e}");
}
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
fn foundation_slot(foundation: solitaire_core::Foundation) -> Option<u8> {
match foundation {
solitaire_core::Foundation::Foundation1 => Some(0),
solitaire_core::Foundation::Foundation2 => Some(1),
solitaire_core::Foundation::Foundation3 => Some(2),
solitaire_core::Foundation::Foundation4 => Some(3),
}
}
// ---------------------------------------------------------------------------
// Task #29 — No-moves detection
// ---------------------------------------------------------------------------
/// Returns `true` if the current game state has at least one legal move
/// that could ever lead to progress.
///
/// Considers a card "playable" if it's currently face-up on the top of
/// the Waste or any Tableau, OR if it lives in the Stock or Waste pile
/// at all (every card in those piles eventually rotates through the
/// Waste's top in both Draw-One and Draw-Three over the course of
/// recycling). For each such candidate, checks whether it can land on
/// any Foundation or any Tableau in the current state.
///
/// Returns `false` only when *no* card anywhere can land anywhere —
/// the player can keep drawing through the stock forever and nothing
/// will ever come of it. This treats "draw cycle with no useful drop"
/// as a softlock rather than as "legal moves remain", which the
/// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool {
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game.stock_cards().is_empty();
let waste_empty = game.waste_cards().is_empty();
if !stock_empty || !waste_empty {
return true;
}
// Stock and waste both exhausted — delegate to the authoritative move
// enumeration in core, which validates tableau sequence structure and
// foundation placement correctly. The previous hand-rolled loop only
// checked can_place_on_tableau(card, dest) for individual face-up cards
// without verifying that the cards above them form a valid alternating run,
// causing false positives when a useful-looking card was buried under an
// invalid sequence.
!game.possible_instructions().is_empty()
}
/// After each `StateChangedEvent`, check if the game has no legal moves.
///
/// When stuck (no legal moves and game not won), fires `InfoToastEvent` and
/// spawns a `GameOverScreen` overlay. The overlay is despawned automatically
/// when `has_legal_moves` returns true again (e.g. after undo) or when the
/// game is won.
#[allow(clippy::too_many_arguments)]
fn check_no_moves(
mut commands: Commands,
mut events: MessageReader<StateChangedEvent>,
game: Res<GameStateResource>,
mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
let had_event = events.read().count() > 0;
if !had_event {
return;
}
// Reset debounce whenever the state changes.
*already_fired = false;
// Despawn game-over overlay whenever moves become available again or game is won.
let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won() {
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
}
if game.0.is_won() {
return;
}
if !moves_ok && !*already_fired {
toast.write(InfoToastEvent(NO_MOVES_MSG.to_string()));
*already_fired = true;
// Only spawn the overlay if one does not already exist, and no other
// modal scrim is currently open (global ModalScrim guard).
if game_over_screens.is_empty() && scrims.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
}
}
}
/// Marker on the "Undo" secondary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverUndoButton;
/// Marker on the "New Game" primary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverNewGameButton;
/// Spawns the game-over modal using the standard `ui_modal` primitive.
///
/// Replaces a bespoke layout that listed action hints as plain text
/// ("Press N for a new game", "Press G to forfeit") — the audit
/// flagged this as the same class of "feels like a debug panel"
/// problem the confirm modal had. Now there are real buttons with
/// hover/press feedback; the keyboard accelerators stay as optional
/// shortcuts displayed inside the buttons' caption chips.
fn spawn_game_over_screen(commands: &mut Commands, score: i32, font_res: Option<&FontResource>) {
spawn_modal(commands, GameOverScreen, ui_theme::Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "No more moves available", font_res);
spawn_modal_body_text(
card,
format!("Final score: {score}"),
ui_theme::TEXT_PRIMARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
GameOverUndoButton,
"Undo",
Some("U"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
GameOverNewGameButton,
"New Game",
Some("N"),
ButtonVariant::Primary,
font_res,
);
});
});
}
/// Handles keyboard input while `GameOverScreen` is open.
///
/// `N` or `Escape` fires `NewGameRequestEvent` (which will trigger the confirm
/// dialog if moves have been made). `U` fires `UndoRequestEvent` and despawns
/// the overlay — the `check_no_moves` system will re-show it on the next
/// `StateChangedEvent` if the undo did not restore any legal moves.
fn handle_game_over_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
}
let Some(keys) = keys else {
return;
};
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens {
commands.entity(entity).despawn();
}
undo.write(UndoRequestEvent);
}
}
/// Mouse / touch counterpart to `handle_game_over_input`. Click on the
/// modal's Undo button → fire `UndoRequestEvent` and despawn so
/// `check_no_moves` can re-evaluate. Click on New Game → fire
/// `NewGameRequestEvent` (the abandon-current-game guard does not apply
/// here because the game is unwinnable).
#[allow(clippy::type_complexity)]
fn handle_game_over_button_input(
mut commands: Commands,
new_game_buttons: Query<&Interaction, (With<GameOverNewGameButton>, Changed<Interaction>)>,
undo_buttons: Query<&Interaction, (With<GameOverUndoButton>, Changed<Interaction>)>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
}
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens {
commands.entity(entity).despawn();
}
undo.write(UndoRequestEvent);
}
}
const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
/// Accumulated real-world seconds since the last auto-save. Exposed as a
/// `Resource` so tests can pre-seed it past the threshold without needing to
/// control `Time::delta_secs()`.
#[derive(Resource, Default)]
pub struct AutoSaveTimer(pub f32);
/// Periodically saves game state every 30 real-world seconds while a game is
/// in progress. The timer uses real delta time (not game elapsed_seconds) so
/// it keeps ticking even if the game clock is paused.
fn auto_save_game_state(
time: Res<Time>,
game: Res<GameStateResource>,
path: Option<Res<GameStatePath>>,
mut timer: ResMut<AutoSaveTimer>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
pending: Res<PendingRestoredGame>,
) {
// Don't save if paused, game is won, no moves have been made yet,
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0)
|| game.0.is_won()
|| game.0.move_count() == 0
|| pending.0.is_some()
{
return;
}
timer.0 += time.delta_secs();
if timer.0 >= AUTO_SAVE_INTERVAL_SECS {
timer.0 -= AUTO_SAVE_INTERVAL_SECS;
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
return;
};
if let Err(e) = save_game_state_to(p, &game.0) {
warn!("game_state: auto-save failed: {e}");
}
}
}
/// 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.
///
/// Special case: when `PendingRestoredGame` still holds a saved game the
/// player never answered the restore prompt for, write THAT to disk
/// instead of the live `GameStateResource`. Otherwise we'd clobber a
/// real saved game with the fresh-deal placeholder we seeded
/// `GameStateResource` with at startup.
fn save_game_state_on_exit(
mut exit_events: MessageReader<AppExit>,
game: Res<GameStateResource>,
path: Res<GameStatePath>,
pending: Res<PendingRestoredGame>,
) {
if exit_events.is_empty() {
return;
}
exit_events.clear();
let Some(p) = path.0.as_deref() else { return };
let to_save = pending.0.as_ref().unwrap_or(&game.0);
if let Err(e) = save_game_state_to(p, to_save) {
warn!("game_state: failed to save on exit: {e}");
}
}
#[cfg(test)]
mod tests;