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