Files
Ferrous-Solitaire/solitaire_engine/src/home_plugin.rs
T
funman300 cbf2483028 feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.

Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.

Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:47:02 +00:00

1169 lines
41 KiB
Rust

//! Mode-launcher overlay shown when the player presses **M** or clicks the
//! Modes affordance.
//!
//! Replaces the prior "keyboard shortcut reference" Home modal with a
//! vertical stack of five mode cards — Classic, Daily Challenge, Zen,
//! Challenge, Time Attack. Clicking a card fires the same launch event
//! the corresponding hotkey does, then closes the overlay. The shortcut
//! reference now lives only in Help (`F1`), which is the canonical place
//! for that information.
//!
//! Level-gated modes (Zen, Challenge, Time Attack) are disabled below
//! `CHALLENGE_UNLOCK_LEVEL`; clicking a locked card fires an
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Public marker components
// ---------------------------------------------------------------------------
/// Marker component on the Home overlay root entity (the modal scrim).
#[derive(Component, Debug)]
pub struct HomeScreen;
/// Marker on the bottom-row "Cancel" button that dismisses the Home modal
/// without launching a mode.
#[derive(Component, Debug)]
pub struct HomeCancelButton;
// ---------------------------------------------------------------------------
// Private mode-card data shape
// ---------------------------------------------------------------------------
/// Which game mode a [`HomeModeCard`] represents.
///
/// Kept private — external consumers should write the corresponding
/// `Start*RequestEvent` (or [`NewGameRequestEvent`] for Classic) directly.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
enum HomeMode {
Classic,
Daily,
Zen,
Challenge,
TimeAttack,
}
impl HomeMode {
/// Display title shown on the card.
fn title(self) -> &'static str {
match self {
HomeMode::Classic => "Classic",
HomeMode::Daily => "Daily Challenge",
HomeMode::Zen => "Zen Mode",
HomeMode::Challenge => "Challenge",
HomeMode::TimeAttack => "Time Attack",
}
}
/// One-line description shown below the title.
fn description(self) -> &'static str {
match self {
HomeMode::Classic => "The standard Klondike deal — score, time, and a fresh shuffle.",
HomeMode::Daily => "Today's seed, same for everyone. Build a streak.",
HomeMode::Zen => "No timer, no score. Just the cards.",
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
}
}
/// The keyboard accelerator that dispatches the same launch event,
/// shown in a small chip on the card.
fn hotkey(self) -> &'static str {
match self {
HomeMode::Classic => "N",
HomeMode::Daily => "C",
HomeMode::Zen => "Z",
HomeMode::Challenge => "X",
HomeMode::TimeAttack => "T",
}
}
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
fn requires_unlock(self) -> bool {
matches!(self, HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack)
}
/// `true` if the player at `level` is allowed to launch the mode.
fn is_unlocked(self, level: u32) -> bool {
!self.requires_unlock() || level >= CHALLENGE_UNLOCK_LEVEL
}
}
/// Marker component placed on each mode-card `Button` so the click
/// handler can identify which mode was pressed.
#[derive(Component, Debug)]
struct HomeModeCard(HomeMode);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers the M-key toggle, the mode-card click handler, and the
/// Cancel-button handler.
pub struct HomePlugin;
impl Plugin for HomePlugin {
fn build(&self, app: &mut App) {
// Be defensive about message registration so HomePlugin works
// standalone in tests (the actual handlers live in
// input_plugin / challenge_plugin / time_attack_plugin /
// daily_challenge_plugin, but those plugins might not be
// installed in a tightly-scoped headless app).
app.add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
// `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the
// same tick. Bevy's parallel scheduler would otherwise let
// two of them run simultaneously and double-despawn the
// entity, panicking when the second command buffer is
// applied. Chaining serialises these systems and keeps the
// despawn deterministic.
.add_systems(
Update,
(
toggle_home_screen,
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_cancel_button,
handle_home_digit_keys,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// M-key toggle
// ---------------------------------------------------------------------------
fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
let level = progress.as_ref().map_or(0, |p| p.0.level);
spawn_home_screen(&mut commands, level, font_res.as_deref());
}
}
// ---------------------------------------------------------------------------
// Card click handler
// ---------------------------------------------------------------------------
/// Dispatches a click on a mode card.
///
/// - **Unlocked** modes fire the matching `Start*RequestEvent` (or
/// [`NewGameRequestEvent`] for Classic) and despawn the modal.
/// - **Locked** modes (level below [`CHALLENGE_UNLOCK_LEVEL`]) fire only
/// an [`InfoToastEvent`] and leave the modal open so the player can
/// pick another mode.
#[allow(clippy::too_many_arguments)]
fn handle_home_card_click(
mut commands: Commands,
cards: Query<(&Interaction, &HomeModeCard), Changed<Interaction>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
for (interaction, card) in &cards {
if *interaction != Interaction::Pressed {
continue;
}
if !card.0.is_unlocked(level) {
info_toast.write(InfoToastEvent(format!(
"{} unlocks at level {CHALLENGE_UNLOCK_LEVEL}",
card.0.title()
)));
// Leave the modal open so the player can pick another mode.
continue;
}
match card.0 {
HomeMode::Classic => {
new_game.write(NewGameRequestEvent::default());
}
HomeMode::Daily => {
daily.write(StartDailyChallengeRequestEvent);
}
HomeMode::Zen => {
zen.write(StartZenRequestEvent);
}
HomeMode::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
// Close the modal after dispatching the launch event.
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Cancel button handler
// ---------------------------------------------------------------------------
fn handle_home_cancel_button(
mut commands: Commands,
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
// ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped
// ---------------------------------------------------------------------------
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
/// function so the keyboard handler reads as a clean dispatch table and so
/// the mapping is easy to unit-test in isolation.
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
match key {
KeyCode::Digit1 => Some(HomeMode::Classic),
KeyCode::Digit2 => Some(HomeMode::Daily),
KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
_ => None,
}
}
/// Direct keyboard activation of a specific mode while the Mode Launcher
/// modal is open. Mirrors the click-handler dispatch in
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
/// Attack respectively when the player has reached
/// [`CHALLENGE_UNLOCK_LEVEL`].
///
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
/// system returns immediately, so digit keys can never accidentally launch
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
/// the click-on-locked-card behaviour) and leaves the modal open so the
/// player can pick another mode.
#[allow(clippy::too_many_arguments)]
fn handle_home_digit_keys(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
) {
// Modal-scoped: do nothing when the Mode Launcher isn't open.
if screens.is_empty() {
return;
}
let Some(mode) = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
]
.into_iter()
.find(|k| keys.just_pressed(*k))
.and_then(digit_to_home_mode) else {
return;
};
let level = progress.as_ref().map_or(0, |p| p.0.level);
if !mode.is_unlocked(level) {
// Locked mode: no-op, modal stays open.
return;
}
match mode {
HomeMode::Classic => {
new_game.write(NewGameRequestEvent::default());
}
HomeMode::Daily => {
daily.write(StartDailyChallengeRequestEvent);
}
HomeMode::Zen => {
zen.write(StartZenRequestEvent);
}
HomeMode::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
// Close the modal after dispatching the launch event — same shape as
// the click handler.
for entity in &screens {
commands.entity(entity).despawn();
}
}
// ---------------------------------------------------------------------------
// Spawn helpers
// ---------------------------------------------------------------------------
/// Spawns the Home modal with five mode cards plus a Cancel button.
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Choose a Mode", font_res);
for mode in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
spawn_mode_card(card, mode, level, font_res);
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
HomeCancelButton,
"Cancel",
Some("M"),
ButtonVariant::Tertiary,
font_res,
);
});
});
// Home is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Tab-walk order for each mode card, matching the visual top-to-bottom
/// stack inside the Home modal. Lower numbers receive focus first under
/// `Focusable`'s sort.
fn home_mode_focus_order(mode: HomeMode) -> i32 {
match mode {
HomeMode::Classic => 0,
HomeMode::Daily => 1,
HomeMode::Zen => 2,
HomeMode::Challenge => 3,
HomeMode::TimeAttack => 4,
}
}
/// Auto-attaches [`Focusable`] (and [`Disabled`] when locked) to every
/// newly-spawned [`HomeModeCard`]. Walks ancestors to find the
/// [`crate::ui_modal::ModalScrim`] so each card's focus group is bound
/// to its parent modal — mirrors the convention that
/// `attach_focusable_to_modal_buttons` uses for `ModalButton`s.
///
/// Doing this in a system (instead of inline at spawn time) lets
/// `spawn_home_screen` keep using the existing `spawn_modal`'s
/// build-closure shape; the scrim entity isn't visible inside that
/// closure, only after the call returns. The system runs every frame
/// and is a no-op once every card has been tagged.
fn attach_focusable_to_home_mode_cards(
mut commands: Commands,
new_cards: Query<(Entity, &HomeModeCard), Without<Focusable>>,
parents: Query<&ChildOf>,
scrims: Query<(), With<crate::ui_modal::ModalScrim>>,
progress: Option<Res<ProgressResource>>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
for (card_entity, card) in &new_cards {
// Walk ancestors until we find the ModalScrim. Bounded loop so a
// malformed hierarchy can't hang the system — same defensive
// shape as `attach_focusable_to_modal_buttons`.
let mut current = card_entity;
let mut scrim_entity: Option<Entity> = None;
for _ in 0..32 {
if scrims.get(current).is_ok() {
scrim_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
let Some(scrim) = scrim_entity else { continue };
commands.entity(card_entity).insert(Focusable {
group: FocusGroup::Modal(scrim),
order: home_mode_focus_order(card.0),
});
if !card.0.is_unlocked(level) {
commands.entity(card_entity).insert(Disabled);
}
}
}
/// Spawns one mode card — a `Button` whose children are a title row, a
/// description line, and (when locked) a "Reach level N" hint.
///
/// The visual deliberately diverges from `spawn_modal_button` because a
/// mode card is a wide, two-line tile rather than a compact action; the
/// `ButtonVariant` palette would not apply cleanly here. Hover/press
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
/// component, which we attach with `ButtonVariant::Secondary` so the card
/// reads as a standard interactive surface.
fn spawn_mode_card(
parent: &mut ChildSpawnerCommands,
mode: HomeMode,
level: u32,
font_res: Option<&FontResource>,
) {
let unlocked = mode.is_unlocked(level);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_title = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_desc = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_chip = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// Locked cards mute their text to communicate the disabled state at
// a glance; the explicit "Unlocks at level N" caption underneath
// backs that up with copy.
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
parent
.spawn((
HomeModeCard(mode),
// Keep this a real Button entity so clicks resolve through
// bevy::ui — the click handler queries on `&Interaction`
// which Button drives.
Button,
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_3),
width: Val::Percent(100.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(border_color),
))
.with_children(|c| {
// Title row — title text on the left, hotkey chip on the right.
c.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((
Text::new(mode.title().to_string()),
font_title.clone(),
TextColor(title_color),
));
if unlocked {
// Hotkey chip — same look as the kbd-chip rows used
// elsewhere so accelerators read consistently.
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(32.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(mode.hotkey().to_string()),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
});
} else {
// Lock icon stand-in — text glyph keeps the layout
// dependency-free (no asset loader required) and
// reads at every supported font size.
row.spawn((
Text::new("LOCKED".to_string()),
font_chip.clone(),
TextColor(STATE_INFO),
));
}
});
// Description line.
c.spawn((
Text::new(mode.description().to_string()),
font_desc.clone(),
TextColor(desc_color),
));
// Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked {
c.spawn((
Text::new(format!(
"Unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)),
TextFont {
font: font_desc.font.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
/// Builds a headless `App` with just the plugins Home actually
/// reaches into. We deliberately skip input_plugin /
/// challenge_plugin / time_attack_plugin / daily_challenge_plugin —
/// Home only needs to dispatch their request events; the events
/// themselves are registered defensively by `HomePlugin::build`.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Press M, run a tick, and return the resulting screen entity.
/// Panics if the modal does not appear (failure mode that any later
/// assertion would mask anyway). The keyboard input is cleared after
/// the press so the next `app.update()` doesn't re-toggle the modal
/// closed — `MinimalPlugins` doesn't run the bevy_input update system
/// that would normally clear `just_pressed` between frames.
fn open_home(app: &mut App) -> Entity {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(KeyCode::KeyM);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyM);
input.clear();
}
app.world_mut()
.query::<(Entity, &HomeScreen)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeScreen must spawn after M press")
}
/// Pump a button-press synthetic interaction onto the entity. Bevy
/// 0.18 surfaces interactions through the `Interaction` component
/// driven by the UI input pipeline, but MinimalPlugins does not run
/// that pipeline — so we insert `Interaction::Pressed` directly,
/// which triggers `Changed<Interaction>` on the next update tick.
/// Pattern is borrowed verbatim from `pause_plugin`'s tests.
fn press_button(app: &mut App, entity: Entity) {
app.world_mut()
.entity_mut(entity)
.insert(Interaction::Pressed);
app.update();
}
/// Find the unique `HomeModeCard` entity for a specific mode. Used
/// by the click-handler tests to target the right card.
fn find_card(app: &mut App, mode: HomeMode) -> Entity {
app.world_mut()
.query::<(Entity, &HomeModeCard)>()
.iter(app.world())
.find(|(_, c)| c.0 == mode)
.map(|(e, _)| e)
.unwrap_or_else(|| panic!("no HomeModeCard for {mode:?}"))
}
#[test]
fn pressing_m_spawns_home_screen() {
let mut app = headless_app();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyM);
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1
);
}
#[test]
fn pressing_m_twice_closes_home_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyM);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyM);
input.clear();
input.press(KeyCode::KeyM);
}
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0
);
}
#[test]
fn modal_contains_a_card_for_each_mode() {
let mut app = headless_app();
let _ = open_home(&mut app);
let modes: Vec<HomeMode> = app
.world_mut()
.query::<&HomeModeCard>()
.iter(app.world())
.map(|c| c.0)
.collect();
for expected in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
assert!(
modes.contains(&expected),
"missing card for {expected:?}; found {modes:?}"
);
}
assert_eq!(modes.len(), 5, "exactly five cards expected");
}
#[test]
fn classic_click_fires_new_game_event_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the click-driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Classic);
press_button(&mut app, card);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1, "one NewGameRequestEvent must fire");
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic"
);
}
#[test]
fn locked_zen_click_is_a_noop_below_unlock_level() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
// Reset event queues so the assertion is clean.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
// No launch events should have fired.
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"locked Zen click must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"locked Zen click must not fire StartZenRequestEvent"
);
// Modal must still be open so the player can pick another mode.
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode click"
);
}
#[test]
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"unlocked Zen click must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen"
);
}
#[test]
fn cancel_button_closes_modal_without_launching_anything() {
let mut app = headless_app();
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
let cancel = app
.world_mut()
.query::<(Entity, &HomeCancelButton)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeCancelButton must exist when modal is open");
press_button(&mut app, cancel);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Cancel must despawn the modal"
);
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Cancel must not fire NewGameRequestEvent"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — Home mode cards
// -----------------------------------------------------------------------
/// Headless app variant that also installs the focus and modal
/// plugins so `attach_focusable_to_modal_buttons` and Phase 2's
/// `attach_focusable_to_home_mode_cards` can run.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Open the Home modal at the given player level. Tags the cards
/// with `Focusable` (and, when locked, `Disabled`) by running an
/// extra tick after the M press so the focus-attach system fires.
fn open_home_at_level(app: &mut App, level: u32) -> Entity {
app.world_mut().resource_mut::<ProgressResource>().0.level = level;
let entity = open_home(app);
// One more tick so `attach_focusable_to_home_mode_cards` runs
// on the freshly-spawned cards.
app.update();
entity
}
#[test]
fn home_mode_cards_get_focusable_marker() {
let mut app = headless_app_with_focus();
let scrim = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
// Every card carries `Focusable` in `FocusGroup::Modal(scrim)`.
let cards: Vec<(HomeMode, Focusable)> = app
.world_mut()
.query::<(&HomeModeCard, &Focusable)>()
.iter(app.world())
.map(|(c, f)| (c.0, *f))
.collect();
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
for (mode, focusable) in &cards {
assert_eq!(
focusable.group,
FocusGroup::Modal(scrim),
"{mode:?} card must be in the Home scrim's focus group"
);
}
}
#[test]
fn home_locked_cards_get_disabled_marker() {
let mut app = headless_app_with_focus();
// Level 0: Zen, Challenge, Time Attack are locked; Classic and
// Daily are not.
let _ = open_home_at_level(&mut app, 0);
let states: Vec<(HomeMode, bool)> = app
.world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
.iter(app.world())
.map(|(c, d)| (c.0, d))
.collect();
for (mode, disabled) in states {
match mode {
HomeMode::Classic | HomeMode::Daily => assert!(
!disabled,
"{mode:?} must not be Disabled at level 0 (it's never locked)"
),
HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack => assert!(
disabled,
"{mode:?} must carry the Disabled marker at level 0 so Tab skips it"
),
}
}
}
#[test]
fn home_unlocked_cards_no_disabled_marker() {
let mut app = headless_app_with_focus();
let _ = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
let any_disabled = app
.world_mut()
.query_filtered::<&HomeModeCard, With<Disabled>>()
.iter(app.world())
.next()
.is_some();
assert!(
!any_disabled,
"no card may be Disabled when the player is at the unlock level"
);
}
// -----------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
// -----------------------------------------------------------------------
/// Press a key and clear the input afterwards so the next `update()`
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
/// for an arbitrary key (the M-press helper releases & clears KeyM,
/// which is also what we need here for Digit keys).
fn press_and_clear(app: &mut App, key: KeyCode) {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(key);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
}
}
#[test]
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the digit-key driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit1);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(
fired.len(),
1,
"exactly one NewGameRequestEvent must fire for Digit1"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic via Digit1"
);
}
#[test]
fn digit3_at_level_zero_is_a_noop() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit3 at level 0 must not fire StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode digit press"
);
}
#[test]
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen via Digit3"
);
}
#[test]
fn digit_keys_outside_home_modal_are_noop() {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartChallengeRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
.clear();
// Press every digit 1-5 in turn — none should trigger a launch.
for key in [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
] {
press_and_clear(&mut app, key);
}
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Digit keys with no modal open must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
);
}
}