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