//! Home overlay shown when the player presses **M** or clicks the Home //! affordance. //! //! Phase B of the 2026-07 UI redesign (`docs/ui-redesign-2026-07.md`) //! turned the flat mode picker into a hierarchy: //! //! 1. **Continue** card — only when a game is in progress (moves made, //! not yet won); shows mode / elapsed / score and dismisses the //! overlay on click. //! 2. **Hero New Game** — one tap re-launches `Settings::last_mode` //! with the current deal options. A "Deal options" disclosure under //! the hero holds the draw 1/3 toggle, the winnable-only toggle, and //! the difficulty tier chips (Classic-mode concerns, off the top level). //! 3. Compact **2×3 mode grid** — Classic, Daily, Zen, Challenge, //! Time Attack, Play by Seed; descriptions render on wide viewports only. //! 4. **Stats strip** at the bottom (click opens Profile). //! //! On wide viewports ([`HOME_TWO_PANE_MIN_WIDTH`]) the body renders two //! panes — hero + grid left; Continue, daily callout, and stats right. //! `Cancel` is now **Back to table** and only renders while a live //! (un-won) game exists underneath the overlay. //! //! 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. The shortcut reference lives only in Help //! (`F1`), which is the canonical place for that information. use bevy::ecs::system::SystemParam; use bevy::input::ButtonInput; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::window::PrimaryWindow; use solitaire_core::{ DrawStockConfig, game_state::{DifficultyLevel, GameMode}, }; use solitaire_data::save_settings_to; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent, ToggleProfileRequestEvent, }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath}; use crate::stats_plugin::StatsResource; use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_modal::{ ButtonVariant, MODAL_CARD_MAX_WIDTH, ModalButton, ScrimDismissible, spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_modal_sized, }; use crate::ui_theme::{ ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY, 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 "Back to table" button that dismisses the /// Home modal without launching a mode. Only spawned while a live /// (un-won) game exists underneath the overlay. #[derive(Component, Debug)] pub struct HomeCancelButton; /// Marker on the Continue card shown when a game is in progress (moves /// made, not yet won). Clicking it dismisses the overlay, returning the /// player to their game — same effect as "Back to table". #[derive(Component, Debug)] pub struct HomeContinueCard; /// Marker on the hero "New Game" button. One tap re-launches /// `Settings::last_mode` with the current deal options. #[derive(Component, Debug)] pub struct HomeNewGameHero; /// Marker on the "Winnable only" toggle chip inside the deal-options /// disclosure. Clicking flips `Settings.winnable_deals_only`. #[derive(Component, Debug)] struct HomeWinnableToggle; /// Marker on the player-stats chip strip at the top of the Home modal. /// Clicking the strip opens the Profile overlay so the player can drill /// into level / XP / cosmetics without first dismissing Home. #[derive(Component, Debug)] struct HomeProfileChip; /// Marker on the "Draw 1" toggle button inside the Home modal's /// draw-mode row. Clicking flips `Settings.draw_mode` to `DrawOne` and /// fires `SettingsChangedEvent` so audio / UI dependents react. #[derive(Component, Debug)] struct HomeDrawOneButton; /// Marker on the "Draw 3" toggle button inside the Home modal's /// draw-mode row. Mirror of [`HomeDrawOneButton`] for `DrawThree`. #[derive(Component, Debug)] struct HomeDrawThreeButton; /// Marker on the scrollable inner Node containing the player chips, /// draw-mode row, and tile grid. Wrapping these in a scrollable /// container keeps the modal usable on small viewports — without it, /// the 3-row tile stack pushes the Cancel button off the bottom of /// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`. #[derive(Component, Debug)] struct HomeScrollable; /// Marker on the "> Deal options" / "v Deal options" toggle button that /// expands / collapses the deal-options disclosure (draw 1/3, winnable /// only, difficulty tier chips) under the hero New Game button. #[derive(Component, Debug)] struct HomeDealOptionsToggle; /// Marker on each difficulty tier chip inside the expanded difficulty /// section. The wrapped `DifficultyLevel` identifies which tier was /// clicked so the handler can fire `StartDifficultyRequestEvent`. #[derive(Component, Debug)] struct HomeDifficultyChip(DifficultyLevel); /// Whether the deal-options disclosure is currently expanded. Toggled by /// `handle_home_deal_options_toggle` and checked by `spawn_home_screen` /// to determine initial render state. /// /// Initialised at plugin startup; `spawn_home_on_launch` upgrades it /// to `true` when `settings.last_difficulty` is already set so /// returning players see their tier pre-expanded. #[derive(Resource, Default, Debug)] pub struct DealOptionsExpanded(pub bool); // --------------------------------------------------------------------------- // 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, PlayBySeed, } 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", HomeMode::PlayBySeed => "Play by Seed", } } /// 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?", HomeMode::PlayBySeed => "Enter any number to play a specific deal.", } } /// Unicode glyph rendered as the picture-tile centrepiece. Stand-in /// for real per-mode artwork — chosen for one-glyph-tells-the-mode /// readability rather than visual fidelity. Swap to `Image` nodes /// when art lands; the rest of the tile layout doesn't change. /// /// Picks are constrained to **card suits** (U+2660-2666), the /// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed /// present in the bundled FiraMono-Medium face. The Geometric /// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in /// that range render as missing-glyph rectangles on Android. fn glyph(self) -> &'static str { match self { // Black club — card suit; the obvious solitaire mark. HomeMode::Classic => "\u{2663}", // Black diamond suit — "gem of the day" reading. HomeMode::Daily => "\u{2666}", // Black heart suit — calm/warm; conveys the Zen mood. HomeMode::Zen => "\u{2665}", // Black spade suit — sharp/high-stakes; signals difficulty. HomeMode::Challenge => "\u{2660}", // Rightwards arrow — "go / fast-forward" for the timed mode. HomeMode::TimeAttack => "\u{2192}", // Number sign — ASCII; "a specific seed ID". HomeMode::PlayBySeed => "#", } } /// The keyboard accelerator that dispatches the same launch event, /// shown in a small chip on desktop cards. fn hotkey(self) -> Option<&'static str> { let key = match self { HomeMode::Classic => "N", HomeMode::Daily => "C", HomeMode::Zen => "Z", HomeMode::Challenge => "X", HomeMode::TimeAttack => "T", HomeMode::PlayBySeed => "6", }; crate::platform::SHOW_KEYBOARD_ACCELERATORS.then_some(key) } /// `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 } /// The [`GameMode`] persisted as `Settings::last_mode` when this card /// launches, or `None` for launches the hero New Game button cannot /// replay (Daily is date-fixed; Play by Seed re-opens the seed /// prompt rather than starting a deal). fn to_game_mode(self) -> Option { match self { HomeMode::Classic => Some(GameMode::Classic), HomeMode::Zen => Some(GameMode::Zen), HomeMode::Challenge => Some(GameMode::Challenge), HomeMode::TimeAttack => Some(GameMode::TimeAttack), HomeMode::Daily | HomeMode::PlayBySeed => None, } } } /// Marker component placed on each mode-card `Button` so the click /// handler can identify which mode was pressed. #[derive(Component, Debug)] struct HomeModeCard(HomeMode); /// Tracks whether the launch-time Home modal has already been auto-shown /// for this app session. Flipped to `true` by [`spawn_home_on_launch`] /// the first time it spawns the modal, so the auto-show is one-shot per /// process — subsequent dismissals (Cancel / mode pick) don't trigger /// a respawn, but the player can still re-open the picker with `M`. /// /// Other plugins (e.g. `game_plugin`'s restore-prompt handler) can flip /// the flag manually to suppress the launch auto-show when the player /// has already made a launch-time choice through a different surface. #[derive(Resource, Debug, Default)] pub struct LaunchHomeShown(pub bool); // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Registers the M-key toggle, the mode-card click handler, and the /// Cancel-button handler. /// /// `auto_show_on_launch` (default true) controls whether the picker /// auto-spawns once the splash clears at app start. Headless tests use /// [`HomePlugin::headless`] to opt out so each test starts with no /// modal in the world. pub struct HomePlugin { auto_show_on_launch: bool, } impl Default for HomePlugin { fn default() -> Self { Self { auto_show_on_launch: true, } } } impl HomePlugin { /// Test-only constructor that disables the launch-time auto-show. /// `MinimalPlugins` test setups don't include a splash, so the /// gating system would otherwise fire on the first tick and /// pre-spawn the modal that every test asserts is absent. pub fn headless() -> Self { Self { auto_show_on_launch: false, } } } impl Plugin for HomePlugin { fn build(&self, app: &mut App) { // Pre-mark the auto-show as already done in headless mode so the // gating system is a permanent no-op for tests. app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch)) .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() // Defensively register MouseWheel so `scroll_home_panel` // runs cleanly under MinimalPlugins headless tests too. .add_message::() // `.chain()` because several systems (M-toggle, card click, // hero button, back-to-table button, digit-key shortcut, // deal-options handlers) all read the `HomeScreen` entity and // may queue a despawn on it in the same tick. Chaining // serialises these systems and keeps the despawn deterministic. .add_systems( Update, ( spawn_home_on_launch, toggle_home_screen, attach_focusable_to_home_mode_cards, handle_home_card_click, handle_home_new_game_hero, handle_home_cancel_button, handle_home_profile_chip, handle_home_draw_mode_buttons, handle_home_winnable_toggle, handle_home_deal_options_toggle, handle_home_difficulty_chip_click, handle_home_digit_keys, ) .chain(), ) .add_systems(Update, scroll_home_panel); } } // --------------------------------------------------------------------------- // Shared system params + launch dispatch // --------------------------------------------------------------------------- /// Read access to every resource the Home modal renders from, bundled /// so the several spawn / repaint call sites share one parameter set. /// /// `settings` is `ResMut` because the deal-option handlers mutate it in /// place before respawning; read-only systems simply never write through /// it (Bevy change detection only triggers on `DerefMut`, so holding the /// mutable handle without writing stays quiet). #[derive(SystemParam)] struct HomeSpawnSources<'w, 's> { progress: Option>, stats: Option>, settings: Option>, daily: Option>, font_res: Option>, game: Option>, windows: Query<'w, 's, &'static Window, With>, } /// The launch-event writers shared by the card click, digit key, and /// hero New Game handlers, plus a dispatch table so all three surfaces /// fire byte-identical events. #[derive(SystemParam)] struct HomeLaunchWriters<'w> { new_game: MessageWriter<'w, NewGameRequestEvent>, zen: MessageWriter<'w, StartZenRequestEvent>, challenge: MessageWriter<'w, StartChallengeRequestEvent>, time_attack: MessageWriter<'w, StartTimeAttackRequestEvent>, daily: MessageWriter<'w, StartDailyChallengeRequestEvent>, play_by_seed: MessageWriter<'w, StartPlayBySeedRequestEvent>, difficulty: MessageWriter<'w, StartDifficultyRequestEvent>, } impl HomeLaunchWriters<'_> { /// Fires the launch event for a mode card. The caller has already /// checked the unlock gate. fn dispatch(&mut self, mode: HomeMode) { match mode { HomeMode::Classic => { self.new_game.write(NewGameRequestEvent::default()); } HomeMode::Daily => { self.daily.write(StartDailyChallengeRequestEvent); } HomeMode::Zen => { self.zen.write(StartZenRequestEvent); } HomeMode::Challenge => { self.challenge.write(StartChallengeRequestEvent); } HomeMode::TimeAttack => { self.time_attack.write(StartTimeAttackRequestEvent); } HomeMode::PlayBySeed => { self.play_by_seed.write(StartPlayBySeedRequestEvent); } } } /// Fires the launch event for the persisted [`GameMode`] the hero /// New Game button replays. fn dispatch_game_mode(&mut self, mode: GameMode) { match mode { GameMode::Classic => { self.new_game.write(NewGameRequestEvent::default()); } GameMode::Zen => { self.zen.write(StartZenRequestEvent); } GameMode::Challenge => { self.challenge.write(StartChallengeRequestEvent); } GameMode::TimeAttack => { self.time_attack.write(StartTimeAttackRequestEvent); } GameMode::Difficulty(level) => { self.difficulty.write(StartDifficultyRequestEvent { level }); } } } } /// Persists `Settings::last_mode` after a launch dispatch so the hero /// New Game button replays it next time. No-op when the mode is already /// current or settings aren't wired (headless tests). fn persist_last_mode( mode: GameMode, settings: &mut Option>, storage_path: Option<&SettingsStoragePath>, changed: &mut MessageWriter, ) { let Some(s) = settings.as_mut() else { return }; if s.0.last_mode == mode { return; } s.0.last_mode = mode; if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &s.0) { warn!("home: failed to persist last_mode: {e}"); } changed.write(SettingsChangedEvent(s.0.clone())); } // --------------------------------------------------------------------------- // Auto-show on launch // --------------------------------------------------------------------------- /// Auto-spawns the Home / mode-picker modal once per app session, so /// the player lands on a deliberate "what mode do I want to play" /// screen instead of the default Classic deal. /// /// Gated on the launch-time UI being clear: /// /// * `SplashRoot` must be gone — the splash owns the foreground during /// the brand beat and the home modal appearing under it would feel /// like a flash of half-rendered UI. /// * `RestorePromptScreen` must not be open and `PendingRestoredGame` /// must be empty — when the player has a saved in-progress game the /// restore prompt takes precedence; the home picker would compete /// with it for attention. /// * First-run onboarding must be finished (`Settings.first_run_complete` /// and no `OnboardingScreen` open) — on a fresh profile the onboarding /// owns the launch beat; Home appearing underneath it stacked two /// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home /// spawns on the first frame after the player finishes or skips. /// * The What's-new card (Phase I) must have had its beat — /// [`crate::whats_new_plugin::WhatsNewPending`] released — so an /// upgrade's release notes get read before the mode picker lands on /// top of them. /// * `HomeScreen` must not already exist (defensive — e.g. the player /// pressed `M` between ticks). /// * `LaunchHomeShown` flips to `true` after the first spawn so this /// system becomes a no-op for the rest of the session. Cancelling /// the modal therefore goes to the underlying default deal rather /// than respawning the picker. #[allow(clippy::too_many_arguments)] fn spawn_home_on_launch( mut commands: Commands, mut shown: ResMut, splash: Query<(), With>, restore_prompts: Query<(), With>, pending_restore: Option>, onboarding: Query<(), With>, whats_new: Option>, existing: Query<(), With>, sources: HomeSpawnSources, mut deal_expanded: ResMut, ) { if shown.0 || !splash.is_empty() || !restore_prompts.is_empty() || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) || !onboarding.is_empty() || whats_new.as_ref().is_some_and(|w| w.0) || sources .settings .as_ref() .is_some_and(|s| !s.0.first_run_complete) || !existing.is_empty() { return; } // Pre-expand the deal options when the player has a saved difficulty // preference — returning players see their tier chips immediately. if sources .settings .as_ref() .is_some_and(|s| s.0.last_difficulty.is_some()) { deal_expanded.0 = true; } spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); shown.0 = true; } // --------------------------------------------------------------------------- // M-key toggle // --------------------------------------------------------------------------- fn toggle_home_screen( mut commands: Commands, keys: Res>, mut requests: MessageReader, sources: HomeSpawnSources, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, deal_expanded: Res, ) { let button_clicked = requests.read().count() > 0; if !keys.just_pressed(KeyCode::KeyM) && !button_clicked { return; } if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else if other_modal_scrims.is_empty() { spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } } /// Builds a [`HomeContext`] from the live resources the Home modal /// reads. Falls back to safe defaults when a resource is missing /// (typical for `MinimalPlugins` headless tests that don't install /// every contributor plugin). fn build_home_context<'a>( sources: &'a HomeSpawnSources, deal_options_expanded: bool, ) -> HomeContext<'a> { let progress = sources.progress.as_deref(); let stats = sources.stats.as_deref(); let settings = sources.settings.as_deref(); let daily = sources.daily.as_deref(); let game = sources.game.as_deref(); let daily_today = daily.map(|d| { let completed_today = progress .and_then(|p| p.0.daily_challenge_last_completed) .is_some_and(|d_last| d_last == d.date); DailyToday { date_label: d.date.format("%b %-d").to_string(), goal: d.goal_description.clone(), completed_today, } }); // A live game is one the player could return to (dealt, not yet // won); it gates the "Back to table" action. The Continue card // additionally requires at least one move — a fresh untouched deal // is indistinguishable from "New Game" and doesn't earn a card. let game_live = game.is_some_and(|g| !g.0.is_won()); let continue_info = game.filter(|g| game_live && g.0.move_count() > 0).map(|g| { let g = &g.0; let mut caption = format!( "{} \u{2022} {}", game_mode_label(g.mode), format_elapsed(g.elapsed_seconds) ); if g.mode != GameMode::Zen { caption.push_str(&format!( " \u{2022} Score {}", format_compact(g.score() as u64) )); } ContinueInfo { caption } }); let wide = sources .windows .iter() .next() .is_some_and(|w| w.width() >= HOME_TWO_PANE_MIN_WIDTH); HomeContext { level: progress.map_or(0, |p| p.0.level), total_xp: progress.map_or(0, |p| p.0.total_xp), daily_streak: progress.map_or(0, |p| p.0.daily_challenge_streak), lifetime_score: stats.map_or(0, |s| s.0.lifetime_score), classic_best: stats.map_or(0, |s| s.0.classic_best_score), zen_best: stats.map_or(0, |s| s.0.zen_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), daily_today, draw_mode: settings .map(|s| s.0.draw_mode) .unwrap_or(DrawStockConfig::DrawOne), font_res: sources.font_res.as_deref(), deal_options_expanded, last_difficulty: settings.and_then(|s| s.0.last_difficulty), last_mode: settings.map(|s| s.0.last_mode).unwrap_or_default(), winnable_only: settings.is_some_and(|s| s.0.winnable_deals_only), continue_info, game_live, wide, } } // --------------------------------------------------------------------------- // Card click handler // --------------------------------------------------------------------------- /// Dispatches a click on a mode card. /// /// - **Unlocked** modes fire the matching `Start*RequestEvent` (or /// [`NewGameRequestEvent`] for Classic), persist `Settings::last_mode` /// (for replayable modes), 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 writers: HomeLaunchWriters, mut info_toast: MessageWriter, mut settings: Option>, storage_path: Option>, mut changed: 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; } if let Some(mode) = card.0.to_game_mode() { persist_last_mode(mode, &mut settings, storage_path.as_deref(), &mut changed); } writers.dispatch(card.0); // Close the modal after dispatching the launch event. for entity in &screens { commands.entity(entity).despawn(); } } } // --------------------------------------------------------------------------- // Hero New Game handler // --------------------------------------------------------------------------- /// Click on the hero "New Game" button — re-launch `Settings::last_mode` /// with the current deal options, then close the modal. A persisted /// gated mode the player can no longer launch (e.g. profile reset) /// falls back to Classic instead of dead-clicking. fn handle_home_new_game_hero( mut commands: Commands, heroes: Query<&Interaction, (With, Changed)>, screens: Query>, progress: Option>, settings: Option>, mut writers: HomeLaunchWriters, ) { if screens.is_empty() { return; } if !heroes.iter().any(|i| *i == Interaction::Pressed) { return; } let level = progress.as_ref().map_or(0, |p| p.0.level); let mut mode = settings.as_ref().map(|s| s.0.last_mode).unwrap_or_default(); let gated = matches!( mode, GameMode::Zen | GameMode::Challenge | GameMode::TimeAttack ); if gated && level < CHALLENGE_UNLOCK_LEVEL { mode = GameMode::Classic; } writers.dispatch_game_mode(mode); for entity in &screens { commands.entity(entity).despawn(); } } // --------------------------------------------------------------------------- // Back-to-table / Continue handlers // --------------------------------------------------------------------------- #[allow(clippy::type_complexity)] fn handle_home_cancel_button( mut commands: Commands, keys: Option>>, cancel_buttons: Query< &Interaction, ( Or<(With, With)>, Changed, ), >, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, ) { if screens.is_empty() { return; } let click = cancel_buttons.iter().any(|i| *i == Interaction::Pressed); let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)); // Esc only closes Home when it is the *topmost* modal. With Profile // (or any other ModalScrim) layered on top, the topmost owns the // dismissal — without this gate a single Esc closed the back // modal (Home) and left the front modal orphaned. let esc_targets_home = esc && other_modal_scrims.is_empty(); if !click && !esc_targets_home { return; } for entity in &screens { commands.entity(entity).despawn(); } } // --------------------------------------------------------------------------- // Header chip + draw-mode button handlers // --------------------------------------------------------------------------- /// Routes mouse-wheel events into the Home modal's scrollable body /// while the modal is open. No-op when no `HomeScrollable` exists in /// the world (modal closed). Mirrors `scroll_settings_panel` and /// `scroll_leaderboard_panel`. fn scroll_home_panel( mut scroll_evr: MessageReader, mut scrollables: Query<&mut ScrollPosition, With>, ) { if scrollables.is_empty() { scroll_evr.clear(); return; } let delta_y: f32 = scroll_evr .read() .map(|ev| match ev.unit { MouseScrollUnit::Line => ev.y * 50.0, MouseScrollUnit::Pixel => ev.y, }) .sum(); if delta_y == 0.0 { return; } for mut sp in scrollables.iter_mut() { sp.0.y = (sp.0.y - delta_y).max(0.0); } } /// Click on the player-stats header chip → fire /// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top /// of Home. Closing Profile (`P` / `Esc`) returns the player to the /// Home picker without losing their context. fn handle_home_profile_chip( chips: Query<&Interaction, (With, Changed)>, mut profile: MessageWriter, ) { if chips.iter().any(|i| *i == Interaction::Pressed) { profile.write(ToggleProfileRequestEvent); } } /// Click on a draw-mode chip — flip `Settings.draw_mode`, persist, /// fire `SettingsChangedEvent`, and respawn the Home modal so the /// active-chip styling reflects the new state. Repaint by full /// rebuild keeps the helper code small (no per-entity colour /// surgery) and the modal is light enough to respawn cleanly. #[allow(clippy::too_many_arguments)] fn handle_home_draw_mode_buttons( mut commands: Commands, one_buttons: Query<&Interaction, (With, Changed)>, three_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, mut sources: HomeSpawnSources, storage_path: Option>, mut changed: MessageWriter, deal_expanded: Res, ) { if screens.is_empty() { return; } // Don't respawn while another modal sits on top — the despawn queues // immediately but executes at end of frame, so a respawn in the same // frame would create a second concurrent ModalScrim. if !other_modal_scrims.is_empty() { return; } let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed); let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed); if !want_one && !want_three { return; } let Some(settings) = sources.settings.as_mut() else { return; }; let target = if want_one { DrawStockConfig::DrawOne } else { DrawStockConfig::DrawThree }; if settings.0.draw_mode == target { return; // already in this mode — avoid a redundant respawn. } settings.0.draw_mode = target; if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &settings.0) { warn!("home: failed to persist draw-mode change: {e}"); } changed.write(SettingsChangedEvent(settings.0.clone())); // Repaint by despawn + respawn so the chip styling and the hero // caption ("Classic • Draw 3") reflect the new state. for entity in &screens { commands.entity(entity).despawn(); } spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } /// Click on the "Winnable only" chip — flip `Settings.winnable_deals_only`, /// persist, fire `SettingsChangedEvent`, and respawn the modal for the /// chip styling. Same repaint-by-rebuild shape as the draw-mode handler. #[allow(clippy::too_many_arguments)] fn handle_home_winnable_toggle( mut commands: Commands, toggles: Query<&Interaction, (With, Changed)>, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, mut sources: HomeSpawnSources, storage_path: Option>, mut changed: MessageWriter, deal_expanded: Res, ) { if screens.is_empty() { return; } if !other_modal_scrims.is_empty() { return; } if !toggles.iter().any(|i| *i == Interaction::Pressed) { return; } let Some(settings) = sources.settings.as_mut() else { return; }; settings.0.winnable_deals_only = !settings.0.winnable_deals_only; if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &settings.0) { warn!("home: failed to persist winnable-only change: {e}"); } changed.write(SettingsChangedEvent(settings.0.clone())); for entity in &screens { commands.entity(entity).despawn(); } spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } // --------------------------------------------------------------------------- // Deal-options disclosure handlers // --------------------------------------------------------------------------- /// Click on the "> / v Deal options" header — toggle [`DealOptionsExpanded`] /// and repaint the Home modal so the chevron and disclosure body update. /// Mirrors `handle_home_draw_mode_buttons`: despawn + respawn keeps all /// styling in `spawn_deal_options_section` rather than scattered across /// mutation helpers. fn handle_home_deal_options_toggle( mut commands: Commands, toggles: Query<&Interaction, (With, Changed)>, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, mut deal_expanded: ResMut, sources: HomeSpawnSources, ) { if screens.is_empty() { return; } if !other_modal_scrims.is_empty() { return; } if !toggles.iter().any(|i| *i == Interaction::Pressed) { return; } deal_expanded.0 = !deal_expanded.0; for entity in &screens { commands.entity(entity).despawn(); } spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } /// Click on a difficulty tier chip — persist `last_difficulty` and /// `last_mode`, fire `StartDifficultyRequestEvent`, and close the Home /// modal. fn handle_home_difficulty_chip_click( mut commands: Commands, chips: Query<(&Interaction, &HomeDifficultyChip), Changed>, screens: Query>, mut difficulty_ev: MessageWriter, mut settings: Option>, storage_path: Option>, mut changed: MessageWriter, ) { if screens.is_empty() { return; } let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else { return; }; let level = chip.0; if let Some(s) = settings.as_mut() { s.0.last_difficulty = Some(level); s.0.last_mode = GameMode::Difficulty(level); if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &s.0) { warn!("home: failed to persist last_difficulty: {e}"); } changed.write(SettingsChangedEvent(s.0.clone())); } difficulty_ev.write(StartDifficultyRequestEvent { level }); 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), KeyCode::Digit6 => Some(HomeMode::PlayBySeed), _ => 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 writers: HomeLaunchWriters, mut settings: Option>, storage_path: Option>, mut changed: 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, KeyCode::Digit6, ] .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; } if let Some(game_mode) = mode.to_game_mode() { persist_last_mode( game_mode, &mut settings, storage_path.as_deref(), &mut changed, ); } writers.dispatch(mode); // Close the modal after dispatching the launch event — same shape as // the click handler. for entity in &screens { commands.entity(entity).despawn(); } } // --------------------------------------------------------------------------- // Spawn helpers // --------------------------------------------------------------------------- /// Bundles the data the Home modal needs to render — built fresh by /// every spawn / repaint call site via [`build_home_context`] from the /// live resources in [`HomeSpawnSources`], with sensible defaults when /// a resource is missing under `MinimalPlugins` headless tests. struct HomeContext<'a> { level: u32, total_xp: u64, lifetime_score: u64, classic_best: u32, zen_best: u32, challenge_best: u32, daily_streak: u32, daily_today: Option, draw_mode: DrawStockConfig, font_res: Option<&'a FontResource>, /// Whether the deal-options disclosure is currently expanded. deal_options_expanded: bool, /// The last difficulty tier the player selected (persisted in Settings). /// When `Some`, that tier's chip is highlighted. last_difficulty: Option, /// The persisted mode the hero New Game button replays. last_mode: GameMode, /// Winnable-only deal filter state for the deal-options chip. winnable_only: bool, /// In-progress game summary. `None` when there is no game, the deal /// is untouched, or the game is already won. continue_info: Option, /// `true` while a live (un-won) game exists underneath the overlay — /// gates the "Back to table" action row. game_live: bool, /// `true` on wide viewports — two-pane body plus card descriptions. wide: bool, } /// In-progress game summary rendered on the Continue card. struct ContinueInfo { /// Pre-formatted "mode • elapsed • score" line. caption: String, } /// Today's daily-challenge metadata as the Home picker needs it. Only /// populated when both [`DailyChallengeResource`] is present (the /// plugin is wired) and we have something useful to show — otherwise /// the Daily card falls back to its baseline description without a /// dated callout. struct DailyToday { /// Short calendar label, e.g. `"May 6"`. Always populated. date_label: String, /// Server-supplied goal copy ("Win in under 5 minutes"). `None` /// when no server backend is wired or the fetch hasn't returned. goal: Option, /// `true` when the player has already recorded today's daily. /// Surfaces a "Done" badge so the picker reads as reward-state /// rather than "you still owe today's run". completed_today: bool, } /// Logical window width at/above which the Home modal renders the /// two-pane layout (hero + grid left; Continue / daily / stats right) /// and mode-card descriptions. 1000 keeps a folded phone portrait /// (900 logical px on the Fold 7) on the single-column layout while /// catching desktop windows and the unfolded posture. const HOME_TWO_PANE_MIN_WIDTH: f32 = 1000.0; /// Card max width while the two-pane layout is active — the default /// 720 px card cannot fit two readable panes side by side. const HOME_WIDE_CARD_MAX_WIDTH: f32 = 1080.0; /// Spawns the Home modal — Continue card, hero New Game with the /// deal-options disclosure, the 2×3 mode grid, and the stats strip, /// plus "Back to table" while a live game exists. See the module docs /// for the phase-B hierarchy. fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { let font_res = ctx.font_res; let max_width = if ctx.wide { HOME_WIDE_CARD_MAX_WIDTH } else { MODAL_CARD_MAX_WIDTH }; let scrim = spawn_modal_sized(commands, HomeScreen, Z_MODAL_PANEL, max_width, |card| { spawn_modal_header(card, "Home", font_res); // Scrollable middle. Constrained to 70vh so the modal fits on // small viewports (the tile grid alone is ~400 px). The action // row sits outside this node so it's always one click away. card.spawn(( HomeScrollable, ScrollPosition::default(), Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_3, width: Val::Percent(100.0), max_height: Val::Vh(70.0), overflow: Overflow::scroll_y(), ..default() }, )) .with_children(|body| { if ctx.wide { // Two panes: launch surfaces left, "my state" right. body.spawn(Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_3, align_items: AlignItems::FlexStart, width: Val::Percent(100.0), ..default() }) .with_children(|panes| { panes .spawn(Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_3, width: Val::Percent(58.0), flex_grow: 1.0, ..default() }) .with_children(|left| { spawn_new_game_hero(left, &ctx); spawn_deal_options_section(left, &ctx); spawn_mode_grid(left, &ctx); }); panes .spawn(Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_3, width: Val::Percent(40.0), ..default() }) .with_children(|right| { spawn_continue_card(right, &ctx); spawn_daily_callout(right, &ctx); spawn_home_stats_strip(right, &ctx); }); }); } else { // Single column, hierarchy top to bottom: Continue, // hero New Game (+ deal options), mode grid, stats. spawn_continue_card(body, &ctx); spawn_new_game_hero(body, &ctx); spawn_deal_options_section(body, &ctx); spawn_mode_grid(body, &ctx); spawn_home_stats_strip(body, &ctx); } }); // "Back to table" only renders while a live game exists — at a // fresh launch there is nothing meaningful to go back to, and // the hero New Game is the primary path forward. if ctx.game_live { spawn_modal_actions(card, |actions| { spawn_modal_button( actions, HomeCancelButton, "Back to table", Some("M"), ButtonVariant::Tertiary, font_res, ); }); } }); // Home is read-only — opt into click-outside-to-dismiss. commands.entity(scrim).insert(ScrimDismissible); } /// The wrapping 2-column mode tile grid — Classic, Daily, Zen, /// Challenge, Time Attack, Play by Seed (a symmetric 2×3). fn spawn_mode_grid(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { parent .spawn(Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, row_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3, width: Val::Percent(100.0), ..default() }) .with_children(|grid| { for mode in [ HomeMode::Classic, HomeMode::Daily, HomeMode::Zen, HomeMode::Challenge, HomeMode::TimeAttack, HomeMode::PlayBySeed, ] { spawn_mode_card(grid, mode, ctx); } }); } /// Continue card — only rendered while a game is in progress. Shows the /// mode / elapsed / score caption; clicking dismisses the overlay (the /// cancel handler also listens on [`HomeContinueCard`]). fn spawn_continue_card(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let Some(info) = ctx.continue_info.as_ref() else { return; }; let font_handle = ctx.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_caption = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; parent .spawn(( HomeContinueCard, Button, ModalButton(ButtonVariant::Secondary), 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(ACCENT_PRIMARY), HighContrastBorder::with_default(ACCENT_PRIMARY), )) .with_children(|c| { c.spawn(( Text::new("Continue"), font_title.clone(), TextColor(ACCENT_PRIMARY), )); c.spawn(( Text::new(info.caption.clone()), font_caption.clone(), TextColor(TEXT_SECONDARY), )); }); } /// Hero "New Game" button — the primary launch surface. One tap replays /// `Settings::last_mode` with the current deal options; the caption /// spells out exactly what that tap starts. fn spawn_new_game_hero(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.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_caption = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; let draw_label = match ctx.draw_mode { DrawStockConfig::DrawOne => "Draw 1", DrawStockConfig::DrawThree => "Draw 3", }; let mut caption = format!("{} \u{2022} {}", game_mode_label(ctx.last_mode), draw_label); if ctx.winnable_only { caption.push_str(" \u{2022} winnable"); } parent .spawn(( HomeNewGameHero, Button, ModalButton(ButtonVariant::Primary), Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, 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(ACCENT_PRIMARY), BorderColor::all(BORDER_STRONG), HighContrastBorder::with_default(BORDER_STRONG), )) .with_children(|c| { c.spawn(( Text::new("New Game"), font_title.clone(), TextColor(BG_BASE), )); c.spawn((Text::new(caption), font_caption.clone(), TextColor(BG_BASE))); }); } /// Daily callout for the wide layout's right pane — mirrors the dated /// caption on the Daily grid card so "today's state" lives beside /// Continue and the stats strip. fn spawn_daily_callout(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let Some(today) = ctx.daily_today.as_ref() else { return; }; let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; let font_value = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; let date_text = if today.completed_today { format!("Today, {} \u{2022} Done", today.date_label) } else { format!("Today, {}", today.date_label) }; let date_color = if today.completed_today { ACCENT_PRIMARY } else { STATE_INFO }; parent .spawn(( 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), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|c| { c.spawn(( Text::new("Daily Challenge"), font_label.clone(), TextColor(TEXT_SECONDARY), )); c.spawn(( Text::new(date_text), font_value.clone(), TextColor(date_color), )); if let Some(goal) = today.goal.as_ref() { c.spawn(( Text::new(format!("Goal: {goal}")), font_label.clone(), TextColor(TEXT_SECONDARY), )); } }); } /// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a /// whole to open the Profile overlay (mirrors the MSSC top-right /// avatar+rewards corner that surfaces level + premium status). Phase B /// moved it from the header to the bottom of the column (right pane on /// wide viewports) so launch surfaces own the top of the hierarchy. fn spawn_home_stats_strip(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; let font_value = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; parent .spawn(( HomeProfileChip, Button, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, justify_content: JustifyContent::SpaceBetween, column_gap: VAL_SPACE_2, padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), width: Val::Percent(100.0), ..default() }, BackgroundColor(BG_ELEVATED), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|row| { for (label, value) in [ ("Level".to_string(), format_compact(ctx.level as u64)), ("XP".to_string(), format_compact(ctx.total_xp)), ("Score".to_string(), format_compact(ctx.lifetime_score)), ] { row.spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, row_gap: VAL_SPACE_1, ..default() }) .with_children(|col| { col.spawn(( Text::new(label), font_label.clone(), TextColor(TEXT_SECONDARY), )); col.spawn(( Text::new(value), font_value.clone(), TextColor(ACCENT_PRIMARY), )); }); } }); } /// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic /// deal (the Settings value the new-game flow reads). Lives inside the /// deal-options disclosure so the per-game choice stays one tap away /// without occupying the top level of the Home hierarchy. fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; let font_btn = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne); 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"), font_label.clone(), TextColor(TEXT_SECONDARY), )); spawn_draw_mode_chip::( row, HomeDrawOneButton, "Draw 1", active_one, &font_btn, ); spawn_draw_mode_chip::( row, HomeDrawThreeButton, "Draw 3", !active_one, &font_btn, ); }); } fn spawn_draw_mode_chip( parent: &mut ChildSpawnerCommands, marker: M, label: &str, active: bool, font: &TextFont, ) { let (bg, fg) = if active { (ACCENT_PRIMARY, BG_ELEVATED) } else { (BG_ELEVATED_HI, TEXT_PRIMARY) }; parent .spawn(( marker, Button, Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(bg), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|c| { c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg))); }); } /// Collapsible deal-options disclosure rendered directly under the hero /// New Game button — the "on the Classic card / New Game hero" home for /// the Classic-mode deal concerns (Phase B decision 2). /// /// Structure: /// ```text /// > Deal options ← HomeDealOptionsToggle (Button, row) /// Draw mode [Draw 1] [Draw 3] ← expanded only /// Deals [Winnable only] ← expanded only /// Difficulty [Easy] [Medium] ... [Random] ← expanded only /// ``` /// /// The toggle header despawns + respawns the home screen (same pattern as /// the draw-mode chips) so the chevron direction and disclosure body /// update without Visibility component surgery. fn spawn_deal_options_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() }; let font_caption = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; let chevron = if ctx.deal_options_expanded { "v" } else { ">" }; // Header row — click to toggle expand/collapse. parent .spawn(( HomeDealOptionsToggle, Button, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1), ..default() }, )) .with_children(|row| { row.spawn(( Text::new(chevron), font_label.clone(), TextColor(TEXT_SECONDARY), )); row.spawn(( Text::new("Deal options"), font_label.clone(), TextColor(TEXT_SECONDARY), )); }); if !ctx.deal_options_expanded { return; } spawn_draw_mode_row(parent, ctx); // Winnable-only filter chip. parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_3, ..default() }) .with_children(|row| { row.spawn(( Text::new("Deals"), font_caption.clone(), TextColor(TEXT_SECONDARY), )); spawn_draw_mode_chip::( row, HomeWinnableToggle, "Winnable only", ctx.winnable_only, &font_chip, ); }); // Difficulty tier chips. parent .spawn(Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, align_items: AlignItems::Center, row_gap: VAL_SPACE_2, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new("Difficulty"), font_caption.clone(), TextColor(TEXT_SECONDARY), )); for level in [ DifficultyLevel::Easy, DifficultyLevel::Medium, DifficultyLevel::Hard, DifficultyLevel::Expert, DifficultyLevel::Grandmaster, DifficultyLevel::Random, ] { let active = ctx.last_difficulty == Some(level); let (bg, fg) = if active { (ACCENT_PRIMARY, BG_ELEVATED) } else { (BG_ELEVATED_HI, TEXT_PRIMARY) }; row.spawn(( HomeDifficultyChip(level), Button, Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(bg), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|c| { c.spawn((Text::new(level.label()), font_chip.clone(), TextColor(fg))); }); } }); } /// Short display label for a persisted [`GameMode`] — used by the /// Continue card caption and the hero New Game subtitle. fn game_mode_label(mode: GameMode) -> String { match mode { GameMode::Classic => "Classic".to_string(), GameMode::Zen => "Zen".to_string(), GameMode::Challenge => "Challenge".to_string(), GameMode::TimeAttack => "Time Attack".to_string(), GameMode::Difficulty(level) => format!("Classic {}", level.label()), } } /// `m:ss` (or `h:mm:ss` past an hour) for the Continue card caption. fn format_elapsed(secs: u64) -> String { let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } } /// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`, /// otherwise the raw number with thousands separators. Keeps chip text /// short enough to fit a 3-up header strip without wrapping. fn format_compact(n: u64) -> String { if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) } else if n >= 10_000 { format!("{:.1}K", n as f64 / 1_000.0) } else if n >= 1_000 { let (high, low) = (n / 1_000, n % 1_000); format!("{high},{low:03}") } else { n.to_string() } } /// Per-mode score / streak chip text. `None` for modes where no /// per-mode best exists yet (Time Attack uses session scoring; modes /// with `0` recorded mean "no win yet" and we hide the chip rather /// than show a 0). fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option { match mode { HomeMode::Classic if ctx.classic_best > 0 => { Some(format!("Best {}", format_compact(ctx.classic_best as u64))) } HomeMode::Zen if ctx.zen_best > 0 => { Some(format!("Best {}", format_compact(ctx.zen_best as u64))) } HomeMode::Challenge if ctx.challenge_best > 0 => Some(format!( "Best {}", format_compact(ctx.challenge_best as u64) )), HomeMode::Daily if ctx.daily_streak > 0 => Some(format!("Streak {}", ctx.daily_streak)), _ => None, } } /// 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, HomeMode::PlayBySeed => 5, } } /// 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, ctx: &HomeContext<'_>) { let level = ctx.level; let font_res = ctx.font_res; let score_chip = score_chip_text_for(mode, ctx); 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.clone(), font_size: TYPE_CAPTION, ..default() }; // Glyph rendered at display size — Unicode emoji standing in for // the per-mode artwork. Centred at the top of the tile. let font_glyph = TextFont { font: font_handle, font_size: TYPE_DISPLAY, ..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 }; let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED }; 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, ModalButton(ButtonVariant::Secondary), Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_2, padding: UiRect::all(VAL_SPACE_3), // 48% per tile + the row's column_gap = a clean 2-up // grid — six modes make a symmetric 2×3. Compact on // narrow viewports where the description is hidden. width: Val::Percent(48.0), min_height: Val::Px(if ctx.wide { 180.0 } else { 120.0 }), align_items: AlignItems::Center, 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| { // Centerpiece glyph — placeholder for real per-mode art. c.spawn(( Text::new(mode.glyph().to_string()), font_glyph.clone(), TextColor(glyph_color), )); // 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, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new(mode.title().to_string()), font_title.clone(), TextColor(title_color), )); if unlocked { // Hotkey chip — suppressed on touch-first Android builds. if let Some(hotkey) = mode.hotkey() { 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), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|chip| { chip.spawn(( Text::new(hotkey), 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 — wide viewports only; the compact // narrow tile is glyph + name (+ state chips). if ctx.wide { c.spawn(( Text::new(mode.description().to_string()), font_desc.clone(), TextColor(desc_color), )); } // Per-mode score / streak chip — populated only when the // player has data for this mode. Hidden on a 0 best so a // fresh profile doesn't show "Best 0" everywhere. if let Some(text) = score_chip.clone() && unlocked { c.spawn(( Text::new(text), font_chip.clone(), TextColor(ACCENT_PRIMARY), Node { margin: UiRect::top(VAL_SPACE_1), ..default() }, )); } // Daily-only "Today's Event" caption — date, optional // server goal, and a "Done" badge once the player has // already recorded today's completion. Only renders for // the Daily card when DailyChallengeResource is present. if matches!(mode, HomeMode::Daily) && unlocked && let Some(today) = ctx.daily_today.as_ref() { let date_text = if today.completed_today { format!("Today, {} \u{2022} Done", today.date_label) } else { format!("Today, {}", today.date_label) }; let date_color = if today.completed_today { ACCENT_PRIMARY } else { STATE_INFO }; c.spawn(( Text::new(date_text), font_chip.clone(), TextColor(date_color), Node { margin: UiRect::top(VAL_SPACE_1), ..default() }, )); if let Some(goal) = today.goal.as_ref() { c.spawn(( Text::new(format!("Goal: {goal}")), font_chip.clone(), TextColor(TEXT_SECONDARY), )); } } // 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::headless()); 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 ); } /// The HUD Menu popover's "Home" row fires /// `ToggleHomeRequestEvent`; it must open Home exactly like the /// `M` accelerator (Phase C: the popover's Play section replaces /// the old Modes row). #[test] fn toggle_home_event_opens_home_screen() { let mut app = headless_app(); app.world_mut() .resource_mut::>() .write(ToggleHomeRequestEvent); app.update(); assert_eq!( app.world_mut() .query::<&HomeScreen>() .iter(app.world()) .count(), 1, "ToggleHomeRequestEvent must open the Home modal" ); // A second request toggles it closed, matching the M key. app.world_mut() .resource_mut::>() .write(ToggleHomeRequestEvent); app.update(); app.update(); assert_eq!( app.world_mut() .query::<&HomeScreen>() .iter(app.world()) .count(), 0, "second ToggleHomeRequestEvent must close the Home modal" ); } #[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, HomeMode::PlayBySeed, ] { assert!( modes.contains(&expected), "missing card for {expected:?}; found {modes:?}" ); } assert_eq!(modes.len(), 6, "exactly six 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::headless()); 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(), 6, "all six 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, Has)>() .iter(app.world()) .map(|(c, d)| (c.0, d)) .collect(); for (mode, disabled) in states { match mode { HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => 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" ); } // ----------------------------------------------------------------------- // Phase B: Continue card, hero New Game, deal-options disclosure // ----------------------------------------------------------------------- /// Count entities carrying `C` — shorthand for the presence asserts /// the Phase B tests make repeatedly. fn count_with(app: &mut App) -> usize { app.world_mut() .query_filtered::<(), With>() .iter(app.world()) .count() } #[test] fn continue_card_absent_on_untouched_deal() { let mut app = headless_app(); let _ = open_home(&mut app); assert_eq!( count_with::(&mut app), 0, "an untouched deal must not earn a Continue card" ); // But the deal is live, so Back to table renders. assert_eq!( count_with::(&mut app), 1, "Back to table must render while a live game exists" ); } #[test] fn continue_card_click_closes_modal_without_launching() { let mut app = headless_app(); app.world_mut() .resource_mut::() .0 .draw() .expect("draw from a fresh deal must succeed"); let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); let card = app .world_mut() .query::<(Entity, &HomeContinueCard)>() .single(app.world()) .map(|(e, _)| e) .expect("Continue card must exist once a move was made"); press_button(&mut app, card); assert_eq!( app.world_mut() .query::<&HomeScreen>() .iter(app.world()) .count(), 0, "Continue must dismiss the modal (back to the running game)" ); let new_game = app.world().resource::>(); let mut nc = new_game.get_cursor(); assert!( nc.read(new_game).next().is_none(), "Continue must not fire NewGameRequestEvent" ); } #[test] fn back_to_table_and_continue_absent_when_game_won() { let mut app = headless_app(); app.world_mut() .resource_mut::() .0 .set_test_won(true); let _ = open_home(&mut app); assert_eq!( count_with::(&mut app), 0, "a won game is over — no Back to table" ); assert_eq!( count_with::(&mut app), 0, "a won game is over — no Continue card" ); } #[test] fn hero_new_game_fires_classic_by_default() { let mut app = headless_app(); let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); let hero = app .world_mut() .query::<(Entity, &HomeNewGameHero)>() .single(app.world()) .map(|(e, _)| e) .expect("hero New Game button must exist"); press_button(&mut app, hero); let new_game = app.world().resource::>(); let mut nc = new_game.get_cursor(); assert_eq!( nc.read(new_game).count(), 1, "hero must fire exactly one NewGameRequestEvent by default" ); assert_eq!( app.world_mut() .query::<&HomeScreen>() .iter(app.world()) .count(), 0, "Home modal must close after the hero launch" ); } #[test] fn hero_new_game_replays_persisted_zen_mode() { let mut app = headless_app(); app.insert_resource(SettingsResource(solitaire_data::Settings { last_mode: GameMode::Zen, ..Default::default() })); app.world_mut().resource_mut::().0.level = CHALLENGE_UNLOCK_LEVEL; let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .clear(); let hero = app .world_mut() .query::<(Entity, &HomeNewGameHero)>() .single(app.world()) .map(|(e, _)| e) .expect("hero New Game button must exist"); press_button(&mut app, hero); let zen = app.world().resource::>(); let mut zc = zen.get_cursor(); assert_eq!( zc.read(zen).count(), 1, "hero must replay the persisted Zen mode" ); let new_game = app.world().resource::>(); let mut nc = new_game.get_cursor(); assert!( nc.read(new_game).next().is_none(), "hero must not also fire a Classic NewGameRequestEvent" ); } #[test] fn hero_falls_back_to_classic_when_last_mode_locked() { let mut app = headless_app(); app.insert_resource(SettingsResource(solitaire_data::Settings { last_mode: GameMode::Zen, ..Default::default() })); // Level 0 — Zen is locked, the hero must not dead-click. let _ = open_home(&mut app); app.world_mut() .resource_mut::>() .clear(); app.world_mut() .resource_mut::>() .clear(); let hero = app .world_mut() .query::<(Entity, &HomeNewGameHero)>() .single(app.world()) .map(|(e, _)| e) .expect("hero New Game button must exist"); press_button(&mut app, hero); let zen = app.world().resource::>(); let mut zc = zen.get_cursor(); assert!( zc.read(zen).next().is_none(), "a locked persisted mode must not launch" ); let new_game = app.world().resource::>(); let mut nc = new_game.get_cursor(); assert_eq!( nc.read(new_game).count(), 1, "hero must fall back to Classic when last_mode is locked" ); } #[test] fn card_click_persists_last_mode() { let mut app = headless_app(); app.insert_resource(SettingsResource(solitaire_data::Settings::default())); app.world_mut().resource_mut::().0.level = CHALLENGE_UNLOCK_LEVEL; let _ = open_home(&mut app); let card = find_card(&mut app, HomeMode::Zen); press_button(&mut app, card); assert_eq!( app.world().resource::().0.last_mode, GameMode::Zen, "launching Zen from its card must persist last_mode" ); } #[test] fn daily_click_does_not_change_last_mode() { let mut app = headless_app(); app.insert_resource(SettingsResource(solitaire_data::Settings::default())); let _ = open_home(&mut app); let card = find_card(&mut app, HomeMode::Daily); press_button(&mut app, card); assert_eq!( app.world().resource::().0.last_mode, GameMode::Classic, "Daily is date-fixed and must not become the hero's replay mode" ); } #[test] fn deal_options_collapsed_by_default_and_expand_on_toggle() { let mut app = headless_app(); let _ = open_home(&mut app); assert_eq!( count_with::(&mut app), 0, "draw chips must be hidden while the disclosure is collapsed" ); assert_eq!( count_with::(&mut app), 0, "winnable chip must be hidden while the disclosure is collapsed" ); let toggle = app .world_mut() .query::<(Entity, &HomeDealOptionsToggle)>() .single(app.world()) .map(|(e, _)| e) .expect("deal-options toggle must exist"); press_button(&mut app, toggle); // The toggle respawns the modal; run one more tick so the fresh // hierarchy is in place before querying. app.update(); assert_eq!( count_with::(&mut app), 1, "draw chips must render once the disclosure expands" ); assert_eq!( count_with::(&mut app), 1, "winnable chip must render once the disclosure expands" ); } #[test] fn winnable_toggle_flips_setting() { let mut app = headless_app(); app.insert_resource(SettingsResource(solitaire_data::Settings::default())); app.insert_resource(DealOptionsExpanded(true)); let _ = open_home(&mut app); let chip = app .world_mut() .query::<(Entity, &HomeWinnableToggle)>() .single(app.world()) .map(|(e, _)| e) .expect("winnable chip must exist while expanded"); press_button(&mut app, chip); assert!( app.world() .resource::() .0 .winnable_deals_only, "clicking the winnable chip must flip winnable_deals_only" ); } }