Files
Ferrous-Solitaire/solitaire_engine/src/pause_plugin.rs
T
funman300 ea1014285d
Test / test (pull_request) Successful in 15m21s
feat(engine): Show solution — auto-play the winning line from the pause menu
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.

Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:49 -07:00

1264 lines
44 KiB
Rust

//! Pause overlay (Esc).
//!
//! While paused:
//! - The `PausedResource` flag is true.
//! - Elapsed-time and Time Attack tickers stop counting (they read this
//! resource and bail out early).
//!
//! The pause modal is built on the standard `ui_modal` scaffold:
//! uniform scrim, centred card, real Resume / Forfeit buttons. Clicking
//! Forfeit (or pressing the `G` accelerator) fires
//! `ForfeitRequestEvent`, which spawns a `ForfeitConfirmScreen` modal
//! stacked above the pause card; confirming there fires `ForfeitEvent`
//! and dismisses both modals so the new game can start cleanly.
//!
//! This replaces the prior double-G keyboard countdown — see commit
//! history for the legacy `forfeit_countdown` toast design.
//!
//! **Drag cancellation:** when Esc is pressed while a mouse drag is in
//! progress, the drag is cancelled (cards snap back to their origin) and
//! the pause overlay is **not** opened. Pressing Esc again with no drag
//! active opens the overlay as normal.
use bevy::prelude::*;
use solitaire_core::DrawStockConfig;
use solitaire_data::save_game_state_to;
use crate::events::{
ForfeitEvent, ForfeitRequestEvent, InfoToastEvent, PauseRequestEvent, StateChangedEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::hud_plugin::HudPopoverOpen;
use crate::progress_plugin::ProgressResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::ui_modal::{
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
};
use bevy::ecs::system::SystemParam;
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
#[derive(Resource, Debug, Default)]
pub struct PausedResource(pub bool);
/// Marker on the pause overlay scrim.
#[derive(Component, Debug)]
pub struct PauseScreen;
/// Marker on the "Draw 1" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawOneButton;
/// Marker on the "Draw 3" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawThreeButton;
/// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)]
struct PauseResumeButton;
/// Marker on the Forfeit tertiary button on the pause modal. A click
/// fires `ForfeitRequestEvent`, the same event the `G` accelerator
/// fires, so the same code path opens the confirm modal either way.
#[derive(Component, Debug)]
struct PauseForfeitButton;
/// Marker on the "Show solution" secondary button on the pause modal.
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
/// `solution_playback_plugin` takes it from there.
#[derive(Component, Debug)]
struct PauseSolutionButton;
/// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)]
pub struct ForfeitConfirmScreen;
/// Marker on the Cancel secondary button inside the forfeit-confirm modal.
#[derive(Component, Debug)]
struct ForfeitCancelButton;
/// Marker on the "Forfeit" primary button inside the forfeit-confirm modal.
#[derive(Component, Debug)]
struct ForfeitConfirmButton;
/// Returns the human-readable label for a draw mode.
///
/// Used on the pause overlay draw-mode toggle button.
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
match mode {
DrawStockConfig::DrawOne => "Draw 1",
DrawStockConfig::DrawThree => "Draw 3",
}
}
/// Handles pause and resume: toggles the pause overlay on Esc, freezes
/// game-input systems via `PausedResource`, saves the in-progress game
/// to disk, and routes the Forfeit confirm-modal flow.
pub struct PausePlugin;
impl Plugin for PausePlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — other plugins may register these
// first, but a duplicate call is always safe.
app.add_message::<SettingsChangedEvent>()
.add_message::<StateChangedEvent>()
.add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.init_resource::<PausedResource>()
.add_systems(
Update,
(
// toggle_pause must see SelectionState *before* handle_selection_keys
// clears it, so it can skip Escape when a card is selected.
// It must also run *before* handle_forfeit_keyboard so the
// ForfeitConfirmScreen is still alive when toggle_pause's
// early-return guard checks for it — otherwise an Esc that
// closes the forfeit modal would also open pause in the
// same frame.
toggle_pause
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
auto_resume_on_overlay,
),
);
}
}
/// Bundles the modal-related queries `toggle_pause` reads each tick.
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
/// parameter cap after the cross-modal Esc guard query was added.
#[derive(SystemParam)]
struct PauseModalQueries<'w, 's> {
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
open_hud_popovers: Query<'w, 's, Entity, With<HudPopoverOpen>>,
}
#[allow(clippy::too_many_arguments)]
fn toggle_pause(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<PauseRequestEvent>,
mut paused: ResMut<PausedResource>,
modal_queries: PauseModalQueries<'_, '_>,
game: Option<Res<GameStateResource>>,
path: Option<Res<GameStatePath>>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
mut drag: Option<ResMut<DragState>>,
mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>,
replay_state: Option<Res<ReplayPlaybackState>>,
) {
let PauseModalQueries {
pause_screens: screens,
forfeit_screens,
game_over_screens,
other_modal_scrims,
open_hud_popovers,
} = modal_queries;
// Either Esc or a click on the HUD "Pause" button (which fires
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
// burst of clicks doesn't queue future toggles.
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::Escape) && !button_clicked {
return;
}
// Forfeit confirm modal eats Esc — let `handle_forfeit_keyboard`
// close it instead of toggling pause.
if !forfeit_screens.is_empty() {
return;
}
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
// Settings, etc.) owns its own dismissal — pause must not stack
// on top of it. Without this guard a single Esc both closes the
// open modal AND spawns the pause overlay underneath, leaving the
// player on a screen they didn't ask for. The HUD-button path
// (`button_clicked`) is gated too; clicking Pause while another
// modal is up is almost always an accident.
if !other_modal_scrims.is_empty() {
return;
}
// A HUD popover (Menu or Modes dropdown) is open — the popover's own
// Escape handler (in HudPlugin) will close it this frame. Don't also
// spawn the pause overlay on top of the closing popover.
if !open_hud_popovers.is_empty() {
return;
}
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
// own the Esc press — that handler stops the replay. Without this guard a
// single Esc both stops the replay AND opens the pause modal on top of the
// (now empty) board, leaving the player on a screen they didn't ask for.
// The HUD-button path is gated too; clicking Pause while watching a replay
// is almost always an accident.
if replay_state.is_some_and(|s| s.is_playing()) {
return;
}
// If a card is currently selected, let SelectionPlugin handle this Escape
// (it will clear the selection). Pause must not also open in the same frame.
if selection.is_some_and(|s| s.selected_pile.is_some()) {
return;
}
// If the game-over overlay is visible, let handle_game_over_input consume
// the Escape key (to start a new game). Do not open the pause overlay.
if !game_over_screens.is_empty() {
return;
}
// If a drag is in progress, cancel it instead of opening the pause overlay.
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
// back to their resting positions exactly as a rejected drop does.
if let Some(ref mut d) = drag
&& !d.is_idle()
{
d.clear();
changed.write(StateChangedEvent);
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
paused.0 = false;
} else {
// Snapshot current level and streak at pause time.
let level = progress.as_deref().map(|p| p.0.level);
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
spawn_pause_screen(&mut commands, level, streak, draw_mode, font_res.as_deref());
paused.0 = true;
// Persist the current game state whenever the player opens the pause
// overlay so an OS-level kill still leaves a resumable save.
if let (Some(g), Some(p)) = (game, path)
&& let Some(disk_path) = p.0.as_deref()
&& let Err(e) = save_game_state_to(disk_path, &g.0)
{
warn!("game_state: failed to save on pause: {e}");
}
}
}
/// Handles the draw-mode segmented control on the pause overlay.
///
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
/// so the rest of the engine sees the update. Change takes effect next game.
fn handle_pause_draw_buttons(
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
if !paused.0 {
return;
}
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if !pressed_one && !pressed_three {
return;
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one {
DrawStockConfig::DrawOne
} else {
DrawStockConfig::DrawThree
};
if settings.0.draw_mode == new_mode {
return;
}
settings.0.draw_mode = new_mode;
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
{
warn!("failed to save settings after draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Closes the pause modal when the player clicks the Resume button.
/// Routes through `PauseRequestEvent` so the same toggle path runs as
/// when Esc is pressed or the HUD Pause button is clicked.
fn handle_pause_resume_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseResumeButton>)>,
mut requests: MessageWriter<PauseRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
requests.write(PauseRequestEvent);
}
}
}
/// Translates a click on the pause modal's "Show solution" button into
/// a resume (`PauseRequestEvent` — playback can't run while paused)
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
fn handle_pause_solution_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
mut pause: MessageWriter<PauseRequestEvent>,
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
solution.write(crate::events::ShowSolutionRequestEvent);
}
}
}
/// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator.
fn handle_pause_forfeit_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseForfeitButton>)>,
mut requests: MessageWriter<ForfeitRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
requests.write(ForfeitRequestEvent);
}
}
}
/// Spawns `ForfeitConfirmScreen` in response to a `ForfeitRequestEvent`
/// (from the `G` accelerator or the Pause modal's Forfeit button).
///
/// Surfaces a toast and bails when there is no game to forfeit (won
/// state, or no `GameStateResource` at all) so the request is never
/// silently dropped — the prior implementation's silent no-op made the
/// pause modal's Forfeit button feel broken.
fn handle_forfeit_request(
mut commands: Commands,
mut requests: MessageReader<ForfeitRequestEvent>,
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
game: Option<Res<GameStateResource>>,
font_res: Option<Res<FontResource>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let requested = requests.read().count() > 0;
if !requested {
return;
}
if !forfeit_screens.is_empty() {
return;
}
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string()));
return;
}
spawn_forfeit_confirm_screen(&mut commands, font_res.as_deref());
}
/// Mouse / touch handler for the forfeit-confirm modal buttons.
///
/// Cancel despawns the confirm modal and leaves the pause modal (if
/// any) untouched. Confirm despawns both modals, clears the paused
/// flag, and fires `ForfeitEvent` for `StatsPlugin` to consume.
#[allow(clippy::too_many_arguments)]
fn handle_forfeit_confirm_buttons(
mut commands: Commands,
yes_buttons: Query<&Interaction, (Changed<Interaction>, With<ForfeitConfirmButton>)>,
no_buttons: Query<&Interaction, (Changed<Interaction>, With<ForfeitCancelButton>)>,
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
pause_screens: Query<Entity, With<PauseScreen>>,
paused: ResMut<PausedResource>,
forfeit: MessageWriter<ForfeitEvent>,
) {
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
if !confirmed && !cancelled {
return;
}
close_forfeit_modal(
&mut commands,
confirmed,
&forfeit_screens,
&pause_screens,
paused,
forfeit,
);
}
/// Keyboard accelerator for the forfeit-confirm modal — Y / Enter
/// confirms; N / Escape cancels.
fn handle_forfeit_keyboard(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
pause_screens: Query<Entity, With<PauseScreen>>,
paused: ResMut<PausedResource>,
forfeit: MessageWriter<ForfeitEvent>,
) {
if forfeit_screens.is_empty() {
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 && !cancelled {
return;
}
close_forfeit_modal(
&mut commands,
confirmed,
&forfeit_screens,
&pause_screens,
paused,
forfeit,
);
}
/// Common cleanup shared by the click and keyboard close paths.
///
/// On `confirm`: despawn both modals, clear the paused flag, and fire
/// `ForfeitEvent`. On cancel: despawn only the confirm modal so the
/// pause modal (if any) stays open.
fn close_forfeit_modal(
commands: &mut Commands,
confirm: bool,
forfeit_screens: &Query<Entity, With<ForfeitConfirmScreen>>,
pause_screens: &Query<Entity, With<PauseScreen>>,
mut paused: ResMut<PausedResource>,
mut forfeit: MessageWriter<ForfeitEvent>,
) {
for entity in forfeit_screens {
commands.entity(entity).despawn();
}
if confirm {
for entity in pause_screens {
commands.entity(entity).despawn();
}
paused.0 = false;
forfeit.write(ForfeitEvent);
}
}
/// Automatically closes the pause modal when any non-pause overlay opens
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
///
/// The player reaches these overlays via the HUD menu while paused, which
/// causes both the pause modal and the overlay to be live simultaneously.
/// That is always unintentional — the overlay should own the screen.
/// Query filter for modals that are not part of the pause flow.
/// Excludes both `PauseScreen` (the pause modal itself) and
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
type NonPauseFamilyScrim = (
With<ModalScrim>,
Without<PauseScreen>,
Without<ForfeitConfirmScreen>,
);
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
mut paused: ResMut<PausedResource>,
) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
return;
}
for entity in &pause_screens {
commands.entity(entity).despawn();
}
if paused.0 {
paused.0 = false;
}
}
/// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are
/// installed.
///
/// `level` and `streak` are optional snapshots taken at pause time —
/// rendered as the modal's body line. `draw_mode` is the current draw
/// mode shown on the toggle button. When the corresponding resources
/// are absent (e.g. headless tests) the related sections are omitted.
fn spawn_pause_screen(
commands: &mut Commands,
level: Option<u32>,
streak: Option<u32>,
draw_mode: Option<DrawStockConfig>,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
spawn_modal_header(card, "Paused", font_res);
if level.is_some() || streak.is_some() {
let info = build_level_streak_line(level, streak);
spawn_modal_body_text(card, info, TEXT_SECONDARY, font_res);
}
if let Some(mode) = draw_mode {
spawn_draw_mode_row(card, mode, font_res);
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
PauseForfeitButton,
"Forfeit",
Some("G"),
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
PauseResumeButton,
"Resume",
Some("Esc"),
ButtonVariant::Primary,
font_res,
);
});
});
}
/// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
///
/// The active option renders as `Secondary` (elevated), the inactive one as
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawStockConfig,
font_res: Option<&FontResource>,
) {
let label_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY_LG,
..default()
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let (one_variant, three_variant) = match mode {
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((Text::new("Draw Mode"), label_font, TextColor(TEXT_PRIMARY)));
spawn_modal_button(
row,
PauseDrawOneButton,
"Draw 1",
None,
one_variant,
font_res,
);
spawn_modal_button(
row,
PauseDrawThreeButton,
"Draw 3",
None,
three_variant,
font_res,
);
});
parent.spawn((
Text::new("Takes effect next game"),
caption_font,
TextColor(TEXT_SECONDARY),
));
}
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Forfeit" modal
/// stacked above the pause modal at `Z_PAUSE_DIALOG`.
fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(
commands,
ForfeitConfirmScreen,
ui_theme::Z_PAUSE_DIALOG,
|card| {
spawn_modal_header(card, "Forfeit this game?", font_res);
spawn_modal_body_text(
card,
"This will count as a loss and break your win streak.",
TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ForfeitCancelButton,
"Cancel",
Some("Esc"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
ForfeitConfirmButton,
"Forfeit",
Some("Y"),
ButtonVariant::Primary,
font_res,
);
});
},
);
}
/// Formats the level / win-streak summary line for the pause overlay.
///
/// Both values are optional because either resource may be absent in
/// headless or partially-configured app contexts.
fn build_level_streak_line(level: Option<u32>, streak: Option<u32>) -> String {
match (level, streak) {
(Some(l), Some(s)) => format!("Level {l} Win streak: {s}"),
(Some(l), None) => format!("Level {l}"),
(None, Some(s)) => format!("Win streak: {s}"),
(None, None) => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press_esc(app: &mut App) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::Escape);
input.clear();
input.press(KeyCode::Escape);
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
/// `MinimalPlugins` does not include the input-plugin tick that
/// transitions `just_pressed` → `pressed` between frames, so a key
/// press would otherwise stay "just-pressed" forever. Call this
/// helper between `app.update()` calls in tests that span multiple
/// frames without re-pressing — it mirrors the real input cycle.
fn advance_input(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
#[test]
fn pressing_esc_pauses() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
1
);
}
#[test]
fn pressing_esc_twice_resumes() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
press_esc(&mut app);
app.update();
assert!(!app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0
);
}
#[test]
fn toggle_is_symmetric_for_multiple_cycles() {
let mut app = headless_app();
// Third press re-pauses after resume.
press_esc(&mut app);
app.update();
press_esc(&mut app);
app.update();
press_esc(&mut app);
app.update();
assert!(
app.world().resource::<PausedResource>().0,
"third Esc must re-pause"
);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
1,
"third Esc must re-spawn PauseScreen"
);
}
// -----------------------------------------------------------------------
// build_level_streak_line (pure function)
// -----------------------------------------------------------------------
#[test]
fn level_streak_both_present() {
assert_eq!(
build_level_streak_line(Some(7), Some(3)),
"Level 7 Win streak: 3"
);
}
#[test]
fn level_streak_only_level() {
assert_eq!(build_level_streak_line(Some(5), None), "Level 5");
}
#[test]
fn level_streak_only_streak() {
assert_eq!(build_level_streak_line(None, Some(4)), "Win streak: 4");
}
#[test]
fn level_streak_neither() {
assert_eq!(build_level_streak_line(None, None), "");
}
// -----------------------------------------------------------------------
// Pause screen with progress / stats resources present
// -----------------------------------------------------------------------
#[test]
fn pause_screen_spawns_with_level_and_streak_when_resources_present() {
use crate::progress_plugin::{ProgressPlugin, ProgressResource};
use crate::settings_plugin::SettingsPlugin;
use crate::stats_plugin::{StatsPlugin, StatsResource};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(crate::game_plugin::GamePlugin)
.add_plugins(crate::table_plugin::TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(StatsPlugin::headless())
.add_plugins(SettingsPlugin::headless())
.add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// Set known values.
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.win_streak_current = 3;
press_esc(&mut app);
app.update();
// Verify the screen was spawned.
assert!(app.world().resource::<PausedResource>().0);
// Find the text nodes on the PauseScreen children and check one contains
// the expected level/streak string.
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
assert!(
texts.iter().any(|t| t == "Level 7 Win streak: 3"),
"expected level/streak line in pause screen texts, got: {texts:?}"
);
}
// -----------------------------------------------------------------------
// PausedResource default (pure)
// -----------------------------------------------------------------------
#[test]
fn paused_resource_default_is_unpaused() {
let p = PausedResource::default();
assert!(!p.0, "game must start unpaused");
}
// -----------------------------------------------------------------------
// draw_mode_label (pure function) — Task #64
// -----------------------------------------------------------------------
#[test]
fn draw_mode_label_draw_one() {
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
}
#[test]
fn draw_mode_label_draw_three() {
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
}
/// Both variants are covered so the match is exhaustive — this test would
/// fail to compile if a new DrawStockConfig variant were added without updating
/// `draw_mode_label`.
#[test]
fn draw_mode_label_covers_all_variants() {
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
let label = draw_mode_label(mode);
assert!(
!label.is_empty(),
"draw_mode_label must never return an empty string"
);
}
}
// -----------------------------------------------------------------------
// pause_draw_toggle_flips_draw_mode — Task #64
// -----------------------------------------------------------------------
#[test]
fn pause_draw_toggle_flips_draw_mode() {
use crate::settings_plugin::{SettingsPlugin, SettingsResource};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless())
.add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// Ensure we start with DrawOne.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.draw_mode = DrawStockConfig::DrawOne;
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
app.world_mut()
.spawn((PauseDrawThreeButton, Button, Interaction::Pressed));
app.update();
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode,
DrawStockConfig::DrawThree,
"pressing Draw 3 must set mode to DrawThree"
);
// Pressing "Draw 1" while DrawThree is active should switch back.
app.world_mut()
.spawn((PauseDrawOneButton, Button, Interaction::Pressed));
app.update();
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode2,
DrawStockConfig::DrawOne,
"pressing Draw 1 must set mode to DrawOne"
);
// Verify a SettingsChangedEvent was fired.
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
let count = cursor.read(events).count();
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
// Restore default settings state for hygiene.
let _ = Settings::default();
}
// -----------------------------------------------------------------------
// Pause modal exposes Resume + Forfeit buttons
// -----------------------------------------------------------------------
#[test]
fn pause_modal_has_resume_and_forfeit_buttons() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
let resume_count = app
.world_mut()
.query::<&PauseResumeButton>()
.iter(app.world())
.count();
let forfeit_count = app
.world_mut()
.query::<&PauseForfeitButton>()
.iter(app.world())
.count();
assert_eq!(
resume_count, 1,
"Resume button must be present on the pause modal"
);
assert_eq!(
forfeit_count, 1,
"Forfeit button must be present on the pause modal"
);
}
/// Clicking the Resume button (via Pressed interaction) closes the
/// pause modal — same outcome as a second Esc.
#[test]
fn pause_resume_button_closes_modal() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
// Mark the Resume button as Pressed.
let resume_entity = {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<PauseResumeButton>>();
q.iter(app.world())
.next()
.expect("Resume button must exist")
};
app.world_mut()
.entity_mut(resume_entity)
.insert(Interaction::Pressed);
// Clear keys so the simulated "click" isn't competing with a real Esc press.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.update();
// One more frame so the resulting PauseRequestEvent is consumed by toggle_pause.
app.update();
assert!(
!app.world().resource::<PausedResource>().0,
"Resume must clear PausedResource"
);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0,
"Resume must despawn PauseScreen"
);
}
// -----------------------------------------------------------------------
// Forfeit confirm modal
// -----------------------------------------------------------------------
/// Test app with the resources `handle_forfeit_request` reads.
/// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App {
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.update();
app
}
#[test]
fn forfeit_request_event_spawns_forfeit_confirm_screen() {
let mut app = forfeit_app();
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
1,
"ForfeitRequestEvent must spawn the forfeit-confirm modal"
);
}
#[test]
fn forfeit_request_does_not_double_spawn() {
let mut app = forfeit_app();
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
1,
"second ForfeitRequestEvent must not stack a second modal"
);
}
/// When the game is already won, a `ForfeitRequestEvent` must not
/// open the modal (you can't forfeit a finished game) and instead
/// surface an `InfoToastEvent` so the user gets feedback that the
/// hotkey was received but is currently a no-op.
#[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_won(true);
app.insert_resource(GameStateResource(game));
app.update();
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
0,
"the forfeit modal must not open when the current game is already won"
);
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).any(|t| t.0 == "No game to forfeit"),
"an InfoToastEvent must be fired so the player gets feedback"
);
}
#[test]
fn forfeit_confirm_y_key_fires_forfeit_event_and_despawns() {
let mut app = forfeit_app();
// Open the forfeit confirm modal.
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
1
);
// Press Y to confirm.
press_key(&mut app, KeyCode::KeyY);
app.update();
// Modal despawned.
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
0,
"Y must despawn the forfeit-confirm modal"
);
// ForfeitEvent fired.
let events = app.world().resource::<Messages<ForfeitEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
1,
"Y must fire exactly one ForfeitEvent"
);
}
#[test]
fn forfeit_confirm_n_key_cancels_without_firing_event() {
let mut app = forfeit_app();
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
press_key(&mut app, KeyCode::KeyN);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
0,
"N must despawn the forfeit-confirm modal"
);
let events = app.world().resource::<Messages<ForfeitEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"Cancelling the modal must not fire ForfeitEvent"
);
}
#[test]
fn forfeit_confirm_esc_cancels_without_toggling_pause() {
let mut app = forfeit_app();
// Open the forfeit confirm modal directly via G-equivalent event.
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
// Pause is NOT open at this point.
assert!(!app.world().resource::<PausedResource>().0);
press_esc(&mut app);
app.update();
// Forfeit modal is gone.
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
0
);
// Pause must NOT have toggled on — the Esc was consumed by the
// forfeit-confirm handler, and toggle_pause must early-return
// when a forfeit modal is visible (the despawn happens in the
// same frame, but the early-return guard runs first).
assert!(
!app.world().resource::<PausedResource>().0,
"Esc that closes the forfeit modal must not also open pause"
);
}
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
/// from the menu while game is paused), `auto_resume_on_overlay` must
/// despawn the pause modal and clear `PausedResource`.
#[test]
fn auto_resume_closes_pause_when_overlay_opens() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
1
);
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
app.world_mut().spawn(ModalScrim);
app.update();
assert!(
!app.world().resource::<PausedResource>().0,
"auto_resume_on_overlay must clear PausedResource when another modal opens"
);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0,
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
);
}
#[test]
fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app();
// Open pause first.
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
// Cycle input so Esc is no longer treated as `just_pressed` —
// otherwise the next update would toggle pause back off and the
// freshly-spawned forfeit modal would also be cancelled by the
// stale Esc in handle_forfeit_keyboard.
advance_input(&mut app);
// Open forfeit-confirm on top of pause.
app.world_mut()
.resource_mut::<Messages<ForfeitRequestEvent>>()
.write(ForfeitRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&ForfeitConfirmScreen>()
.iter(app.world())
.count(),
1
);
press_key(&mut app, KeyCode::KeyY);
app.update();
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0,
"confirming forfeit must also despawn the pause modal"
);
assert!(
!app.world().resource::<PausedResource>().0,
"PausedResource must be cleared when a forfeit is confirmed"
);
}
}