3388169329
- The how-to-play slide told touch players to left/right-click; Android now gets tap/double-tap copy pointing at the bottom-bar Hint button. - On a fresh profile the Home auto-show spawned underneath the onboarding modal, stacking two scrims (v0.44.0 emulator smoke finding). spawn_home_on_launch now waits until first_run_complete and the onboarding modal is gone, so the launch beat is onboarding, then Home, then the table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2972 lines
107 KiB
Rust
2972 lines
107 KiB
Rust
//! 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.
|
||
/// * 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.
|
||
/// * `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>>,
|
||
onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
|
||
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())
|
||
|| !onboarding.is_empty()
|
||
|| 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<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"
|
||
);
|
||
}
|
||
}
|