Skip to main content

cuqueclicker_lib/
input.rs

1//! Platform-agnostic input router.
2//!
3//! Defines a small [`InputEvent`] vocabulary that's a strict superset of
4//! what we need to translate from any input source (crossterm on native,
5//! ratzilla on web). The router consumes one [`InputEvent`] and produces
6//! zero or more [`Action`]s into a caller-owned `Vec<Action>` buffer; it
7//! also mutates the [`UiState`] that doesn't belong on the sim side
8//! (`mode`, `zoom_idx`, `running`, `last_mouse_pos`).
9//!
10//! Adapters live next to their event source — `app.rs::translate_crossterm`
11//! produces `InputEvent` from `crossterm::event::Event`, and (when the
12//! wasm port lands) a sibling adapter does the same for `ratzilla::event`.
13//! Both feed the same router so behavior parity is enforced by sharing
14//! code, not by duplicating it.
15//!
16//! Geometry (`fingerer_rows`, `upgrade_rows`, `help_hits`, etc.) is passed
17//! in via [`InputContext`] — the renderer recomputes these every frame
18//! and the click handler hit-tests against the latest set.
19
20use ratatui::layout::Rect;
21
22use crate::game::state::GameState;
23use crate::sim::{Action, BuyQty};
24use crate::ui::{HelpAction, Mode};
25
26/// Platform-neutral input vocabulary. Crossterm's `Event::{Key,Mouse,Resize,…}`
27/// and ratzilla's `KeyEvent`/`MouseEvent`/`WheelEvent` both narrow into this
28/// — anything we don't need (focus, paste, resize) is dropped at the adapter.
29#[derive(Clone, Debug)]
30pub enum InputEvent {
31    /// A key was pressed. `code` is the resolved key (with shifted symbols
32    /// already mapped to their character form, e.g. Shift+1 → `!`).
33    KeyPress { code: KeyCode, mods: Modifiers },
34    /// A mouse button went down at terminal cell `(col, row)`.
35    MouseDown {
36        col: u16,
37        row: u16,
38        button: MouseButton,
39        mods: Modifiers,
40    },
41    /// The mouse moved over `(col, row)` — used for hover highlighting.
42    /// Drag events normalize to this too; the router doesn't care which.
43    MouseMoved { col: u16, row: u16 },
44    /// Scroll wheel scrolled. `(col, row)` is the cursor cell at the time
45    /// of the wheel tick — used to gate zoom to the play-area.
46    Wheel {
47        col: u16,
48        row: u16,
49        delta: WheelDelta,
50    },
51}
52
53/// Subset of key codes the game actually consumes. Anything else from the
54/// underlying terminal event is dropped at the adapter.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum KeyCode {
57    Char(char),
58    Esc,
59    F(u8),
60}
61
62/// Subset of mouse buttons the game cares about. Middle-click is dropped
63/// at every adapter (it had no game effect on native and we don't intend
64/// one on web either); add a variant here if a future input source wants
65/// it routed.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum MouseButton {
68    Left,
69    Right,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
73pub struct Modifiers {
74    pub shift: bool,
75    pub alt: bool,
76    pub ctrl: bool,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum WheelDelta {
81    Up,
82    Down,
83}
84
85/// State that lives on the input/render side of the boundary, not on the
86/// sim. Persistence-wise: not serialized, recreated fresh on each launch.
87pub struct UiState {
88    pub mode: Mode,
89    pub zoom_idx: usize,
90    pub running: bool,
91    pub last_mouse_pos: Option<(u16, u16)>,
92}
93
94impl UiState {
95    pub fn new() -> Self {
96        Self {
97            mode: Mode::Game,
98            zoom_idx: 0,
99            running: true,
100            last_mouse_pos: None,
101        }
102    }
103}
104
105impl Default for UiState {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// Per-frame geometry the click router hit-tests against. All `Rect`s come
112/// from the latest `ui::draw` output; `current` is the latest published
113/// snapshot. Borrowed for the duration of one event dispatch.
114pub struct InputContext<'a> {
115    pub fingerer_rows: &'a [(usize, Rect)],
116    pub upgrade_rows: &'a [(usize, Rect)],
117    pub help_hits: &'a [(HelpAction, Rect)],
118    pub biscuit_rect: Rect,
119    pub golden_rect: Rect,
120    pub play_area: Rect,
121    pub prestige_reset_rect: Rect,
122    pub debug: bool,
123    pub current: &'a GameState,
124}
125
126/// Process one [`InputEvent`]. Mutates [`UiState`]; appends produced actions
127/// to `out`. Pure data — does no I/O. The router *reads* `GameState` (via
128/// `ctx.current` for `prestige_available()` / `golden.is_some()` and via
129/// `ui::hands::occupied_at` for misclick gating) but never mutates it; all
130/// mutation flows through the produced [`Action`]s and `apply_action`.
131pub fn process_input_event(
132    ev: InputEvent,
133    ui: &mut UiState,
134    ctx: &InputContext,
135    out: &mut Vec<Action>,
136) {
137    match ev {
138        InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
139        InputEvent::MouseDown {
140            col,
141            row,
142            button,
143            mods,
144        } => {
145            ui.last_mouse_pos = Some((col, row));
146            // M1+M2: try help-bar / prestige-reset hits first. These give
147            // the mouse-only player parity with `[u]/[p]/[s]/[a]/[g]/[q]/[r]`
148            // shortcuts. Consumed hits short-circuit the rest of the click
149            // pipeline so we don't also fire a misclick particle.
150            if try_help_click(col, row, ui, ctx, out) {
151                return;
152            }
153            handle_click(col, row, button, mods, ui, ctx, out);
154        }
155        InputEvent::MouseMoved { col, row } => {
156            // K5: hover highlighting; renderer reads `last_mouse_pos`.
157            // Drag events from the underlying terminal collapse to this.
158            ui.last_mouse_pos = Some((col, row));
159        }
160        InputEvent::Wheel { col, row, delta } => {
161            // Scroll only zooms inside the play area (the whole left column
162            // where the biscuit lives, including the void around a small
163            // biscuit at low zoom). Cold frames (no rect yet) conservatively
164            // allow zoom so the very first scroll after launch isn't dropped.
165            if !in_play_area(col, row, ctx.play_area) {
166                return;
167            }
168            match delta {
169                WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
170                WheelDelta::Down => {
171                    ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
172                }
173            }
174        }
175    }
176}
177
178/// True when the scroll happened anywhere inside the play-area rect — the
179/// whole left column the biscuit lives in (HUD-and-help-excluded). Cold
180/// frames (no rect yet) conservatively allow zoom so the very first scroll
181/// after launch isn't dropped.
182fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
183    if play_area.width == 0 || play_area.height == 0 {
184        return true;
185    }
186    col >= play_area.x
187        && col < play_area.x + play_area.width
188        && row >= play_area.y
189        && row < play_area.y + play_area.height
190}
191
192fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
193    rect.width > 0
194        && rect.height > 0
195        && col >= rect.x
196        && col < rect.x + rect.width
197        && row >= rect.y
198        && row < rect.y + rect.height
199}
200
201fn click_buy_qty(mods: Modifiers) -> BuyQty {
202    if mods.alt || mods.ctrl {
203        BuyQty::Max
204    } else if mods.shift {
205        BuyQty::Ten
206    } else {
207        BuyQty::One
208    }
209}
210
211/// Try to consume a click on a help-bar hint or the prestige-reset confirm
212/// line. Returns true when the click was handled — caller short-circuits
213/// the rest of the pipeline (no biscuit/row/misclick path).
214fn try_help_click(
215    col: u16,
216    row: u16,
217    ui: &mut UiState,
218    ctx: &InputContext,
219    out: &mut Vec<Action>,
220) -> bool {
221    // Prestige-reset confirm: in-panel button. Match BEFORE help-bar so
222    // the confirm "wins" if the help bar happens to overlap it (it
223    // shouldn't, but defensive).
224    if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
225        out.push(Action::PrestigeReset);
226        ui.mode = Mode::Game;
227        return true;
228    }
229    for &(action, rect) in ctx.help_hits {
230        if !rect_contains(rect, col, row) {
231            continue;
232        }
233        match action {
234            HelpAction::OpenMode(target) => {
235                // Same toggle semantics the keyboard uses: tapping the
236                // hint for the active mode returns to Game.
237                ui.mode = if ui.mode == target {
238                    Mode::Game
239                } else {
240                    target
241                };
242            }
243            HelpAction::GrabGolden => {
244                if ctx.current.golden.is_some() {
245                    out.push(Action::CatchGolden);
246                }
247            }
248            HelpAction::PrestigeReset => {
249                if ctx.current.prestige_available() > 0 {
250                    out.push(Action::PrestigeReset);
251                    ui.mode = Mode::Game;
252                }
253            }
254            HelpAction::Quit => {
255                ui.running = false;
256            }
257        }
258        return true;
259    }
260    false
261}
262
263fn handle_click(
264    col: u16,
265    row: u16,
266    button: MouseButton,
267    mods: Modifiers,
268    ui: &UiState,
269    ctx: &InputContext,
270    out: &mut Vec<Action>,
271) {
272    // Golden cuques are catchable from ANY panel — match the keyboard 'g'
273    // behavior, which has no mode guard. The marker still renders on the
274    // biscuit while a non-Game panel is open. Right-click on a golden
275    // also catches.
276    if rect_contains(ctx.golden_rect, col, row) {
277        out.push(Action::CatchGolden);
278        return;
279    }
280    // Clicking the biscuit itself is also mode-agnostic. Right-click on
281    // the biscuit is a no-op so a player can't accidentally finger the
282    // cuque with the wrong button.
283    if rect_contains(ctx.biscuit_rect, col, row) {
284        if button == MouseButton::Left {
285            out.push(Action::Click { col, row });
286        }
287        return;
288    }
289    // Mouse-buy fingerers from the sidebar in Game mode. Modifiers control
290    // quantity (plain = 1, Shift = 10, Alt/Ctrl = max), matching the
291    // digit-key shortcuts. Right-click is the always-Max affordance
292    // regardless of modifiers.
293    if ui.mode == Mode::Game {
294        for &(idx, r) in ctx.fingerer_rows {
295            if rect_contains(r, col, row) {
296                let qty = if button == MouseButton::Right {
297                    BuyQty::Max
298                } else {
299                    click_buy_qty(mods)
300                };
301                out.push(Action::BuyFingerer { idx, qty });
302                return;
303            }
304        }
305    }
306    // Mouse-buy upgrades from the Upgrades panel. Modifiers ignored — each
307    // upgrade is a one-shot purchase. Right-click also buys.
308    if ui.mode == Mode::Upgrades {
309        for &(idx, r) in ctx.upgrade_rows {
310            if rect_contains(r, col, row) {
311                out.push(Action::BuyUpgrade(idx));
312                return;
313            }
314        }
315    }
316    // J10: nothing actionable under the click. Acknowledge it visually with
317    // a brief "·" so the dead-zone (e.g. the air around a 25%-zoom biscuit)
318    // doesn't feel inert. Skip when:
319    //   - the click was right-button (right-click without a target is a
320    //     true no-op);
321    //   - the click landed on an orbital hand glyph — those are decoration,
322    //     not click targets, but they're visually present, so a misclick
323    //     "·" replacing part of `[]` / `:*` / `>>` reads as flicker.
324    //   - M3: the click landed OUTSIDE the play area (HUD title, sidebar,
325    //     debug pane, help bar). Inert UI chrome shouldn't get a "·"
326    //     overpainted into it.
327    if button != MouseButton::Left {
328        return;
329    }
330    if !rect_contains(ctx.play_area, col, row) {
331        return;
332    }
333    if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.current) {
334        return;
335    }
336    out.push(Action::Misclick { col, row });
337}
338
339fn handle_key(
340    code: KeyCode,
341    mods: Modifiers,
342    ui: &mut UiState,
343    ctx: &InputContext,
344    out: &mut Vec<Action>,
345) {
346    match code {
347        // Gated on the platform's `can_quit` capability so a stray `q`
348        // press in the browser doesn't silently flip `ui.running` (which
349        // a future feature might key off of even though the rAF loop
350        // doesn't read it today).
351        KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
352        // J12: Esc dismisses panels back to Game mode but is a NO-OP from
353        // Game itself. Quit is `q` only — Esc-to-quit was an aggressive
354        // default that surprised playtesters who reflex-pressed it to
355        // "deselect" with no panel open.
356        KeyCode::Esc => match ui.mode {
357            Mode::Game => {}
358            _ => ui.mode = Mode::Game,
359        },
360        KeyCode::Char('s') | KeyCode::Char('S') => {
361            ui.mode = if matches!(ui.mode, Mode::Stats) {
362                Mode::Game
363            } else {
364                Mode::Stats
365            };
366        }
367        KeyCode::Char('a') | KeyCode::Char('A') => {
368            ui.mode = if matches!(ui.mode, Mode::Achievements) {
369                Mode::Game
370            } else {
371                Mode::Achievements
372            };
373        }
374        KeyCode::Char('u') | KeyCode::Char('U') => {
375            ui.mode = if matches!(ui.mode, Mode::Upgrades) {
376                Mode::Game
377            } else {
378                Mode::Upgrades
379            };
380        }
381        // [g] catches any Golden Cuque variant. Guard on the latest snapshot
382        // to avoid sending a noop CatchGolden when nothing is on screen.
383        KeyCode::Char('g') | KeyCode::Char('G') if ctx.current.golden.is_some() => {
384            out.push(Action::CatchGolden);
385        }
386        // Debug/testing: gated by `debug`. See src/ui/debug_pane.rs for the
387        // advertised key list. F8 (not F1) is Lucky because Chrome / Edge /
388        // Safari hijack F1 for browser Help and never forward the keydown
389        // to the wasm page; F8 has no default browser binding outside of
390        // an open DevTools instance.
391        KeyCode::F(8) if ctx.debug => {
392            out.push(Action::DevForceGolden(
393                crate::game::golden::GoldenVariant::Lucky,
394            ));
395        }
396        KeyCode::F(2) if ctx.debug => {
397            out.push(Action::DevForceGolden(
398                crate::game::golden::GoldenVariant::Frenzy,
399            ));
400        }
401        KeyCode::F(3) if ctx.debug => {
402            out.push(Action::DevForceGolden(
403                crate::game::golden::GoldenVariant::Buff,
404            ));
405        }
406        KeyCode::F(4) if ctx.debug => {
407            out.push(Action::DevAddCuques(1_000_000.0));
408        }
409        KeyCode::Char('p') | KeyCode::Char('P') => {
410            ui.mode = if matches!(ui.mode, Mode::Prestige) {
411                Mode::Game
412            } else {
413                Mode::Prestige
414            };
415        }
416        // Prestige confirm: check the snapshot for available prestige before
417        // firing. Optimistically close the panel — if the sim rejects the
418        // reset (raced against a simultaneous lifetime-cuque drop) nothing
419        // bad happens.
420        KeyCode::Char('r') | KeyCode::Char('R')
421            if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
422        {
423            out.push(Action::PrestigeReset);
424            ui.mode = Mode::Game;
425        }
426        KeyCode::Char('+') | KeyCode::Char('=') => {
427            ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
428        }
429        KeyCode::Char('-') | KeyCode::Char('_') => {
430            ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
431        }
432        // Space ALWAYS fingers the cuque, regardless of which panel is open
433        // — same contract as left-click on the biscuit.
434        KeyCode::Char(' ') => {
435            out.push(Action::ClickCenter);
436        }
437        KeyCode::Char(c) => {
438            if let Some((slot, shifted_sym)) = digit_slot(c) {
439                let buy_10 = shifted_sym || mods.shift;
440                let buy_max = mods.alt || mods.ctrl;
441                match ui.mode {
442                    Mode::Game => {
443                        if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
444                            let qty = if buy_max {
445                                BuyQty::Max
446                            } else if buy_10 {
447                                BuyQty::Ten
448                            } else {
449                                BuyQty::One
450                            };
451                            out.push(Action::BuyFingerer { idx: fid, qty });
452                        }
453                    }
454                    Mode::Upgrades => {
455                        if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
456                            out.push(Action::BuyUpgrade(u_idx));
457                        }
458                    }
459                    _ => {}
460                }
461            }
462        }
463        _ => {}
464    }
465}
466
467fn digit_slot(c: char) -> Option<(usize, bool)> {
468    match c {
469        '1' => Some((0, false)),
470        '2' => Some((1, false)),
471        '3' => Some((2, false)),
472        '4' => Some((3, false)),
473        '5' => Some((4, false)),
474        '6' => Some((5, false)),
475        '7' => Some((6, false)),
476        '8' => Some((7, false)),
477        '9' => Some((8, false)),
478        '0' => Some((9, false)),
479        '!' => Some((0, true)),
480        '@' => Some((1, true)),
481        '#' => Some((2, true)),
482        '$' => Some((3, true)),
483        '%' => Some((4, true)),
484        '^' => Some((5, true)),
485        '&' => Some((6, true)),
486        '*' => Some((7, true)),
487        '(' => Some((8, true)),
488        ')' => Some((9, true)),
489        _ => None,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    //! Router tests. The whole point of pulling `process_input_event` into a
496    //! platform-neutral module is that we can exercise it with no terminal,
497    //! no event loop, and no threading. Each test feeds a synthetic event
498    //! and asserts on the produced `Vec<Action>` + `UiState` deltas.
499    //!
500    //! Constructing an `InputContext` requires stubs for the per-frame rects
501    //! and a borrowed `GameState`; helpers below collapse the boilerplate.
502    //! `state_with_golden()` and `state_with_prestige()` mutate just enough
503    //! of the default to exercise the gates the router cares about.
504    use super::*;
505    use crate::game::golden::{self, GoldenVariant};
506    use crate::sim::{Action, BuyQty};
507    use ratatui::layout::Rect;
508    use std::mem::discriminant;
509
510    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
511        Rect::new(x, y, w, h)
512    }
513
514    /// Builds an `InputContext` over caller-provided rect/row/state slices.
515    /// The lifetimes line up because everything is borrowed from locals
516    /// the test itself owns.
517    #[allow(clippy::too_many_arguments)]
518    fn ctx<'a>(
519        biscuit: Rect,
520        golden_rect: Rect,
521        play_area: Rect,
522        prestige_reset_rect: Rect,
523        fingerer_rows: &'a [(usize, Rect)],
524        upgrade_rows: &'a [(usize, Rect)],
525        help_hits: &'a [(HelpAction, Rect)],
526        debug: bool,
527        current: &'a GameState,
528    ) -> InputContext<'a> {
529        InputContext {
530            fingerer_rows,
531            upgrade_rows,
532            help_hits,
533            biscuit_rect: biscuit,
534            golden_rect,
535            play_area,
536            prestige_reset_rect,
537            debug,
538            current,
539        }
540    }
541
542    fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
543        ctx(
544            Rect::default(),
545            Rect::default(),
546            Rect::default(),
547            Rect::default(),
548            &[],
549            &[],
550            &[],
551            false,
552            state,
553        )
554    }
555
556    /// State with a golden cuque on screen, so [g] / golden-rect clicks have
557    /// something to catch.
558    fn state_with_golden() -> GameState {
559        let mut g = golden::spawn_in(rect(10, 10, 20, 10));
560        g.variant = GoldenVariant::Lucky;
561        GameState {
562            golden: Some(g),
563            ..GameState::default()
564        }
565    }
566
567    /// State with enough lifetime cuques to make `prestige_available()` > 0,
568    /// so the prestige-reset confirm rect is "live".
569    fn state_with_prestige() -> GameState {
570        // prestige_available() square-roots `lifetime_cuques / 1e9` and
571        // floors. 4e9 → 2 prestige tokens.
572        GameState {
573            lifetime_cuques: 4_000_000_000.0,
574            ..GameState::default()
575        }
576    }
577
578    fn key(code: KeyCode) -> InputEvent {
579        InputEvent::KeyPress {
580            code,
581            mods: Modifiers::default(),
582        }
583    }
584
585    fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
586        InputEvent::KeyPress {
587            code,
588            mods: Modifiers { shift, alt, ctrl },
589        }
590    }
591
592    fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
593        InputEvent::MouseDown {
594            col,
595            row,
596            button,
597            mods,
598        }
599    }
600
601    // -- Key handling ------------------------------------------------------
602
603    #[test]
604    fn q_key_flips_running_off() {
605        let s = GameState::default();
606        let mut ui = UiState::new();
607        let mut out = Vec::new();
608        process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
609        assert!(!ui.running);
610        assert!(out.is_empty());
611    }
612
613    #[test]
614    fn esc_from_game_is_noop() {
615        // J12: Esc from Game must not quit and must not change mode.
616        let s = GameState::default();
617        let mut ui = UiState::new();
618        let mut out = Vec::new();
619        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
620        assert!(ui.running);
621        assert_eq!(ui.mode, Mode::Game);
622        assert!(out.is_empty());
623    }
624
625    #[test]
626    fn esc_from_stats_returns_to_game() {
627        let s = GameState::default();
628        let mut ui = UiState::new();
629        ui.mode = Mode::Stats;
630        let mut out = Vec::new();
631        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
632        assert_eq!(ui.mode, Mode::Game);
633        assert!(out.is_empty());
634    }
635
636    #[test]
637    fn s_key_toggles_stats() {
638        let s = GameState::default();
639        let mut ui = UiState::new();
640        let mut out = Vec::new();
641        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
642        assert_eq!(ui.mode, Mode::Stats);
643        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
644        assert_eq!(ui.mode, Mode::Game);
645    }
646
647    #[test]
648    fn space_emits_click_center() {
649        let s = GameState::default();
650        let mut ui = UiState::new();
651        let mut out = Vec::new();
652        process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
653        assert_eq!(out.len(), 1);
654        assert!(matches!(out[0], Action::ClickCenter));
655    }
656
657    #[test]
658    fn g_with_no_golden_is_silent() {
659        let s = GameState::default();
660        let mut ui = UiState::new();
661        let mut out = Vec::new();
662        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
663        assert!(out.is_empty());
664    }
665
666    #[test]
667    fn g_with_golden_emits_catch() {
668        let s = state_with_golden();
669        let mut ui = UiState::new();
670        let mut out = Vec::new();
671        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
672        assert!(matches!(out.as_slice(), [Action::CatchGolden]));
673    }
674
675    #[test]
676    fn fkeys_gated_by_debug() {
677        let s = GameState::default();
678        let mut ui = UiState::new();
679        // debug=false → all F-keys silent.
680        let mut out = Vec::new();
681        let c = empty_ctx(&s);
682        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
683        process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
684        process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
685        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
686        assert!(out.is_empty(), "F-keys must be silent when debug=false");
687    }
688
689    #[test]
690    fn fkeys_active_when_debug() {
691        let s = GameState::default();
692        let mut ui = UiState::new();
693        let c = ctx(
694            Rect::default(),
695            Rect::default(),
696            Rect::default(),
697            Rect::default(),
698            &[],
699            &[],
700            &[],
701            true, // debug ON
702            &s,
703        );
704        let mut out = Vec::new();
705        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
706        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
707        assert!(matches!(
708            out[0],
709            Action::DevForceGolden(GoldenVariant::Lucky)
710        ));
711        assert!(matches!(out[1], Action::DevAddCuques(_)));
712    }
713
714    // -- Digit shortcuts: modifier→BuyQty ----------------------------------
715
716    fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
717        ctx(
718            Rect::default(),
719            Rect::default(),
720            Rect::default(),
721            Rect::default(),
722            rows,
723            &[],
724            &[],
725            false,
726            state,
727        )
728    }
729
730    #[test]
731    fn digit_1_buys_one() {
732        let s = GameState::default();
733        let mut ui = UiState::new();
734        let rows = [(0_usize, rect(0, 0, 1, 1))];
735        let mut out = Vec::new();
736        process_input_event(
737            key(KeyCode::Char('1')),
738            &mut ui,
739            &fingerer_row_ctx(&s, &rows),
740            &mut out,
741        );
742        assert!(matches!(
743            out.as_slice(),
744            [Action::BuyFingerer {
745                idx: 0,
746                qty: BuyQty::One,
747            }]
748        ));
749    }
750
751    #[test]
752    fn shifted_digit_symbol_buys_ten() {
753        // Terminals emit '!' for Shift+1 without a SHIFT modifier on macOS.
754        // The router must recognize '!' as the Shift-1 alias.
755        let s = GameState::default();
756        let mut ui = UiState::new();
757        let rows = [(0_usize, rect(0, 0, 1, 1))];
758        let mut out = Vec::new();
759        process_input_event(
760            key(KeyCode::Char('!')),
761            &mut ui,
762            &fingerer_row_ctx(&s, &rows),
763            &mut out,
764        );
765        assert!(matches!(
766            out.as_slice(),
767            [Action::BuyFingerer {
768                idx: 0,
769                qty: BuyQty::Ten,
770            }]
771        ));
772    }
773
774    #[test]
775    fn shift_modifier_on_digit_buys_ten() {
776        // Some keymaps emit '1' WITH the SHIFT modifier — also valid for ×10.
777        let s = GameState::default();
778        let mut ui = UiState::new();
779        let rows = [(0_usize, rect(0, 0, 1, 1))];
780        let mut out = Vec::new();
781        process_input_event(
782            key_with(KeyCode::Char('1'), true, false, false),
783            &mut ui,
784            &fingerer_row_ctx(&s, &rows),
785            &mut out,
786        );
787        assert!(matches!(
788            out.as_slice(),
789            [Action::BuyFingerer {
790                qty: BuyQty::Ten,
791                ..
792            }]
793        ));
794    }
795
796    #[test]
797    fn alt_or_ctrl_modifier_on_digit_buys_max() {
798        let s = GameState::default();
799        let rows = [(0_usize, rect(0, 0, 1, 1))];
800        for (alt, ctrl) in [(true, false), (false, true)] {
801            let mut ui = UiState::new();
802            let mut out = Vec::new();
803            process_input_event(
804                key_with(KeyCode::Char('1'), false, alt, ctrl),
805                &mut ui,
806                &fingerer_row_ctx(&s, &rows),
807                &mut out,
808            );
809            assert!(
810                matches!(
811                    out.as_slice(),
812                    [Action::BuyFingerer {
813                        qty: BuyQty::Max,
814                        ..
815                    }]
816                ),
817                "alt={alt} ctrl={ctrl} should buy max",
818            );
819        }
820    }
821
822    #[test]
823    fn digit_with_no_visible_row_is_silent() {
824        // Game mode but no fingerer_rows yet (cold frame): pressing 1 must
825        // not panic or emit an action.
826        let s = GameState::default();
827        let mut ui = UiState::new();
828        let mut out = Vec::new();
829        process_input_event(
830            key(KeyCode::Char('1')),
831            &mut ui,
832            &fingerer_row_ctx(&s, &[]),
833            &mut out,
834        );
835        assert!(out.is_empty());
836    }
837
838    // -- Mouse button semantics --------------------------------------------
839
840    #[test]
841    fn left_click_on_biscuit_emits_click() {
842        let s = GameState::default();
843        let mut ui = UiState::new();
844        let c = ctx(
845            rect(10, 5, 30, 20),
846            Rect::default(),
847            rect(0, 0, 100, 30),
848            Rect::default(),
849            &[],
850            &[],
851            &[],
852            false,
853            &s,
854        );
855        let mut out = Vec::new();
856        process_input_event(
857            mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
858            &mut ui,
859            &c,
860            &mut out,
861        );
862        assert!(
863            matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
864            "got {:?}",
865            out
866        );
867    }
868
869    #[test]
870    fn right_click_on_biscuit_is_noop() {
871        // Right-click on the biscuit must not finger the cuque (avoids
872        // accidental clicks). Specifically: no Click action, no misclick.
873        let s = GameState::default();
874        let mut ui = UiState::new();
875        let c = ctx(
876            rect(10, 5, 30, 20),
877            Rect::default(),
878            rect(0, 0, 100, 30),
879            Rect::default(),
880            &[],
881            &[],
882            &[],
883            false,
884            &s,
885        );
886        let mut out = Vec::new();
887        process_input_event(
888            mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
889            &mut ui,
890            &c,
891            &mut out,
892        );
893        assert!(out.is_empty(), "got {:?}", out);
894    }
895
896    #[test]
897    fn left_click_on_golden_emits_catch() {
898        let s = state_with_golden();
899        let mut ui = UiState::new();
900        let c = ctx(
901            Rect::default(),
902            rect(50, 12, 4, 2),
903            rect(0, 0, 100, 30),
904            Rect::default(),
905            &[],
906            &[],
907            &[],
908            false,
909            &s,
910        );
911        let mut out = Vec::new();
912        process_input_event(
913            mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
914            &mut ui,
915            &c,
916            &mut out,
917        );
918        assert!(matches!(out.as_slice(), [Action::CatchGolden]));
919    }
920
921    #[test]
922    fn right_click_on_golden_also_catches() {
923        // The marker is small and reflex right-clicks shouldn't waste it.
924        let s = state_with_golden();
925        let mut ui = UiState::new();
926        let c = ctx(
927            Rect::default(),
928            rect(50, 12, 4, 2),
929            rect(0, 0, 100, 30),
930            Rect::default(),
931            &[],
932            &[],
933            &[],
934            false,
935            &s,
936        );
937        let mut out = Vec::new();
938        process_input_event(
939            mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
940            &mut ui,
941            &c,
942            &mut out,
943        );
944        assert!(matches!(out.as_slice(), [Action::CatchGolden]));
945    }
946
947    #[test]
948    fn left_click_on_fingerer_row_buys_one() {
949        let s = GameState::default();
950        let mut ui = UiState::new();
951        let rows = [(2_usize, rect(100, 5, 38, 3))];
952        let c = ctx(
953            Rect::default(),
954            Rect::default(),
955            Rect::default(),
956            Rect::default(),
957            &rows,
958            &[],
959            &[],
960            false,
961            &s,
962        );
963        let mut out = Vec::new();
964        process_input_event(
965            mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
966            &mut ui,
967            &c,
968            &mut out,
969        );
970        assert!(matches!(
971            out.as_slice(),
972            [Action::BuyFingerer {
973                idx: 2,
974                qty: BuyQty::One,
975            }]
976        ));
977    }
978
979    #[test]
980    fn right_click_on_fingerer_row_buys_max() {
981        // J15: right-click is the always-Max affordance (modifiers ignored).
982        let s = GameState::default();
983        let mut ui = UiState::new();
984        let rows = [(2_usize, rect(100, 5, 38, 3))];
985        let c = ctx(
986            Rect::default(),
987            Rect::default(),
988            Rect::default(),
989            Rect::default(),
990            &rows,
991            &[],
992            &[],
993            false,
994            &s,
995        );
996        let mut out = Vec::new();
997        process_input_event(
998            mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
999            &mut ui,
1000            &c,
1001            &mut out,
1002        );
1003        assert!(matches!(
1004            out.as_slice(),
1005            [Action::BuyFingerer {
1006                qty: BuyQty::Max,
1007                ..
1008            }]
1009        ));
1010    }
1011
1012    #[test]
1013    fn shift_left_click_on_fingerer_row_buys_ten() {
1014        let s = GameState::default();
1015        let mut ui = UiState::new();
1016        let rows = [(2_usize, rect(100, 5, 38, 3))];
1017        let c = ctx(
1018            Rect::default(),
1019            Rect::default(),
1020            Rect::default(),
1021            Rect::default(),
1022            &rows,
1023            &[],
1024            &[],
1025            false,
1026            &s,
1027        );
1028        let mut out = Vec::new();
1029        let mods = Modifiers {
1030            shift: true,
1031            ..Modifiers::default()
1032        };
1033        process_input_event(
1034            mouse_down(110, 6, MouseButton::Left, mods),
1035            &mut ui,
1036            &c,
1037            &mut out,
1038        );
1039        assert!(matches!(
1040            out.as_slice(),
1041            [Action::BuyFingerer {
1042                qty: BuyQty::Ten,
1043                ..
1044            }]
1045        ));
1046    }
1047
1048    // -- Misclick gating ---------------------------------------------------
1049
1050    #[test]
1051    fn dead_zone_left_click_inside_play_area_emits_misclick() {
1052        // Click in the empty space of the play area (not biscuit, not row).
1053        let s = GameState::default();
1054        let mut ui = UiState::new();
1055        let c = ctx(
1056            rect(40, 10, 20, 10), // biscuit far from click
1057            Rect::default(),
1058            rect(0, 0, 100, 30), // play_area covers the click
1059            Rect::default(),
1060            &[],
1061            &[],
1062            &[],
1063            false,
1064            &s,
1065        );
1066        let mut out = Vec::new();
1067        process_input_event(
1068            mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1069            &mut ui,
1070            &c,
1071            &mut out,
1072        );
1073        assert!(
1074            matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1075            "got {:?}",
1076            out
1077        );
1078    }
1079
1080    #[test]
1081    fn click_outside_play_area_does_not_misclick() {
1082        // M3: clicks on inert UI chrome (HUD title, sidebar, debug pane)
1083        // must NOT spawn a misclick particle.
1084        let s = GameState::default();
1085        let mut ui = UiState::new();
1086        let c = ctx(
1087            rect(40, 10, 20, 10),
1088            Rect::default(),
1089            rect(0, 0, 100, 30), // play area capped at col 100
1090            Rect::default(),
1091            &[],
1092            &[],
1093            &[],
1094            false,
1095            &s,
1096        );
1097        let mut out = Vec::new();
1098        process_input_event(
1099            mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1100            &mut ui,
1101            &c,
1102            &mut out,
1103        );
1104        assert!(out.is_empty(), "got {:?}", out);
1105    }
1106
1107    #[test]
1108    fn right_click_in_dead_zone_is_silent() {
1109        // Right-click on nothing actionable is a true no-op (no misclick ack).
1110        let s = GameState::default();
1111        let mut ui = UiState::new();
1112        let c = ctx(
1113            rect(40, 10, 20, 10),
1114            Rect::default(),
1115            rect(0, 0, 100, 30),
1116            Rect::default(),
1117            &[],
1118            &[],
1119            &[],
1120            false,
1121            &s,
1122        );
1123        let mut out = Vec::new();
1124        process_input_event(
1125            mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1126            &mut ui,
1127            &c,
1128            &mut out,
1129        );
1130        assert!(out.is_empty());
1131    }
1132
1133    // -- Help-bar clickable hints ------------------------------------------
1134
1135    #[test]
1136    fn click_quit_hint_flips_running() {
1137        let s = GameState::default();
1138        let mut ui = UiState::new();
1139        let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1140        let c = ctx(
1141            Rect::default(),
1142            Rect::default(),
1143            rect(0, 0, 100, 30),
1144            Rect::default(),
1145            &[],
1146            &[],
1147            &hits,
1148            false,
1149            &s,
1150        );
1151        let mut out = Vec::new();
1152        process_input_event(
1153            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1154            &mut ui,
1155            &c,
1156            &mut out,
1157        );
1158        assert!(!ui.running);
1159        assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1160    }
1161
1162    #[test]
1163    fn click_open_mode_hint_toggles_mode() {
1164        let s = GameState::default();
1165        let mut ui = UiState::new();
1166        let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1167        let c = ctx(
1168            Rect::default(),
1169            Rect::default(),
1170            rect(0, 0, 100, 30),
1171            Rect::default(),
1172            &[],
1173            &[],
1174            &hits,
1175            false,
1176            &s,
1177        );
1178        let mut out = Vec::new();
1179        process_input_event(
1180            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1181            &mut ui,
1182            &c,
1183            &mut out,
1184        );
1185        assert_eq!(ui.mode, Mode::Stats);
1186    }
1187
1188    // -- Prestige reset rect -----------------------------------------------
1189
1190    #[test]
1191    fn prestige_reset_rect_unavailable_does_not_reset() {
1192        // With prestige_available() == 0, clicking the confirm rect must
1193        // NOT produce a PrestigeReset action. The click can still fall
1194        // through to a Misclick if it's in the play area (that's by
1195        // design — it's a dead-zone click) but never to a reset.
1196        let s = GameState::default(); // prestige_available() = 0
1197        let mut ui = UiState::new();
1198        let c = ctx(
1199            Rect::default(),
1200            Rect::default(),
1201            rect(0, 0, 100, 30),
1202            rect(40, 15, 30, 1), // prestige_reset_rect at this position
1203            &[],
1204            &[],
1205            &[],
1206            false,
1207            &s,
1208        );
1209        let mut out = Vec::new();
1210        process_input_event(
1211            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1212            &mut ui,
1213            &c,
1214            &mut out,
1215        );
1216        assert!(
1217            !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1218            "no PrestigeReset when unavailable; got {:?}",
1219            out
1220        );
1221        assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1222    }
1223
1224    #[test]
1225    fn prestige_reset_rect_available_emits_action() {
1226        let s = state_with_prestige();
1227        let mut ui = UiState::new();
1228        ui.mode = Mode::Prestige;
1229        let c = ctx(
1230            Rect::default(),
1231            Rect::default(),
1232            rect(0, 0, 100, 30),
1233            rect(40, 15, 30, 1),
1234            &[],
1235            &[],
1236            &[],
1237            false,
1238            &s,
1239        );
1240        let mut out = Vec::new();
1241        process_input_event(
1242            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1243            &mut ui,
1244            &c,
1245            &mut out,
1246        );
1247        assert_eq!(out.len(), 1);
1248        assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1249        assert_eq!(
1250            ui.mode,
1251            Mode::Game,
1252            "panel auto-closes after prestige confirm",
1253        );
1254    }
1255
1256    // -- Wheel zoom --------------------------------------------------------
1257
1258    #[test]
1259    fn wheel_down_inside_play_area_increases_zoom_idx() {
1260        let s = GameState::default();
1261        let mut ui = UiState::new();
1262        let c = ctx(
1263            Rect::default(),
1264            Rect::default(),
1265            rect(0, 0, 100, 30),
1266            Rect::default(),
1267            &[],
1268            &[],
1269            &[],
1270            false,
1271            &s,
1272        );
1273        let mut out = Vec::new();
1274        process_input_event(
1275            InputEvent::Wheel {
1276                col: 50,
1277                row: 15,
1278                delta: WheelDelta::Down,
1279            },
1280            &mut ui,
1281            &c,
1282            &mut out,
1283        );
1284        assert_eq!(ui.zoom_idx, 1);
1285    }
1286
1287    #[test]
1288    fn wheel_outside_play_area_does_not_zoom() {
1289        // Wheel events on the right-hand sidebar must not zoom the biscuit.
1290        let s = GameState::default();
1291        let mut ui = UiState::new();
1292        let c = ctx(
1293            Rect::default(),
1294            Rect::default(),
1295            rect(0, 0, 100, 30),
1296            Rect::default(),
1297            &[],
1298            &[],
1299            &[],
1300            false,
1301            &s,
1302        );
1303        let mut out = Vec::new();
1304        process_input_event(
1305            InputEvent::Wheel {
1306                col: 120,
1307                row: 10,
1308                delta: WheelDelta::Down,
1309            },
1310            &mut ui,
1311            &c,
1312            &mut out,
1313        );
1314        assert_eq!(ui.zoom_idx, 0);
1315    }
1316
1317    #[test]
1318    fn wheel_up_saturates_at_zero() {
1319        let s = GameState::default();
1320        let mut ui = UiState::new();
1321        ui.zoom_idx = 0;
1322        let c = ctx(
1323            Rect::default(),
1324            Rect::default(),
1325            rect(0, 0, 100, 30),
1326            Rect::default(),
1327            &[],
1328            &[],
1329            &[],
1330            false,
1331            &s,
1332        );
1333        let mut out = Vec::new();
1334        process_input_event(
1335            InputEvent::Wheel {
1336                col: 50,
1337                row: 15,
1338                delta: WheelDelta::Up,
1339            },
1340            &mut ui,
1341            &c,
1342            &mut out,
1343        );
1344        assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1345    }
1346
1347    #[test]
1348    fn wheel_down_caps_at_last_level() {
1349        let s = GameState::default();
1350        let mut ui = UiState::new();
1351        let last = crate::ui::biscuit::level_count() - 1;
1352        ui.zoom_idx = last;
1353        let c = ctx(
1354            Rect::default(),
1355            Rect::default(),
1356            rect(0, 0, 100, 30),
1357            Rect::default(),
1358            &[],
1359            &[],
1360            &[],
1361            false,
1362            &s,
1363        );
1364        let mut out = Vec::new();
1365        process_input_event(
1366            InputEvent::Wheel {
1367                col: 50,
1368                row: 15,
1369                delta: WheelDelta::Down,
1370            },
1371            &mut ui,
1372            &c,
1373            &mut out,
1374        );
1375        assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1376    }
1377
1378    // -- Mouse position tracking -------------------------------------------
1379
1380    #[test]
1381    fn mouse_moved_updates_last_position() {
1382        let s = GameState::default();
1383        let mut ui = UiState::new();
1384        let mut out = Vec::new();
1385        process_input_event(
1386            InputEvent::MouseMoved { col: 42, row: 7 },
1387            &mut ui,
1388            &empty_ctx(&s),
1389            &mut out,
1390        );
1391        assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1392        assert!(out.is_empty());
1393    }
1394
1395    #[test]
1396    fn mouse_down_updates_last_position_before_dispatch() {
1397        // The hover renderer reads `last_mouse_pos` next frame; a click
1398        // should leave the cursor "where it landed" so the row beneath the
1399        // click is highlighted.
1400        let s = GameState::default();
1401        let mut ui = UiState::new();
1402        let c = ctx(
1403            rect(40, 10, 20, 10),
1404            Rect::default(),
1405            rect(0, 0, 100, 30),
1406            Rect::default(),
1407            &[],
1408            &[],
1409            &[],
1410            false,
1411            &s,
1412        );
1413        let mut out = Vec::new();
1414        process_input_event(
1415            mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1416            &mut ui,
1417            &c,
1418            &mut out,
1419        );
1420        assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1421    }
1422}