From dd6326199927c03cbd1e186d51ec011b858269fb Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 6 May 2026 06:57:25 +0000 Subject: [PATCH] feat(engine): auto-show Home / mode picker on launch The Home (mode picker) was only reachable via M during gameplay, so players who hadn't discovered the hotkey never saw the Daily / Zen / Challenge / Time Attack entry points after the splash cleared. - HomePlugin gains an `auto_show_on_launch` flag (default true) and a matching `headless()` test constructor that disables it. - spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the splash has cleared, gated on RestorePromptScreen / PendingRestoredGame so the Welcome-back flow still takes precedence on machines with a saved game. - App entry uses HomePlugin::default(); both headless test fixtures switch to HomePlugin::headless() so per-test worlds start clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_app/src/main.rs | 2 +- solitaire_engine/src/home_plugin.rs | 100 +++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index bda51da..9e4cc8e 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -129,7 +129,7 @@ fn main() { .add_plugins(TimeAttackPlugin) .add_plugins(HudPlugin) .add_plugins(HelpPlugin) - .add_plugins(HomePlugin) + .add_plugins(HomePlugin::default()) .add_plugins(ProfilePlugin) .add_plugins(PausePlugin) .add_plugins(SettingsPlugin::default()) diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 7e730f9..e2de230 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -115,22 +115,55 @@ impl HomeMode { #[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`. +#[derive(Resource, Debug, Default)] +struct LaunchHomeShown(bool); + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Registers the M-key toggle, the mode-card click handler, and the /// Cancel-button handler. -pub struct HomePlugin; +/// +/// `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) { - // Be defensive about message registration so HomePlugin works - // standalone in tests (the actual handlers live in - // input_plugin / challenge_plugin / time_attack_plugin / - // daily_challenge_plugin, but those plugins might not be - // installed in a tightly-scoped headless app). - app.add_message::() + // 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)) + .add_message::() .add_message::() .add_message::() .add_message::() @@ -147,6 +180,7 @@ impl Plugin for HomePlugin { .add_systems( Update, ( + spawn_home_on_launch, toggle_home_screen, attach_focusable_to_home_mode_cards, handle_home_card_click, @@ -158,6 +192,54 @@ impl Plugin for HomePlugin { } } +// --------------------------------------------------------------------------- +// Auto-show on launch +// --------------------------------------------------------------------------- + +/// Auto-spawns the Home / mode-picker modal once per app session, so +/// the player lands on a deliberate "what mode do I want to play" +/// screen instead of the default Classic deal. +/// +/// Gated on the launch-time UI being clear: +/// +/// * `SplashRoot` must be gone — the splash owns the foreground during +/// the brand beat and the home modal appearing under it would feel +/// like a flash of half-rendered UI. +/// * `RestorePromptScreen` must not be open and `PendingRestoredGame` +/// must be empty — when the player has a saved in-progress game the +/// restore prompt takes precedence; the home picker would compete +/// with it for attention. +/// * `HomeScreen` must not already exist (defensive — e.g. the player +/// pressed `M` between ticks). +/// * `LaunchHomeShown` flips to `true` after the first spawn so this +/// system becomes a no-op for the rest of the session. Cancelling +/// the modal therefore goes to the underlying default deal rather +/// than respawning the picker. +#[allow(clippy::too_many_arguments)] +fn spawn_home_on_launch( + mut commands: Commands, + mut shown: ResMut, + splash: Query<(), With>, + restore_prompts: Query<(), With>, + pending_restore: Option>, + existing: Query<(), With>, + progress: Option>, + font_res: Option>, +) { + if shown.0 + || !splash.is_empty() + || !restore_prompts.is_empty() + || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) + || !existing.is_empty() + { + return; + } + + let level = progress.as_ref().map_or(0, |p| p.0.level); + spawn_home_screen(&mut commands, level, font_res.as_deref()); + shown.0 = true; +} + // --------------------------------------------------------------------------- // M-key toggle // --------------------------------------------------------------------------- @@ -602,7 +684,7 @@ mod tests { .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(ProgressPlugin::headless()) - .add_plugins(HomePlugin); + .add_plugins(HomePlugin::headless()); app.init_resource::>(); app.update(); app @@ -892,7 +974,7 @@ mod tests { .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(ProgressPlugin::headless()) - .add_plugins(HomePlugin); + .add_plugins(HomePlugin::headless()); app.init_resource::>(); app.update(); app