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" },