From 7dba772e6768dd8e0d722ac38e546ff95f4e45ae Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 03:01:41 +0000 Subject: [PATCH] feat(engine): digit shortcuts (1-5) launch modes from inside the Mode Launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing M already opens the Home modal (which is the Mode Launcher post-v0.11) and Tab cycles focus through the cards. The remaining gap was direct keyboard activation of a specific mode — players had to tab-and-enter or click. A new modal-scoped digit handler closes that gap: 1 → Classic (NewGameRequestEvent) 2 → Daily Challenge (StartDailyChallengeRequestEvent) 3 → Zen (StartZenRequestEvent, gated at level 5) 4 → Challenge (StartChallengeRequestEvent, gated at level 5) 5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5) handle_home_digit_keys runs only when HomeScreen exists and short- circuits otherwise — the digit keys can't accidentally launch a mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent- no-op rather than firing a toast, mirroring the click-on-locked-card behaviour without the InfoToastEvent (the click path's toast is the authoritative "level too low" surface). The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18 parallel scheduler would otherwise let handle_home_card_click, handle_home_cancel_button, and the new digit handler all queue a HomeScreen despawn concurrently — the second buffer apply panics on the already-despawned entity. help_plugin gains a new "Mode Launcher (M)" section with the digit rows and a level-5 unlock note. onboarding's slide-3 hotkey table gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so first-run players see the full path. The help-modal canonical list now mirrors the onboarding teach. Four new headless tests pin the contract: Digit1 launches Classic and closes the modal; Digit3 at level 0 is a no-op (modal stays open); Digit3 at unlock level launches Zen and closes; digit keys outside the modal fire no events at all. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/help_plugin.rs | 10 + solitaire_engine/src/home_plugin.rs | 291 +++++++++++++++++++++- solitaire_engine/src/onboarding_plugin.rs | 1 + 3 files changed, 301 insertions(+), 1 deletion(-) diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 9e4f32c..3368dde 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -104,6 +104,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[ ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" }, ], }, + ControlSection { + title: "Mode Launcher (M)", + rows: &[ + ControlRow { keys: "1", description: "Launch Classic" }, + ControlRow { keys: "2", description: "Launch Daily Challenge" }, + ControlRow { keys: "3", description: "Launch Zen (level 5+)" }, + ControlRow { keys: "4", description: "Launch Challenge (level 5+)" }, + ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" }, + ], + }, ControlSection { title: "Overlays", rows: &[ diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index c02f8f8..78df54d 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -135,6 +135,14 @@ impl Plugin for HomePlugin { .add_message::() .add_message::() .add_message::() + // `.chain()` because several systems (M-toggle, card click, + // cancel button, digit-key shortcut) all read the + // `HomeScreen` entity and may queue a despawn on it in the + // same tick. Bevy's parallel scheduler would otherwise let + // two of them run simultaneously and double-despawn the + // entity, panicking when the second command buffer is + // applied. Chaining serialises these systems and keeps the + // despawn deterministic. .add_systems( Update, ( @@ -142,7 +150,9 @@ impl Plugin for HomePlugin { attach_focusable_to_home_mode_cards, handle_home_card_click, handle_home_cancel_button, - ), + handle_home_digit_keys, + ) + .chain(), ); } } @@ -251,6 +261,98 @@ fn handle_home_cancel_button( } } +// --------------------------------------------------------------------------- +// Digit-key shortcuts (1-5) — modal-scoped +// --------------------------------------------------------------------------- + +/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching +/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free +/// function so the keyboard handler reads as a clean dispatch table and so +/// the mapping is easy to unit-test in isolation. +fn digit_to_home_mode(key: KeyCode) -> Option { + match key { + KeyCode::Digit1 => Some(HomeMode::Classic), + KeyCode::Digit2 => Some(HomeMode::Daily), + KeyCode::Digit3 => Some(HomeMode::Zen), + KeyCode::Digit4 => Some(HomeMode::Challenge), + KeyCode::Digit5 => Some(HomeMode::TimeAttack), + _ => None, + } +} + +/// Direct keyboard activation of a specific mode while the Mode Launcher +/// modal is open. Mirrors the click-handler dispatch in +/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches +/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time +/// Attack respectively when the player has reached +/// [`CHALLENGE_UNLOCK_LEVEL`]. +/// +/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the +/// system returns immediately, so digit keys can never accidentally launch +/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches +/// the click-on-locked-card behaviour) and leaves the modal open so the +/// player can pick another mode. +#[allow(clippy::too_many_arguments)] +fn handle_home_digit_keys( + mut commands: Commands, + keys: Res>, + progress: Option>, + screens: Query>, + mut new_game: MessageWriter, + mut zen: MessageWriter, + mut challenge: MessageWriter, + mut time_attack: MessageWriter, + mut daily: MessageWriter, +) { + // Modal-scoped: do nothing when the Mode Launcher isn't open. + if screens.is_empty() { + return; + } + + let Some(mode) = [ + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + ] + .into_iter() + .find(|k| keys.just_pressed(*k)) + .and_then(digit_to_home_mode) else { + return; + }; + + let level = progress.as_ref().map_or(0, |p| p.0.level); + if !mode.is_unlocked(level) { + // Locked mode: no-op, modal stays open. + return; + } + + match mode { + HomeMode::Classic => { + new_game.write(NewGameRequestEvent::default()); + } + HomeMode::Daily => { + daily.write(StartDailyChallengeRequestEvent); + } + HomeMode::Zen => { + zen.write(StartZenRequestEvent); + } + HomeMode::Challenge => { + challenge.write(StartChallengeRequestEvent); + } + HomeMode::TimeAttack => { + time_attack.write(StartTimeAttackRequestEvent); + } + } + + // Close the modal after dispatching the launch event — same shape as + // the click handler. + for entity in &screens { + commands.entity(entity).despawn(); + } +} + // --------------------------------------------------------------------------- // Spawn helpers // --------------------------------------------------------------------------- @@ -873,4 +975,191 @@ mod tests { "no card may be Disabled when the player is at the unlock level" ); } + + // ----------------------------------------------------------------------- + // Digit-key shortcuts (1-5) — modal-scoped direct mode launch + // ----------------------------------------------------------------------- + + /// Press a key and clear the input afterwards so the next `update()` + /// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but + /// for an arbitrary key (the M-press helper releases & clears KeyM, + /// which is also what we need here for Digit keys). + fn press_and_clear(app: &mut App, key: KeyCode) { + { + let mut input = app.world_mut().resource_mut::>(); + input.press(key); + } + app.update(); + { + let mut input = app.world_mut().resource_mut::>(); + input.release(key); + input.clear(); + } + } + + #[test] + fn digit1_in_home_modal_starts_classic_and_closes_modal() { + let mut app = headless_app(); + let _ = open_home(&mut app); + + // Drain any pre-existing NewGameRequestEvent so the assertion + // only sees the digit-key driven write. + app.world_mut() + .resource_mut::>() + .clear(); + + press_and_clear(&mut app, KeyCode::Digit1); + + let events = app.world().resource::>(); + let mut cursor = events.get_cursor(); + let fired: Vec<_> = cursor.read(events).copied().collect(); + assert_eq!( + fired.len(), + 1, + "exactly one NewGameRequestEvent must fire for Digit1" + ); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 0, + "Home modal must close after launching Classic via Digit1" + ); + } + + #[test] + fn digit3_at_level_zero_is_a_noop() { + let mut app = headless_app(); + // Default level is 0 — Zen is locked. + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + + press_and_clear(&mut app, KeyCode::Digit3); + + let zen = app.world().resource::>(); + let mut zc = zen.get_cursor(); + assert!( + zc.read(zen).next().is_none(), + "Digit3 at level 0 must not fire StartZenRequestEvent" + ); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 1, + "Home modal must remain open after a locked-mode digit press" + ); + } + + #[test] + fn digit3_at_unlock_level_starts_zen_and_closes_modal() { + let mut app = headless_app(); + // Bump the player to the unlock level *before* opening the modal + // so the Mode Launcher is in its unlocked state. + app.world_mut() + .resource_mut::() + .0 + .level = CHALLENGE_UNLOCK_LEVEL; + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + + press_and_clear(&mut app, KeyCode::Digit3); + + let zen = app.world().resource::>(); + let mut zc = zen.get_cursor(); + assert_eq!( + zc.read(zen).count(), + 1, + "Digit3 at unlock level must fire exactly one StartZenRequestEvent" + ); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 0, + "Home modal must close after launching Zen via Digit3" + ); + } + + #[test] + fn digit_keys_outside_home_modal_are_noop() { + let mut app = headless_app(); + // Modal is NOT open. Bump level so Zen would otherwise be allowed + // — this isolates the modal-scope guard from the unlock check. + app.world_mut() + .resource_mut::() + .0 + .level = CHALLENGE_UNLOCK_LEVEL; + + // Drain any pre-existing events. + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + + // Press every digit 1-5 in turn — none should trigger a launch. + for key in [ + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + ] { + press_and_clear(&mut app, key); + } + + let new_game = app.world().resource::>(); + let mut nc = new_game.get_cursor(); + assert!( + nc.read(new_game).next().is_none(), + "Digit keys with no modal open must not fire NewGameRequestEvent" + ); + let zen = app.world().resource::>(); + let mut zc = zen.get_cursor(); + assert!( + zc.read(zen).next().is_none(), + "Digit keys with no modal open must not fire StartZenRequestEvent" + ); + let chal = app.world().resource::>(); + let mut cc = chal.get_cursor(); + assert!( + cc.read(chal).next().is_none(), + "Digit keys with no modal open must not fire StartChallengeRequestEvent" + ); + let ta = app.world().resource::>(); + let mut tc = ta.get_cursor(); + assert!( + tc.read(ta).next().is_none(), + "Digit keys with no modal open must not fire StartTimeAttackRequestEvent" + ); + let daily = app.world().resource::>(); + let mut dc = daily.get_cursor(); + assert!( + dc.read(daily).next().is_none(), + "Digit keys with no modal open must not fire StartDailyChallengeRequestEvent" + ); + } } diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index 5ed465e..2e15b2a 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -94,6 +94,7 @@ const HOTKEYS: &[HotkeyRow] = &[ HotkeyRow { keys: "D / Space", description: "Draw from stock" }, HotkeyRow { keys: "U", description: "Undo last move" }, HotkeyRow { keys: "N", description: "New Classic game" }, + HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" }, HotkeyRow { keys: "S", description: "Stats & progression" }, HotkeyRow { keys: "A", description: "Achievements" }, HotkeyRow { keys: "O", description: "Settings" },