//! 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::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() // `.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>, progress: Option>, font_res: Option>, screens: Query>, ) { 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>, progress: Option>, screens: Query>, mut new_game: MessageWriter, mut zen: MessageWriter, mut challenge: MessageWriter, mut time_attack: MessageWriter, mut daily: MessageWriter, mut info_toast: MessageWriter, ) { 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, Changed)>, screens: Query>, ) { 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 { 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>, progress: Option>, screens: Query>, mut new_game: MessageWriter, mut zen: MessageWriter, mut challenge: MessageWriter, mut time_attack: MessageWriter, mut daily: MessageWriter, ) { // 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>, parents: Query<&ChildOf>, scrims: Query<(), With>, progress: Option>, ) { 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 = 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::>(); 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::>(); input.press(KeyCode::KeyM); } app.update(); { let mut input = app.world_mut().resource_mut::>(); 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` 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::>() .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::>() .press(KeyCode::KeyM); app.update(); { let mut input = app.world_mut().resource_mut::>(); 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 = 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::>() .clear(); let card = find_card(&mut app, HomeMode::Classic); press_button(&mut app, card); let events = app.world().resource::>(); 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::>() .clear(); app.world_mut() .resource_mut::>() .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::>(); 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::>(); 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::() .0 .level = CHALLENGE_UNLOCK_LEVEL; let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); let card = find_card(&mut app, HomeMode::Zen); press_button(&mut app, card); let zen = app.world().resource::>(); 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::>() .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::>(); 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::>(); 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::().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)>() .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>() .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::>(); input.press(key); } app.update(); { let mut input = app.world_mut().resource_mut::>(); 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::>() .clear(); press_and_clear(&mut app, KeyCode::Digit1); let events = app.world().resource::>(); 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::>() .clear(); press_and_clear(&mut app, KeyCode::Digit3); let zen = app.world().resource::>(); 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::() .0 .level = CHALLENGE_UNLOCK_LEVEL; let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); press_and_clear(&mut app, KeyCode::Digit3); let zen = app.world().resource::>(); 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::() .0 .level = CHALLENGE_UNLOCK_LEVEL; // Drain any pre-existing events. app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .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::>(); 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::>(); 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::>(); 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::>(); 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::>(); let mut dc = daily.get_cursor(); assert!( dc.read(daily).next().is_none(), "Digit keys with no modal open must not fire StartDailyChallengeRequestEvent" ); } }