Files
Ferrous-Solitaire/solitaire_engine/src/home_plugin.rs
T
funman300 fc87b13e5b
Test / test (pull_request) Successful in 37m46s
style: cargo fmt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:11:54 -07:00

2961 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<GameMode> {
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::<DealOptionsExpanded>()
.add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel`
// runs cleanly under MinimalPlugins headless tests too.
.add_message::<MouseWheel>()
// `.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<Res<'w, ProgressResource>>,
stats: Option<Res<'w, StatsResource>>,
settings: Option<ResMut<'w, SettingsResource>>,
daily: Option<Res<'w, DailyChallengeResource>>,
font_res: Option<Res<'w, FontResource>>,
game: Option<Res<'w, GameStateResource>>,
windows: Query<'w, 's, &'static Window, With<PrimaryWindow>>,
}
/// 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<ResMut<SettingsResource>>,
storage_path: Option<&SettingsStoragePath>,
changed: &mut MessageWriter<SettingsChangedEvent>,
) {
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.
/// * `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<LaunchHomeShown>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
existing: Query<(), With<HomeScreen>>,
sources: HomeSpawnSources,
mut deal_expanded: ResMut<DealOptionsExpanded>,
) {
if shown.0
|| !splash.is_empty()
|| !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|| !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<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleHomeRequestEvent>,
sources: HomeSpawnSources,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
deal_expanded: Res<DealOptionsExpanded>,
) {
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<Interaction>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut writers: HomeLaunchWriters,
mut info_toast: MessageWriter<InfoToastEvent>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
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<HomeNewGameHero>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
progress: Option<Res<ProgressResource>>,
settings: Option<Res<SettingsResource>>,
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<Res<ButtonInput<KeyCode>>>,
cancel_buttons: Query<
&Interaction,
(
Or<(With<HomeCancelButton>, With<HomeContinueCard>)>,
Changed<Interaction>,
),
>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
) {
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<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
) {
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<HomeProfileChip>, Changed<Interaction>)>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
) {
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<HomeDrawOneButton>, Changed<Interaction>)>,
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut sources: HomeSpawnSources,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
deal_expanded: Res<DealOptionsExpanded>,
) {
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<HomeWinnableToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut sources: HomeSpawnSources,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
deal_expanded: Res<DealOptionsExpanded>,
) {
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<HomeDealOptionsToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut deal_expanded: ResMut<DealOptionsExpanded>,
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<Interaction>>,
screens: Query<Entity, With<HomeScreen>>,
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
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<HomeMode> {
match key {
KeyCode::Digit1 => Some(HomeMode::Classic),
KeyCode::Digit2 => Some(HomeMode::Daily),
KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
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<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut writers: HomeLaunchWriters,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
// 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<DailyToday>,
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<DifficultyLevel>,
/// 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<ContinueInfo>,
/// `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<String>,
/// `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::<HomeDrawOneButton>(
row,
HomeDrawOneButton,
"Draw 1",
active_one,
&font_btn,
);
spawn_draw_mode_chip::<HomeDrawThreeButton>(
row,
HomeDrawThreeButton,
"Draw 3",
!active_one,
&font_btn,
);
});
}
fn spawn_draw_mode_chip<M: Component>(
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::<HomeWinnableToggle>(
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<String> {
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<Focusable>>,
parents: Query<&ChildOf>,
scrims: Query<(), With<crate::ui_modal::ModalScrim>>,
progress: Option<Res<ProgressResource>>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
for (card_entity, card) in &new_cards {
// Walk ancestors until we find the ModalScrim. Bounded loop so a
// malformed hierarchy can't hang the system — same defensive
// shape as `attach_focusable_to_modal_buttons`.
let mut current = card_entity;
let mut scrim_entity: Option<Entity> = None;
for _ in 0..32 {
if scrims.get(current).is_ok() {
scrim_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
let Some(scrim) = scrim_entity else { continue };
commands.entity(card_entity).insert(Focusable {
group: FocusGroup::Modal(scrim),
order: home_mode_focus_order(card.0),
});
if !card.0.is_unlocked(level) {
commands.entity(card_entity).insert(Disabled);
}
}
}
/// Spawns one mode card — a `Button` whose children are a title row, a
/// description line, and (when locked) a "Reach level N" hint.
///
/// The visual deliberately diverges from `spawn_modal_button` because a
/// mode card is a wide, two-line tile rather than a compact action; the
/// `ButtonVariant` palette would not apply cleanly here. Hover/press
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
/// component, which we attach with `ButtonVariant::Secondary` so the card
/// reads as a standard interactive surface.
fn spawn_mode_card(parent: &mut ChildSpawnerCommands, mode: HomeMode, 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::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Press M, run a tick, and return the resulting screen entity.
/// Panics if the modal does not appear (failure mode that any later
/// assertion would mask anyway). The keyboard input is cleared after
/// the press so the next `app.update()` doesn't re-toggle the modal
/// closed — `MinimalPlugins` doesn't run the bevy_input update system
/// that would normally clear `just_pressed` between frames.
fn open_home(app: &mut App) -> Entity {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(KeyCode::KeyM);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyM);
input.clear();
}
app.world_mut()
.query::<(Entity, &HomeScreen)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeScreen must spawn after M press")
}
/// Pump a button-press synthetic interaction onto the entity. Bevy
/// 0.18 surfaces interactions through the `Interaction` component
/// driven by the UI input pipeline, but MinimalPlugins does not run
/// that pipeline — so we insert `Interaction::Pressed` directly,
/// which triggers `Changed<Interaction>` on the next update tick.
/// Pattern is borrowed verbatim from `pause_plugin`'s tests.
fn press_button(app: &mut App, entity: Entity) {
app.world_mut()
.entity_mut(entity)
.insert(Interaction::Pressed);
app.update();
}
/// Find the unique `HomeModeCard` entity for a specific mode. Used
/// by the click-handler tests to target the right card.
fn find_card(app: &mut App, mode: HomeMode) -> Entity {
app.world_mut()
.query::<(Entity, &HomeModeCard)>()
.iter(app.world())
.find(|(_, c)| c.0 == mode)
.map(|(e, _)| e)
.unwrap_or_else(|| panic!("no HomeModeCard for {mode:?}"))
}
#[test]
fn pressing_m_spawns_home_screen() {
let mut app = headless_app();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyM);
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1
);
}
/// 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::<Messages<ToggleHomeRequestEvent>>()
.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::<Messages<ToggleHomeRequestEvent>>()
.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::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyM);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyM);
input.clear();
input.press(KeyCode::KeyM);
}
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0
);
}
#[test]
fn modal_contains_a_card_for_each_mode() {
let mut app = headless_app();
let _ = open_home(&mut app);
let modes: Vec<HomeMode> = app
.world_mut()
.query::<&HomeModeCard>()
.iter(app.world())
.map(|c| c.0)
.collect();
for expected in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
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::<Messages<NewGameRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Classic);
press_button(&mut app, card);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1, "one NewGameRequestEvent must fire");
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic"
);
}
#[test]
fn locked_zen_click_is_a_noop_below_unlock_level() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
// Reset event queues so the assertion is clean.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
// No launch events should have fired.
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"locked Zen click must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"locked Zen click must not fire StartZenRequestEvent"
);
// Modal must still be open so the player can pick another mode.
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode click"
);
}
#[test]
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level.
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"unlocked Zen click must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen"
);
}
#[test]
fn cancel_button_closes_modal_without_launching_anything() {
let mut app = headless_app();
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
let cancel = app
.world_mut()
.query::<(Entity, &HomeCancelButton)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeCancelButton must exist when modal is open");
press_button(&mut app, cancel);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Cancel must despawn the modal"
);
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Cancel must not fire NewGameRequestEvent"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — Home mode cards
// -----------------------------------------------------------------------
/// Headless app variant that also installs the focus and modal
/// plugins so `attach_focusable_to_modal_buttons` and Phase 2's
/// `attach_focusable_to_home_mode_cards` can run.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Open the Home modal at the given player level. Tags the cards
/// with `Focusable` (and, when locked, `Disabled`) by running an
/// extra tick after the M press so the focus-attach system fires.
fn open_home_at_level(app: &mut App, level: u32) -> Entity {
app.world_mut().resource_mut::<ProgressResource>().0.level = level;
let entity = open_home(app);
// One more tick so `attach_focusable_to_home_mode_cards` runs
// on the freshly-spawned cards.
app.update();
entity
}
#[test]
fn home_mode_cards_get_focusable_marker() {
let mut app = headless_app_with_focus();
let scrim = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
// Every card carries `Focusable` in `FocusGroup::Modal(scrim)`.
let cards: Vec<(HomeMode, Focusable)> = app
.world_mut()
.query::<(&HomeModeCard, &Focusable)>()
.iter(app.world())
.map(|(c, f)| (c.0, *f))
.collect();
assert_eq!(cards.len(), 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<Disabled>)>()
.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<Disabled>>()
.iter(app.world())
.next()
.is_some();
assert!(
!any_disabled,
"no card may be Disabled when the player is at the unlock level"
);
}
// -----------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
// -----------------------------------------------------------------------
/// Press a key and clear the input afterwards so the next `update()`
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
/// for an arbitrary key (the M-press helper releases & clears KeyM,
/// which is also what we need here for Digit keys).
fn press_and_clear(app: &mut App, key: KeyCode) {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(key);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
}
}
#[test]
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the digit-key driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit1);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(
fired.len(),
1,
"exactly one NewGameRequestEvent must fire for Digit1"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic via Digit1"
);
}
#[test]
fn digit3_at_level_zero_is_a_noop() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit3 at level 0 must not fire StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode digit press"
);
}
#[test]
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen via Digit3"
);
}
#[test]
fn digit_keys_outside_home_modal_are_noop() {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartChallengeRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
.clear();
// Press every digit 1-5 in turn — none should trigger a launch.
for key in [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
] {
press_and_clear(&mut app, key);
}
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Digit keys with no modal open must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app
.world()
.resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app
.world()
.resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app
.world()
.resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
);
}
// -----------------------------------------------------------------------
// 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<C: Component>(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<C>>()
.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::<HomeContinueCard>(&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::<HomeCancelButton>(&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::<GameStateResource>()
.0
.draw()
.expect("draw from a fresh deal must succeed");
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.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::<Messages<NewGameRequestEvent>>();
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::<GameStateResource>()
.0
.set_test_won(true);
let _ = open_home(&mut app);
assert_eq!(
count_with::<HomeCancelButton>(&mut app),
0,
"a won game is over — no Back to table"
);
assert_eq!(
count_with::<HomeContinueCard>(&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::<Messages<NewGameRequestEvent>>()
.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::<Messages<NewGameRequestEvent>>();
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::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.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::<Messages<StartZenRequestEvent>>();
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::<Messages<NewGameRequestEvent>>();
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::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.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::<Messages<StartZenRequestEvent>>();
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::<Messages<NewGameRequestEvent>>();
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::<ProgressResource>().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::<SettingsResource>().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::<SettingsResource>().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::<HomeDrawOneButton>(&mut app),
0,
"draw chips must be hidden while the disclosure is collapsed"
);
assert_eq!(
count_with::<HomeWinnableToggle>(&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::<HomeDrawOneButton>(&mut app),
1,
"draw chips must render once the disclosure expands"
);
assert_eq!(
count_with::<HomeWinnableToggle>(&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::<SettingsResource>()
.0
.winnable_deals_only,
"clicking the winnable chip must flip winnable_deals_only"
);
}
}