Files
Ferrous-Solitaire/solitaire_engine/src/game_plugin.rs
T
funman300 18d7937b51 refactor(core): derive Copy for DrawMode; drop redundant .clone() calls (M-18)
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:18:23 -07:00

2802 lines
110 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 std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_data::{
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, Replay, ReplayMove,
SOLVER_DEAL_RETRY_CAP,
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
};
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;
/// 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 [`ReplayMove`] 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.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
/// Ordered list of moves applied so far this game.
pub moves: Vec<ReplayMove>,
}
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(), DrawMode::DrawOne),
saved,
)
} else {
(
saved.unwrap_or_else(|| {
GameState::new(seed_from_system_time(), DrawMode::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::<crate::events::CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems(
Update,
poll_pending_new_game_seed.before(GameMutation),
)
.add_systems(
Update,
(
handle_new_game,
handle_draw,
handle_move,
handle_undo,
)
.chain()
.in_set(GameMutation),
)
.add_systems(Update, check_no_moves.after(GameMutation))
.add_systems(Update, record_replay_on_win.after(GameMutation))
.add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation))
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
// Restore prompt: spawn the modal once the splash is gone,
// route Continue / New Game intents back into the existing
// GameMutation flow.
.add_systems(Update, spawn_restore_prompt_if_pending)
.add_systems(Update, handle_restore_prompt.before(GameMutation))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state)
.add_systems(Last, save_game_state_on_exit);
}
}
/// 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 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64)
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] 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:
/// - [`SolverResult::Winnable`] — provably solvable; accept.
/// - [`SolverResult::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).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
///
/// If every seed in the retry window is `Unwinnable` (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: DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
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>>,
) {
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.
let confirm_already_open = !confirm_screens.is_empty();
if needs_confirm && !confirm_already_open && !ev.confirmed {
// 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(&solitaire_core::pile::PileType::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>>,
) {
if pending.0.is_none() || !splash.is_empty() || !existing.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>,
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;
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_ids: Vec<u32> = {
let stock = game.0.piles.get(&PileType::Stock);
match stock {
Some(p) if !p.cards.is_empty() => {
let draw_count = match game.0.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
let n = p.cards.len();
let take = n.min(draw_count);
// The top `take` cards (at the end of the vec) will be drawn.
p.cards[n - take..].iter().map(|c| c.id).collect()
}
_ => Vec::new(),
}
};
match game.0.draw() {
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for id in drawn_ids {
flipped.write(CardFlippedEvent(id));
}
// 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(ReplayMove::StockClick);
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<crate::events::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 flip_candidate_id = game.0.piles.get(&ev.from).and_then(|p| {
let n = p.cards.len();
if n > ev.count {
let c = &p.cards[n - ev.count - 1];
if !c.face_up { Some(c.id) } else { None }
} else {
None
}
});
match game.0.move_cards(ev.from.clone(), ev.to.clone(), 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.
recording.moves.push(ReplayMove::Move {
from: ev.from.clone(),
to: ev.to.clone(),
count: ev.count,
});
// Fire flip event if the candidate card is now face-up.
if let Some(fid) = flip_candidate_id
&& game.0.piles.get(&ev.from)
.and_then(|p| p.cards.last())
.is_some_and(|c| c.id == fid && c.face_up)
{
flipped.write(crate::events::CardFlippedEvent(fid));
}
// 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 PileType::Foundation(slot) = ev.to
&& let Some(pile) = game.0.piles.get(&ev.to)
&& pile.cards.len() == 13
&& let Some(suit) = pile.claimed_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}");
}
}
}
// ---------------------------------------------------------------------------
// 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 {
use solitaire_core::card::Card;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// 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.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
if !stock_empty || !waste_empty {
return true;
}
// Stock and waste exhausted — check whether any visible card can be placed.
let mut sources: Vec<Card> = Vec::new();
// Top waste card (waste is empty here, but included for completeness).
if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last()
{
sources.push(top.clone());
}
// Any face-up card in a tableau column can be the base of a movable run.
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
for card in t.cards.iter().filter(|c| c.face_up) {
sources.push(card.clone());
}
}
}
for card in &sources {
for slot in 0..4_u8 {
if let Some(dest) = game.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(card, dest)
{
return true;
}
}
for i in 0..7_usize {
if let Some(dest) = game.piles.get(&PileType::Tableau(i))
&& can_place_on_tableau(card, dest)
{
return true;
}
}
}
false
}
/// 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>>,
) {
// 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 {
#[cfg(target_os = "android")]
let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game";
#[cfg(not(target_os = "android"))]
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
toast.write(InfoToastEvent(no_moves_msg.to_string()));
*already_fired = true;
// Only spawn the overlay if one does not already exist.
if game_over_screens.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) {
new_game.write(NewGameRequestEvent::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) {
new_game.write(NewGameRequestEvent::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 {
use super::*;
use solitaire_core::pile::PileType;
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
fn test_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
// Disable I/O — tests must not touch the real game state file or
// the real replay file. Both default to dirs::data_dir() in the
// plugin's build path; clearing them keeps tests self-contained.
app.insert_resource(GameStatePath(None));
app.insert_resource(ReplayPath(None));
// Force `PendingRestoredGame` empty so production saved-game
// state on the dev machine's disk (loaded by `GamePlugin::build`)
// can't leak into per-test world state and trip the
// `pending.0.is_some()` guard in `auto_save_game_state` /
// `save_game_state_on_exit`. Without this clear, an
// unrelated `~/.local/share/ferrous_solitaire/game_state.json`
// would silently disable the auto-save path under test.
app.insert_resource(PendingRestoredGame(None));
// Override the system-time seed with a known value.
app.world_mut()
.resource_mut::<GameStateResource>()
.0 = GameState::new(seed, DrawMode::DrawOne);
app
}
#[test]
fn plugin_inserts_game_state_resource() {
let app = test_app(1);
assert!(app.world().get_resource::<GameStateResource>().is_some());
assert!(app.world().get_resource::<GameStatePath>().is_some());
assert!(app.world().get_resource::<DragState>().is_some());
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
}
#[test]
fn draw_request_advances_game_state() {
let mut app = test_app(42);
let stock_before = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Stock]
.cards
.len();
app.world_mut().write_message(DrawRequestEvent);
app.update();
let stock_after = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Stock]
.cards
.len();
let waste_after = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Waste]
.cards
.len();
assert_eq!(stock_after, stock_before - 1);
assert_eq!(waste_after, 1);
}
#[test]
fn draw_request_fires_state_changed_event() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_some());
}
#[test]
fn undo_after_draw_restores_state() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
assert_eq!(g.piles[&PileType::Waste].cards.len(), 0);
}
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Tableau(0)]
.cards
.iter()
.map(|c| c.id)
.collect();
app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
app.update();
let after: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Tableau(0)]
.cards
.iter()
.map(|c| c.id)
.collect();
assert_ne!(before, after);
}
#[test]
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
let mut elapsed = 0;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 2.5, false);
assert_eq!(elapsed, 2);
// Remaining 0.5 should still be in the accumulator.
advance_elapsed(&mut elapsed, &mut acc, 0.5, false);
assert_eq!(elapsed, 3);
}
#[test]
fn advance_elapsed_is_noop_when_won() {
let mut elapsed = 100;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 5.0, true);
assert_eq!(elapsed, 100);
assert_eq!(acc, 0.0);
}
#[test]
fn advance_elapsed_saturates_at_u64_max() {
let mut elapsed = u64::MAX;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 5.0, false);
assert_eq!(elapsed, u64::MAX, "elapsed must not overflow past u64::MAX");
}
#[test]
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
let mut elapsed = 0;
let mut acc = 0.0;
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 1);
// Repeat once more for a total of 2 seconds.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 2);
}
#[test]
fn invalid_move_does_not_fire_state_changed() {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_none());
}
// -----------------------------------------------------------------------
// Persistence tests
// -----------------------------------------------------------------------
fn tmp_gs_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
}
/// save_game_state_on_exit writes to disk when AppExit fires.
#[test]
fn exit_saves_game_state() {
use solitaire_data::load_game_state_from;
let path = tmp_gs_path("exit_save");
let _ = std::fs::remove_file(&path);
let mut app = test_app(7);
// Point persistence at our temp file.
app.insert_resource(GameStatePath(Some(path.clone())));
// Override the seed so we can verify it was written.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(7654, DrawMode::DrawOne);
app.world_mut().write_message(AppExit::Success);
app.update();
let loaded = load_game_state_from(&path).expect("file should exist after exit");
assert_eq!(loaded.seed, 7654);
let _ = std::fs::remove_file(&path);
}
/// new_game_request deletes any previously saved state file.
#[test]
fn new_game_deletes_saved_state() {
use solitaire_data::save_game_state_to;
let path = tmp_gs_path("new_game_delete");
// Pre-create a saved file.
save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap();
assert!(path.exists());
let mut app = test_app(1);
app.insert_resource(GameStatePath(Some(path.clone())));
app.world_mut().write_message(NewGameRequestEvent { seed: Some(2), mode: None, confirmed: false });
app.update();
assert!(!path.exists(), "saved file should be deleted after new game");
}
#[test]
fn moving_cards_off_face_down_card_fires_card_flipped_event() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two cards: a face-down King at bottom, face-up Queen on top.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t.cards.clear();
t.cards.push(Card { id: 900, suit: Suit::Spades, rank: Rank::King, face_up: false });
t.cards.push(Card { id: 901, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
}
// Set up an empty Tableau(1) for the Queen to land on.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.clear();
// A King must be in Tableau(1) for Queen to land there; skip validation
// by placing a King first.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let t = gs.0.piles.get_mut(&PileType::Tableau(1)).unwrap();
t.cards.push(Card { id: 902, suit: Suit::Clubs, rank: Rank::King, face_up: true });
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Messages<crate::events::CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "CardFlippedEvent must fire when a face-down card is exposed");
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
/// can be 0.0 (or, under heavy parallel cargo-test load, large
/// enough that the pre-seeded margin is consumed by it), so a
/// single-frame check is fragile. Looping until the file appears
/// (or hitting the bound) makes the test robust against
/// first-frame Time variance without changing the underlying
/// behaviour contract.
#[test]
fn auto_save_writes_after_30_seconds() {
use solitaire_data::load_game_state_from;
let path = tmp_gs_path("auto_save_30s");
let _ = std::fs::remove_file(&path);
let mut app = test_app(42);
app.insert_resource(GameStatePath(Some(path.clone())));
// Give the game one move so move_count > 0 (auto-save guard).
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
// healthy run hits it on the first or second frame; the cap
// prevents an infinite loop if a future regression skips
// the save unconditionally.
for _ in 0..16 {
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
app.update();
if path.exists() {
break;
}
}
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
let loaded = load_game_state_from(&path).expect("file must be loadable");
assert_eq!(loaded.seed, 42);
let _ = std::fs::remove_file(&path);
}
/// auto_save_game_state does NOT write to disk when no moves have been made.
#[test]
fn auto_save_skips_when_no_moves() {
let path = tmp_gs_path("auto_save_skip");
let _ = std::fs::remove_file(&path);
let mut app = test_app(99);
app.insert_resource(GameStatePath(Some(path.clone())));
// move_count stays at 0 (fresh game); timer is past threshold.
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
app.update();
assert!(!path.exists(), "auto-save must not fire when move_count == 0");
}
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two face-up cards.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t.cards.clear();
t.cards.push(Card { id: 910, suit: Suit::Clubs, rank: Rank::King, face_up: true });
t.cards.push(Card { id: 911, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
}
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.clear();
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true });
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Messages<crate::events::CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
}
// -----------------------------------------------------------------------
// Task #29 — has_legal_moves pure-function tests
// -----------------------------------------------------------------------
#[test]
fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always has a non-empty stock (24 cards), so drawing
// is always a legal move regardless of the initial face-up tableau cards.
let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
}
#[test]
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
// Drawing from a non-empty stock is always a legal move in standard
// Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.cards.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false });
}
// Stock is non-empty, so drawing is always a valid move.
assert!(
has_legal_moves(&game),
"non-empty stock means drawing is a legal move regardless of placement options",
);
}
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste so draw is NOT available.
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
assert!(has_legal_moves(&game), "Ace can always go to an empty foundation");
}
#[test]
fn has_legal_moves_returns_false_when_stuck() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste.
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all foundations and all tableau.
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place a Two of Clubs with no legal destination.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true,
});
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
}
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
// card of its column the previous code would return false (softlock)
// even though the player can still move that run.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
// only legal tableau move, and that move targets the Queen which is non-top.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 10, suit: Suit::Spades, rank: Rank::Queen, face_up: true });
t0.cards.push(Card { id: 11, suit: Suit::Hearts, rank: Rank::Jack, face_up: true });
let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap();
t1.cards.push(Card { id: 12, suit: Suit::Diamonds, rank: Rank::King, face_up: true });
assert!(
has_legal_moves(&game),
"Queen (non-top face-up) should be detected as a valid move source onto King",
);
}
// -----------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog tests
// -----------------------------------------------------------------------
/// Helper that also initialises `ButtonInput<KeyCode>` so the keyboard
/// systems do not panic in MinimalPlugins environments.
fn test_app_with_input(seed: u64) -> App {
let mut app = test_app(seed);
app.init_resource::<ButtonInput<KeyCode>>();
app
}
#[test]
fn new_game_request_with_moves_spawns_confirm_dialog() {
let mut app = test_app_with_input(42);
// Simulate an active game with moves made.
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
app.world_mut()
.write_message(NewGameRequestEvent { seed: None, mode: None, confirmed: false });
app.update();
let count = app
.world_mut()
.query::<&ConfirmNewGameScreen>()
.iter(app.world())
.count();
assert_eq!(count, 1, "ConfirmNewGameScreen must be spawned when move_count > 0");
}
#[test]
fn new_game_request_on_fresh_game_skips_confirm() {
let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game).
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
0,
"test assumes a fresh game with no moves"
);
app.world_mut()
.write_message(NewGameRequestEvent { seed: None, mode: None, confirmed: false });
app.update();
let count = app
.world_mut()
.query::<&ConfirmNewGameScreen>()
.iter(app.world())
.count();
assert_eq!(count, 0, "ConfirmNewGameScreen must NOT appear for a fresh game");
}
// -----------------------------------------------------------------------
// Task #58 — Game-over overlay tests
// -----------------------------------------------------------------------
#[test]
fn game_over_screen_absent_when_moves_available() {
// A fresh game always has moves (stock is non-empty).
let mut app = test_app_with_input(42);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
.world_mut()
.query::<&GameOverScreen>()
.iter(app.world())
.count();
assert_eq!(count, 0, "GameOverScreen must not appear when moves are available");
}
#[test]
fn game_over_screen_spawns_when_stuck() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app_with_input(1);
// Force a stuck state: empty all piles + stock/waste, leave only a
// Two of Clubs on tableau 0 with no legal destination.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
}
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
.world_mut()
.query::<&GameOverScreen>()
.iter(app.world())
.count();
assert_eq!(count, 1, "GameOverScreen must appear when no legal moves exist");
}
/// Verify that the game-over overlay contains the expected header text and
/// action-hint strings so players understand why the overlay appeared and
/// what keys to press.
#[test]
fn game_over_screen_text_content() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app_with_input(1);
// Force a stuck state identical to `game_over_screen_spawns_when_stuck`.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
}
app.world_mut().write_message(StateChangedEvent);
app.update();
// Collect all Text values that are children of the GameOverScreen entity tree.
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
assert!(
texts.iter().any(|t| t == "No more moves available"),
"header must read 'No more moves available'; found: {texts:?}"
);
// The modal now uses real buttons instead of plain action-hint
// text, so we assert on the button labels and their hotkey
// chips rather than the prior "Press N…" / "Press G…" prose.
assert!(
texts.iter().any(|t| t == "New Game"),
"primary action button must label 'New Game'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "N"),
"primary action must show its 'N' hotkey chip; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Undo"),
"secondary action button must label 'Undo'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "U"),
"secondary action must show its 'U' hotkey chip; found: {texts:?}"
);
}
// -----------------------------------------------------------------------
// Task #56 — Escape dismisses GameOverScreen and starts new game
// -----------------------------------------------------------------------
/// Pressing Escape while `GameOverScreen` is visible must fire
/// `NewGameRequestEvent` — identical behaviour to pressing N.
#[test]
fn escape_on_game_over_screen_fires_new_game_request() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app_with_input(1);
// Force a stuck state so GameOverScreen spawns.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
}
app.world_mut().write_message(StateChangedEvent);
app.update();
// Confirm the overlay is present.
assert_eq!(
app.world_mut()
.query::<&GameOverScreen>()
.iter(app.world())
.count(),
1,
"GameOverScreen must be present before pressing Escape"
);
// Clear the NewGameRequestEvent queue so we start with a clean slate.
app.world_mut().resource_mut::<Messages<NewGameRequestEvent>>().clear();
// Simulate Escape press.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.clear();
input.press(KeyCode::Escape);
}
app.update();
// NewGameRequestEvent must have been fired.
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut reader = events.get_cursor();
assert!(
reader.read(events).next().is_some(),
"Escape on GameOverScreen must fire NewGameRequestEvent"
);
}
// -----------------------------------------------------------------------
// Task #48 — Undo with empty stack fires InfoToastEvent
// -----------------------------------------------------------------------
/// Sending `UndoRequestEvent` on a fresh game (empty undo stack) must fire
/// exactly one `InfoToastEvent` with the message "Nothing to undo".
#[test]
fn undo_on_empty_stack_fires_info_toast() {
let mut app = test_app(42);
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
assert_eq!(
fired[0].0,
"Nothing to undo",
"toast message must be 'Nothing to undo'"
);
}
// -----------------------------------------------------------------------
// Foundation-completion flourish — FoundationCompletedEvent firing logic
// -----------------------------------------------------------------------
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
/// (12 cards, all face-up) and place the King of `suit` on
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
/// foundation.
fn seed_foundation_with_ace_through_queen(
app: &mut App,
slot: u8,
suit: solitaire_core::card::Suit,
) {
use solitaire_core::card::{Card, Rank};
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
];
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let foundation = gs
.0
.piles
.get_mut(&PileType::Foundation(slot))
.expect("foundation slot must exist");
foundation.cards.clear();
for (i, &rank) in ranks.iter().enumerate() {
foundation.cards.push(Card {
id: 5_000 + i as u32 + (slot as u32) * 100,
suit,
rank,
face_up: true,
});
}
// Put the King on Tableau(0) so a single move can complete it.
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 6_000 + (slot as u32),
suit,
rank: Rank::King,
face_up: true,
});
}
/// Reading helper: collect every `FoundationCompletedEvent` written
/// during the most recent `update()` so the test body can assert
/// against count, slot, and suit.
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
let events = app
.world()
.resource::<Messages<FoundationCompletedEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).copied().collect()
}
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
#[test]
fn foundation_completed_event_fires_when_king_lands() {
use solitaire_core::card::Suit;
let mut app = test_app(1);
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(2),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert_eq!(
fired.len(),
1,
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
);
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
}
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
// empty all tableaux + foundations, then place a face-up King of
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
// there legally.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
});
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
);
}
/// At 12 cards on a foundation (AceJack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
#[test]
fn foundation_completed_event_does_not_fire_at_12_cards() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
let suit = Suit::Diamonds;
let slot: u8 = 1;
// Pre-fill foundation with Ace through Jack (11 cards).
let pre_ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack,
];
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
foundation.cards.clear();
for (i, &rank) in pre_ranks.iter().enumerate() {
foundation.cards.push(Card {
id: 8_000 + i as u32,
suit,
rank,
face_up: true,
});
}
// Queen on Tableau(0) so a single move pushes the foundation
// count to exactly 12 (still below the completion threshold).
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 8_900,
suit,
rank: Rank::Queen,
face_up: true,
});
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(slot),
count: 1,
});
app.update();
// Sanity: the move actually landed (foundation has 12 cards now).
let foundation_len = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Foundation(slot)]
.cards
.len();
assert_eq!(foundation_len, 12, "Queen must have landed on the foundation");
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
);
}
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
// Make a move so the undo stack is non-empty.
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Clear events from the draw so we start with a clean slate.
app.world_mut().resource_mut::<Messages<InfoToastEvent>>().clear();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert!(
fired.is_empty(),
"no InfoToastEvent must fire on a successful undo"
);
}
// -----------------------------------------------------------------------
// Win-game replay recording
//
// The recording resource captures exactly the player-driven actions
// that successfully advanced GameState. On GameWonEvent it freezes
// into a Replay (with seed/mode/time/score metadata) and persists.
// -----------------------------------------------------------------------
/// Set up Tableau(0) with a face-up Ace of Clubs that can be moved
/// to the empty Foundation(0) — gives us a single deterministic move
/// to drive the recording without depending on the dealt layout.
fn seed_single_legal_move(app: &mut App) {
use solitaire_core::card::{Card, Rank, Suit};
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 999,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
let f0 = gs.0.piles.get_mut(&PileType::Foundation(0)).unwrap();
f0.cards.clear();
}
/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
#[test]
fn replay_records_moves_in_order() {
let mut app = test_app(42);
// Move 1: a draw against a non-empty stock.
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Move 2: a real card move from tableau to foundation.
seed_single_legal_move(&mut app);
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(0),
count: 1,
});
app.update();
// Move 3: another draw.
app.world_mut().write_message(DrawRequestEvent);
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert_eq!(
recording.moves.len(),
3,
"recording must capture exactly the three successful actions",
);
assert!(
matches!(recording.moves[0], ReplayMove::StockClick),
"first entry must be StockClick, got {:?}",
recording.moves[0],
);
match &recording.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, PileType::Tableau(0), "from pile must be Tableau(0)");
assert_eq!(*to, PileType::Foundation(0), "to pile must be Foundation(0)");
assert_eq!(*count, 1, "single-card move must have count 1");
}
other => panic!("second entry must be a Move, got {other:?}"),
}
assert!(
matches!(recording.moves[2], ReplayMove::StockClick),
"third entry must be StockClick, got {:?}",
recording.moves[2],
);
}
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
let mut app = test_app(42);
// Stock → Waste is InvalidDestination; the live engine rejects it.
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"rejected moves must not enter the recording, got {:?}",
recording.moves,
);
}
/// Undo intentionally does NOT enter the recording. The replay
/// represents the canonical path the player took to win, not the
/// missteps that were rolled back.
#[test]
fn replay_recording_skips_undo() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert_eq!(
recording.moves.len(),
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(recording.moves[0], ReplayMove::StockClick));
}
/// Starting a new game wipes the recording so the next deal begins
/// with a clean buffer.
#[test]
fn replay_recording_clears_on_new_game() {
let mut app = test_app(1);
app.world_mut().write_message(DrawRequestEvent);
app.update();
assert_eq!(
app.world().resource::<RecordingReplay>().moves.len(),
1,
"draw should have been recorded",
);
// Use `confirmed: true` so the request bypasses the
// abandon-current-game modal (which fires when move_count > 0)
// and goes straight to the new-game branch that clears the
// recording. The modal-spawn path is exercised by other tests
// in this module.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(2),
mode: None,
confirmed: true,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"recording must be cleared on new-game start; got {:?}",
recording.moves,
);
}
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test]
fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded moves manually so we can verify they survive
// the freeze/save round-trip without having to drive a real win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(2),
count: 1,
});
}
// Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it.
app.world_mut().write_message(GameWonEvent {
score: 4321,
time_seconds: 250,
});
app.update();
let history = load_replay_history_from(&path)
.expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
"fresh history must contain exactly the just-recorded win",
);
let loaded = &history.replays[0];
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
assert_eq!(loaded.time_seconds, 250, "time_seconds must come from the win event");
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
match &loaded.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, PileType::Waste);
assert_eq!(*to, PileType::Tableau(2));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(11);
app.insert_resource(ReplayPath(Some(path.clone())));
// First win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 60,
});
app.update();
// Second win — different score so we can distinguish.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
time_seconds: 120,
});
app.update();
let history = load_replay_history_from(&path).expect("history must exist");
assert_eq!(history.replays.len(), 2, "both wins must be retained");
// Newest first — second win lands at index 0.
assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100);
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without
/// driving any actual moves) would clobber the developer's real
/// replay file every time `cargo test` ran.
#[test]
fn replay_with_empty_recording_skips_save() {
let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(1);
app.insert_resource(ReplayPath(Some(path.clone())));
// Recording is empty by default — fire a win event anyway.
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 30,
});
app.update();
assert!(
!path.exists(),
"no replay must be written when recording is empty",
);
}
// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------
/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
let settings = solitaire_data::Settings {
winnable_deals_only,
..solitaire_data::Settings::default()
};
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}
#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
// Toggle off — the engine must use the seed it was handed and
// never invoke the solver. Seed 999 is just an arbitrary
// deterministic seed; the test asserts the resulting deal
// matches `GameState::new(999, DrawOne)`.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
assert_eq!(
actual_seed, 999,
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
for i in 0..7 {
assert_eq!(
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
expected.piles[&PileType::Tableau(i)].cards,
"tableau column {i} must match the unfiltered seed",
);
}
}
#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
// When seed is None and toggle is off, the engine uses a
// system-time seed and skips the solver. We can't pin the
// exact seed, but we can assert the seed is *not* the
// sentinel zero (which would only happen if SystemTime is
// before the epoch — practically impossible), AND that no
// resource has been mutated to suggest the solver ran.
// The strongest assertion is "the move runs to completion
// without panicking", which the .update() call covers.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
}
#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
// Even with the toggle on, an *explicit* seed must be honoured:
// daily challenges, replay seeding, and challenge-mode all
// pass `Some(seed)` and must never be retried.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(123),
mode: None,
confirmed: false,
});
app.update();
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
123,
"explicit-seed requests must skip the solver retry loop",
);
}
#[test]
fn choose_winnable_seed_skips_unwinnable_seed() {
// Seed 394 was identified by the offline scan
// (`solver::tests::find_unwinnable`) as the only Unwinnable
// seed in 0..500 under the default solver budget. Seed 395
// resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
assert_eq!(
chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
);
}
#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
// End-to-end: with the toggle on, fire a NewGameRequestEvent
// with seed=None and *manually pre-seed* the system-time
// path by clearing the GameStateResource so handle_new_game
// takes the random branch. We can't easily inject the
// system-time seed here, so we exercise the helper via a
// separate call and assert the *resource* receives the
// post-retry seed when the helper would have rejected.
//
// We test the integration by setting up an alternative
// scenario: pass `seed: Some(394)` with toggle on. Our
// implementation already documents that explicit seeds skip
// the retry, so this *won't* trigger retry. The cleaner
// integration is captured in `choose_winnable_seed_skips_*`.
// Here we verify the default-seed path doesn't crash when
// toggle is on — exercising the live solver call inside
// handle_new_game without depending on the solver picking
// a specific seed.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.undo_stack_len(),
0
);
}
/// Async-solver flow: a winnable-only request with no explicit
/// seed must populate `PendingNewGameSeed` on the same frame the
/// request fires (no main-thread stall waiting on the solver),
/// and subsequent updates must clear the pending state and
/// produce a new GameState.
///
/// Drives multiple `app.update()` calls because the polling
/// system needs at least one tick after spawn to observe the
/// task as ready and re-emit the synthetic event.
#[test]
fn winnable_seed_search_runs_async_and_completes_eventually() {
let mut app = test_app(394);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
// First update: handle_new_game spawns the solver task and
// returns. The GameStateResource is unchanged on this tick —
// the player's previous game is still on screen, so the UI
// doesn't visually stall.
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first frame should have an in-flight solver task",
);
// Pump frames until the polling system observes the task as
// ready and re-emits the synthetic event. AsyncComputeTaskPool
// is a shared pool across the whole `cargo test` run — when
// dozens of tests execute in parallel the pool can take a
// while to actually schedule our future. The yield_now() lets
// the pool's worker threads make progress between our polls
// without burning wall-clock time.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"solver task should have completed within 15 s wall-clock",
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
0,
"completed new game must be in fresh-deal state",
);
}
/// Cancel-on-replace: a winnable-only request that arrives while
/// a previous solver task is in flight must drop the previous
/// task and queue the new one. The most recently-fired request
/// is the one whose seed wins, regardless of which task started
/// first.
#[test]
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
let mut app = test_app(394);
insert_settings(&mut app, true);
// Fire the first request; first update spawns the task.
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first request should be in flight",
);
// Fire a SECOND request with an explicit seed before the
// first task can complete. handle_new_game's `pending.inner =
// None` line must drop the in-flight task; the explicit-seed
// branch then bypasses the solver entirely. After this tick
// the GameStateResource carries seed 12345, not whatever the
// solver would have picked for the first request.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(12345),
mode: None,
confirmed: true,
});
app.update();
// Drive a few more ticks to drain any stragglers.
for _ in 0..5 {
app.update();
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"explicit-seed request must have cancelled the in-flight task",
);
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
12345,
"explicit-seed request takes precedence over the dropped solver task",
);
}
}