fa54c58bc4
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets, 1.35x labels) between compact Menu and Pause. Draw is new — it fires the same DrawRequestEvent as tapping the stock, so the most frequent action no longer needs a reach to the top of a tall folded screen. Help, Modes, and New Game leave the touch bar (they live in Menu -> System, the Home grid, and Home's hero respectively). Desktop keeps the seven-button bar unchanged (decision 5: touch-only). Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s), each step through the normal request path so the scoring penalty applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity gate at zero with the fourth DrawRequestEvent writer. 6 new hud_plugin tests; workspace suite + clippy green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1395 lines
57 KiB
Rust
1395 lines
57 KiB
Rust
//! 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 `DrawRequestEvent` — same rationale as
|
||
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
|
||
/// stock tap (input_plugin), and the touch action bar's Draw button
|
||
/// (hud_plugin, Phase F).
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct DrawRequestWriters;
|
||
|
||
/// 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;
|
||
}
|
||
// The session itself is the authoritative recording: its history
|
||
// holds the dealt board plus the forward instruction list (undos
|
||
// already popped), and it serialises via the upstream card_game
|
||
// serializers so playback never re-deals from the seed.
|
||
let session_recording = game.0.recording();
|
||
// 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 = session_recording.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(),
|
||
session_recording,
|
||
)
|
||
.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;
|