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::game::tree::coord::TreeCoord;
24use crate::sim::{Action, BuyQty};
25use crate::ui::{HelpAction, Mode, TreeButtonAction};
26
27/// Platform-neutral input vocabulary. Crossterm's `Event::{Key,Mouse,Resize,…}`
28/// and ratzilla's `KeyEvent`/`MouseEvent`/`WheelEvent` both narrow into this
29/// — anything we don't need (focus, paste, resize) is dropped at the adapter.
30#[derive(Clone, Debug)]
31pub enum InputEvent {
32    /// A key was pressed. `code` is the resolved key (with shifted symbols
33    /// already mapped to their character form, e.g. Shift+1 → `!`).
34    KeyPress { code: KeyCode, mods: Modifiers },
35    /// A mouse button went down at terminal cell `(col, row)`.
36    MouseDown {
37        col: u16,
38        row: u16,
39        button: MouseButton,
40        mods: Modifiers,
41    },
42    /// A mouse button was released. Used to end drag tracking in the tree
43    /// modal; click effects fire on `MouseDown`, not on `MouseUp`.
44    MouseUp {
45        col: u16,
46        row: u16,
47        button: MouseButton,
48    },
49    /// The mouse moved over `(col, row)` — used for hover highlighting.
50    /// Drag events normalize to this too; the router doesn't care which.
51    MouseMoved { col: u16, row: u16 },
52    /// Scroll wheel scrolled. `(col, row)` is the cursor cell at the time
53    /// of the wheel tick — used to gate zoom to the play-area.
54    Wheel {
55        col: u16,
56        row: u16,
57        delta: WheelDelta,
58    },
59}
60
61/// Subset of key codes the game actually consumes. Anything else from the
62/// underlying terminal event is dropped at the adapter.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum KeyCode {
65    Char(char),
66    Esc,
67    F(u8),
68    /// Cursor / pan navigation. Mapped from crossterm `Up/Down/Left/Right`.
69    Up,
70    Down,
71    Left,
72    Right,
73    /// Confirm — used by the tree modal to buy the focused node.
74    Enter,
75}
76
77/// Subset of mouse buttons the game cares about. Middle-click is dropped
78/// at every adapter (it had no game effect on native and we don't intend
79/// one on web either); add a variant here if a future input source wants
80/// it routed.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum MouseButton {
83    Left,
84    Right,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
88pub struct Modifiers {
89    pub shift: bool,
90    pub alt: bool,
91    pub ctrl: bool,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum WheelDelta {
96    Up,
97    Down,
98}
99
100/// State that lives on the input/render side of the boundary, not on the
101/// sim. Persistence-wise: not serialized, recreated fresh on each launch.
102pub struct UiState {
103    pub mode: Mode,
104    pub zoom_idx: usize,
105    pub running: bool,
106    pub last_mouse_pos: Option<(u16, u16)>,
107    pub tree_render: TreeRenderState,
108    /// True after the player invoked the prestige-reset trigger
109    /// (keyboard `[r]` or click on the `"Press [r]..."` line) but
110    /// has not yet confirmed. While set, the Prestige panel shows
111    /// a yes/no confirmation block instead of the bare hint, and the
112    /// `[r]` / Yes-button paths run the actual reset. Cleared on
113    /// successful reset, on No-button / Esc / `[n]`, and on any
114    /// mode change away from Prestige.
115    pub prestige_confirm_pending: bool,
116}
117
118impl UiState {
119    pub fn new() -> Self {
120        Self {
121            mode: Mode::Game,
122            zoom_idx: 0,
123            running: true,
124            last_mouse_pos: None,
125            tree_render: TreeRenderState::default(),
126            prestige_confirm_pending: false,
127        }
128    }
129}
130
131impl Default for UiState {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137/// Render-side state for the upgrade tree modal. Holds the smoothed camera
138/// pan (so panning eases instead of snapping), the active drag tracker,
139/// and a snapshot of the last-seen cursor for cursor-change detection.
140///
141/// All `f32` because the tween needs sub-cell precision — the final
142/// `pan_x` / `pan_y` are rounded back to integer cells in the renderer.
143#[derive(Clone, Copy, Debug)]
144pub struct TreeRenderState {
145    /// Current rendered pan (canvas-cell coords). Eased toward `target_*`
146    /// each frame; pan reads use `.round() as i32`.
147    pub pan_x: f32,
148    pub pan_y: f32,
149    /// Where the camera is heading. Set to the cursor's centered position
150    /// whenever the cursor changes; modified directly by drag.
151    pub target_pan_x: f32,
152    pub target_pan_y: f32,
153    /// Previously-rendered cursor — drives cursor-change detection
154    /// (cursor change resets `target_pan_*` to its centered position).
155    pub prev_cursor: TreeCoord,
156    /// `false` before the first frame in the current modal session; the
157    /// renderer snaps `pan_*` to `target_*` on that frame instead of
158    /// tweening. Reset to `false` on every modal close so the camera
159    /// doesn't tween from a stale position on reopen.
160    pub initialized: bool,
161    /// Last mouse cell observed while the left button was held. While
162    /// `Some`, MouseMoved events apply the (cell-delta) directly to
163    /// `pan_*` AND `target_pan_*` so the dragged position sticks after
164    /// release (the tween doesn't pull back to cursor).
165    pub drag_last: Option<(u16, u16)>,
166}
167
168impl Default for TreeRenderState {
169    fn default() -> Self {
170        Self {
171            pan_x: 0.0,
172            pan_y: 0.0,
173            target_pan_x: 0.0,
174            target_pan_y: 0.0,
175            prev_cursor: TreeCoord::ORIGIN,
176            initialized: false,
177            drag_last: None,
178        }
179    }
180}
181
182/// Per-frame geometry the click router hit-tests against. All `Rect`s come
183/// from the latest `ui::draw` output; `current` is the latest published
184/// snapshot. Borrowed for the duration of one event dispatch.
185pub struct InputContext<'a> {
186    pub fingerer_rows: &'a [(usize, Rect)],
187    pub tree_node_rects: &'a [(crate::game::tree::coord::TreeCoord, Rect)],
188    /// Optional left-clickable action button in the tree-modal info pane.
189    /// `Some` when the focused node is currently actionable (buyable +
190    /// affordable, or owned + refundable). Lets a touch / single-button
191    /// player trigger buy/refund without needing right-click.
192    pub tree_action_button: Option<(TreeButtonAction, Rect, TreeCoord)>,
193    pub help_hits: &'a [(HelpAction, Rect)],
194    pub biscuit_rect: Rect,
195    /// Screen position of the biscuit's focal cell. See
196    /// [`crate::ui::DrawOutput::biscuit_focal`]. Used by `hands::occupied_at`
197    /// to keep its hit-test math in sync with the visual orbit.
198    pub biscuit_focal: (u16, u16),
199    /// `(spawn_id, rect)` for every on-screen powerup. Click hit-test
200    /// walks this list and routes the first match to
201    /// `Action::CatchPowerup(spawn_id)`. The `g` hotkey min_by_key's it
202    /// for the most-urgent. Both reference instances by id, never by
203    /// Vec index — `swap_remove` on catch is safe.
204    pub powerup_rects: &'a [(u64, Rect)],
205    pub play_area: Rect,
206    pub prestige_reset_rect: Rect,
207    pub prestige_confirm_yes_rect: Rect,
208    pub prestige_confirm_no_rect: Rect,
209    pub debug: bool,
210    pub current: &'a GameState,
211}
212
213impl<'a> InputContext<'a> {
214    /// Build an input context by borrowing from a render
215    /// [`DrawOutput`](crate::ui::DrawOutput).
216    ///
217    /// The platform shells (`app.rs`, `wasm_app.rs`) call this so adding
218    /// a new clickable region only touches `DrawOutput` + `InputContext` +
219    /// this projection — never the platform code. Without this single
220    /// projection point, native and wasm each kept their own field-by-field
221    /// copy of the layout snapshot, and a new field meant updating both;
222    /// the wasm build broke once when only the native copy was updated.
223    pub fn from_layout(
224        layout: &'a crate::ui::DrawOutput,
225        current: &'a GameState,
226        debug: bool,
227    ) -> Self {
228        InputContext {
229            fingerer_rows: &layout.fingerer_rows,
230            tree_node_rects: &layout.tree_node_rects,
231            tree_action_button: layout.tree_action_button,
232            help_hits: &layout.help_hits,
233            biscuit_rect: layout.biscuit_rect,
234            biscuit_focal: layout.biscuit_focal,
235            powerup_rects: &layout.powerup_rects,
236            play_area: layout.play_area,
237            prestige_reset_rect: layout.prestige_reset_rect,
238            prestige_confirm_yes_rect: layout.prestige_confirm_yes_rect,
239            prestige_confirm_no_rect: layout.prestige_confirm_no_rect,
240            debug,
241            current,
242        }
243    }
244}
245
246/// Process one [`InputEvent`]. Mutates [`UiState`]; appends produced actions
247/// to `out`. Pure data — does no I/O. The router *reads* `GameState` (via
248/// `ctx.current` for `prestige_available()` / `powerups.iter()` and via
249/// `ui::hands::occupied_at` for misclick gating) but never mutates it; all
250/// mutation flows through the produced [`Action`]s and `apply_action`.
251pub fn process_input_event(
252    ev: InputEvent,
253    ui: &mut UiState,
254    ctx: &InputContext,
255    out: &mut Vec<Action>,
256) {
257    match ev {
258        InputEvent::KeyPress { code, mods } => {
259            // Keyboard nav inside the tree modal cancels any in-progress
260            // drag so a stray-held button doesn't keep panning after the
261            // player switches to keyboard.
262            if ui.mode == Mode::Tree {
263                ui.tree_render.drag_last = None;
264            }
265            handle_key(code, mods, ui, ctx, out);
266        }
267        InputEvent::MouseDown {
268            col,
269            row,
270            button,
271            mods,
272        } => {
273            ui.last_mouse_pos = Some((col, row));
274            // In tree mode, left-mouse-down starts a potential drag — the
275            // drag actually begins on the next MouseMoved event with the
276            // anchor still held. Click effects (focus, buy) still fire
277            // immediately below.
278            if ui.mode == Mode::Tree && button == MouseButton::Left {
279                ui.tree_render.drag_last = Some((col, row));
280            }
281            // M1+M2: try help-bar / prestige-reset hits first. These give
282            // the mouse-only player parity with `[u]/[p]/[s]/[a]/[g]/[q]/[r]`
283            // shortcuts. Consumed hits short-circuit the rest of the click
284            // pipeline so we don't also fire a misclick particle.
285            if try_help_click(col, row, ui, ctx, out) {
286                return;
287            }
288            handle_click(col, row, button, mods, ui, ctx, out);
289        }
290        InputEvent::MouseUp { col, row, button } => {
291            ui.last_mouse_pos = Some((col, row));
292            // Left release ends an in-progress tree drag. Other buttons
293            // are not used for drag.
294            if button == MouseButton::Left {
295                ui.tree_render.drag_last = None;
296            }
297        }
298        InputEvent::MouseMoved { col, row } => {
299            // K5: hover highlighting; renderer reads `last_mouse_pos`.
300            // Drag events from the underlying terminal collapse to this.
301            // While the tree modal is open with a held-left, apply the
302            // cell delta to the pan target so the camera follows the
303            // mouse instantly (no tween — that would fight the drag).
304            if ui.mode == Mode::Tree
305                && let Some((lc, lr)) = ui.tree_render.drag_last
306            {
307                let dx = col as i32 - lc as i32;
308                let dy = row as i32 - lr as i32;
309                if dx != 0 || dy != 0 {
310                    let r = &mut ui.tree_render;
311                    r.pan_x -= dx as f32;
312                    r.pan_y -= dy as f32;
313                    r.target_pan_x -= dx as f32;
314                    r.target_pan_y -= dy as f32;
315                    r.drag_last = Some((col, row));
316                }
317            }
318            ui.last_mouse_pos = Some((col, row));
319        }
320        InputEvent::Wheel { col, row, delta } => {
321            // Biscuit zoom is meaningless in tree mode — the biscuit isn't
322            // visible, and accidentally zooming-out behind the modal then
323            // closing it is a confusing UX trap. Drop wheel events entirely
324            // while the tree is open. (A future feature could repurpose
325            // wheel for tree-canvas zoom; for now plain drop.)
326            if ui.mode == Mode::Tree {
327                return;
328            }
329            // Scroll only zooms inside the play area (the whole left column
330            // where the biscuit lives, including the void around a small
331            // biscuit at low zoom). Cold frames (no rect yet) conservatively
332            // allow zoom so the very first scroll after launch isn't dropped.
333            if !in_play_area(col, row, ctx.play_area) {
334                return;
335            }
336            match delta {
337                WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
338                WheelDelta::Down => {
339                    ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
340                }
341            }
342        }
343    }
344}
345
346/// True when the scroll happened anywhere inside the play-area rect — the
347/// whole left column the biscuit lives in (HUD-and-help-excluded). Cold
348/// frames (no rect yet) conservatively allow zoom so the very first scroll
349/// after launch isn't dropped.
350fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
351    if play_area.width == 0 || play_area.height == 0 {
352        return true;
353    }
354    col >= play_area.x
355        && col < play_area.x + play_area.width
356        && row >= play_area.y
357        && row < play_area.y + play_area.height
358}
359
360fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
361    rect.width > 0
362        && rect.height > 0
363        && col >= rect.x
364        && col < rect.x + rect.width
365        && row >= rect.y
366        && row < rect.y + rect.height
367}
368
369/// Push the catch action for whichever on-screen powerup has the fewest
370/// life ticks remaining (most-urgent across every kind). No-op if nothing
371/// is on screen. Shared by the keyboard `g` handler and the help-bar
372/// `[g]` click.
373fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
374    if let Some(p) = ctx.current.powerups.iter().min_by_key(|p| p.life_ticks) {
375        out.push(Action::CatchPowerup(p.spawn_id));
376    }
377}
378
379fn click_buy_qty(mods: Modifiers) -> BuyQty {
380    if mods.alt || mods.ctrl {
381        BuyQty::Max
382    } else if mods.shift {
383        BuyQty::Ten
384    } else {
385        BuyQty::One
386    }
387}
388
389/// Try to consume a click on a help-bar hint or the prestige-reset confirm
390/// line. Returns true when the click was handled — caller short-circuits
391/// the rest of the pipeline (no biscuit/row/misclick path).
392fn try_help_click(
393    col: u16,
394    row: u16,
395    ui: &mut UiState,
396    ctx: &InputContext,
397    out: &mut Vec<Action>,
398) -> bool {
399    // Prestige confirmation: click the Yes button → run the reset and
400    // close back to Game; click the No button → cancel the pending
401    // confirmation. The reset rect (only populated when NOT yet pending)
402    // flips into pending state — a single click can't run the reset.
403    if rect_contains(ctx.prestige_confirm_yes_rect, col, row) {
404        if ctx.current.prestige_available() > 0 {
405            out.push(Action::PrestigeReset);
406        }
407        ui.prestige_confirm_pending = false;
408        ui.mode = Mode::Game;
409        return true;
410    }
411    if rect_contains(ctx.prestige_confirm_no_rect, col, row) {
412        ui.prestige_confirm_pending = false;
413        return true;
414    }
415    if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
416        ui.prestige_confirm_pending = true;
417        return true;
418    }
419    for &(action, rect) in ctx.help_hits {
420        if !rect_contains(rect, col, row) {
421            continue;
422        }
423        match action {
424            HelpAction::OpenMode(target) => {
425                // Same toggle semantics the keyboard uses: tapping the
426                // hint for the active mode returns to Game. Clear any
427                // pending prestige confirm so navigating away cancels
428                // cleanly.
429                ui.prestige_confirm_pending = false;
430                ui.mode = if ui.mode == target {
431                    Mode::Game
432                } else {
433                    target
434                };
435            }
436            HelpAction::GrabGolden => {
437                // Help-bar `[g]` click — grab the most-urgent powerup
438                // currently on screen, identical to the keyboard 'g'.
439                push_grab_most_urgent(ctx, out);
440            }
441            HelpAction::Quit => {
442                ui.running = false;
443            }
444            HelpAction::TreeFocusOrigin => {
445                out.push(Action::TreeFocus(TreeCoord::ORIGIN));
446            }
447            HelpAction::TreeFocusLastBought => {
448                let target = ctx.current.tree.last_bought.unwrap_or(TreeCoord::ORIGIN);
449                out.push(Action::TreeFocus(target));
450            }
451        }
452        return true;
453    }
454    false
455}
456
457fn handle_click(
458    col: u16,
459    row: u16,
460    button: MouseButton,
461    mods: Modifiers,
462    ui: &UiState,
463    ctx: &InputContext,
464    out: &mut Vec<Action>,
465) {
466    // Tree mode is a FULL-SCREEN modal that paints over the biscuit /
467    // sidebar / HUD. The rects from those layers are still live in
468    // `InputContext` (the renderer drew them before the tree overpainted),
469    // and falling through to the normal click pipeline would let those
470    // ghost rects swallow tree clicks. Concretely: the cuque-anchor
471    // renders at screen-center, which is also where `biscuit_rect` sits,
472    // so clicking the anchor used to fire `Action::Click` (a biscuit
473    // finger) instead of `Action::TreeFocus`. Isolate tree-mode clicks
474    // so only `tree_node_rects` apply.
475    if ui.mode == Mode::Tree {
476        // Left-click on the info-pane action button = buy or refund
477        // the focused lot. Lets touch / single-button players trigger
478        // the action without a right-click. Right-click here also
479        // fires the action — same intent either way.
480        if let Some((action, r, captured_cursor)) = ctx.tree_action_button
481            && rect_contains(r, col, row)
482        {
483            // Use the cursor coord captured at RENDER time (alongside the
484            // rect), not `ctx.current.tree.cursor` — between draw and click
485            // a keyboard nav can shift the cursor by one lot, and we want
486            // the click to act on the lot the user actually clicked at.
487            match action {
488                TreeButtonAction::Buy => out.push(Action::TreeBuy(captured_cursor)),
489                TreeButtonAction::Refund => out.push(Action::TreeRefund(captured_cursor)),
490            }
491            return;
492        }
493        for &(lot, r) in ctx.tree_node_rects {
494            if rect_contains(r, col, row) {
495                out.push(Action::TreeFocus(lot));
496                if button == MouseButton::Right {
497                    let owned = ctx.current.tree.bought.contains(&lot);
498                    if owned {
499                        // Try refund — silently rejected by the sim if
500                        // it would orphan another owned node, so safe
501                        // to emit unconditionally.
502                        out.push(Action::TreeRefund(lot));
503                    } else if ctx.current.can_buy_tree_node(lot) {
504                        out.push(Action::TreeBuy(lot));
505                    }
506                }
507                return;
508            }
509        }
510        // Click on empty tree canvas: no-op. The MouseDown handler
511        // above already started drag tracking, so a hold + move from
512        // here will pan; a release without motion does nothing visible.
513        let _ = mods;
514        return;
515    }
516
517    // Powerups are catchable from ANY panel — match the keyboard 'g'
518    // behavior, which has no mode guard. The marker still renders on the
519    // biscuit while a non-Game panel is open. Right-click on a powerup
520    // also catches.
521    //
522    // Each powerup is its own click target — clicking one does NOT vacuum
523    // up an adjacent or overlapping powerup. Multiple of any kind coexist
524    // freely; each one catches only itself, by spawn_id.
525    for &(id, rect) in ctx.powerup_rects {
526        if rect_contains(rect, col, row) {
527            out.push(Action::CatchPowerup(id));
528            return;
529        }
530    }
531    // Clicking the biscuit itself is also mode-agnostic. Right-click on
532    // the biscuit is a no-op so a player can't accidentally finger the
533    // cuque with the wrong button.
534    if rect_contains(ctx.biscuit_rect, col, row) {
535        if button == MouseButton::Left {
536            out.push(Action::Click { col, row });
537        }
538        return;
539    }
540    // Mouse-buy fingerers from the sidebar in Game mode. Modifiers control
541    // quantity (plain = 1, Shift = 10, Alt/Ctrl = max), matching the
542    // digit-key shortcuts. Right-click is the always-Max affordance
543    // regardless of modifiers.
544    if ui.mode == Mode::Game {
545        for &(idx, r) in ctx.fingerer_rows {
546            if rect_contains(r, col, row) {
547                let qty = if button == MouseButton::Right {
548                    BuyQty::Max
549                } else {
550                    click_buy_qty(mods)
551                };
552                out.push(Action::BuyFingerer { idx, qty });
553                return;
554            }
555        }
556    }
557    let _ = mods;
558    // J10: nothing actionable under the click. Acknowledge it visually with
559    // a brief "·" so the dead-zone (e.g. the air around a 25%-zoom biscuit)
560    // doesn't feel inert. Skip when:
561    //   - the click was right-button (right-click without a target is a
562    //     true no-op);
563    //   - the click landed on an orbital hand glyph — those are decoration,
564    //     not click targets, but they're visually present, so a misclick
565    //     "·" replacing part of `[]` / `:*` / `>>` reads as flicker.
566    //   - M3: the click landed OUTSIDE the play area (HUD title, sidebar,
567    //     debug pane, help bar). Inert UI chrome shouldn't get a "·"
568    //     overpainted into it.
569    if button != MouseButton::Left {
570        return;
571    }
572    if !rect_contains(ctx.play_area, col, row) {
573        return;
574    }
575    if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
576        return;
577    }
578    out.push(Action::Misclick { col, row });
579}
580
581fn handle_key(
582    code: KeyCode,
583    mods: Modifiers,
584    ui: &mut UiState,
585    ctx: &InputContext,
586    out: &mut Vec<Action>,
587) {
588    match code {
589        // Gated on the platform's `can_quit` capability so a stray `q`
590        // press in the browser doesn't silently flip `ui.running` (which
591        // a future feature might key off of even though the rAF loop
592        // doesn't read it today).
593        KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
594        // J12: Esc dismisses panels back to Game mode but is a NO-OP from
595        // Game itself. Quit is `q` only — Esc-to-quit was an aggressive
596        // default that surprised playtesters who reflex-pressed it to
597        // "deselect" with no panel open.
598        KeyCode::Esc => {
599            // Esc inside a pending prestige confirm should cancel
600            // (NOT close the panel) so the player doesn't accidentally
601            // wipe progress AND lose the panel context in one keypress.
602            if ui.mode == Mode::Prestige && ui.prestige_confirm_pending {
603                ui.prestige_confirm_pending = false;
604            } else {
605                match ui.mode {
606                    Mode::Game => {}
607                    _ => {
608                        ui.prestige_confirm_pending = false;
609                        ui.mode = Mode::Game;
610                    }
611                }
612            }
613        }
614        KeyCode::Char('s') | KeyCode::Char('S') => {
615            ui.prestige_confirm_pending = false;
616            ui.mode = if matches!(ui.mode, Mode::Stats) {
617                Mode::Game
618            } else {
619                Mode::Stats
620            };
621        }
622        KeyCode::Char('a') | KeyCode::Char('A') => {
623            ui.prestige_confirm_pending = false;
624            ui.mode = if matches!(ui.mode, Mode::Achievements) {
625                Mode::Game
626            } else {
627                Mode::Achievements
628            };
629        }
630        KeyCode::Char('t') | KeyCode::Char('T') => {
631            ui.prestige_confirm_pending = false;
632            ui.mode = if matches!(ui.mode, Mode::Tree) {
633                Mode::Game
634            } else {
635                Mode::Tree
636            };
637        }
638        // [g] catches the most-urgent powerup (lowest remaining life
639        // ticks) across all four slots — Lucky, Frenzy, Buff, and
640        // Green Coin. A second [g] press grabs the next-most-urgent.
641        // Lets the player race against expiry without the keyboard
642        // accidentally vacuuming up siblings.
643        KeyCode::Char('g') | KeyCode::Char('G') => {
644            push_grab_most_urgent(ctx, out);
645        }
646        // Debug/testing: gated by `debug`. See src/ui/debug_pane.rs for the
647        // advertised key list. F8 (not F1) is Lucky because Chrome / Edge /
648        // Safari hijack F1 for browser Help and never forward the keydown
649        // to the wasm page; F8 has no default browser binding outside of
650        // an open DevTools instance.
651        KeyCode::F(8) if ctx.debug => {
652            out.push(Action::DevForcePowerup(
653                crate::game::powerup::PowerupKind::Lucky,
654            ));
655        }
656        KeyCode::F(2) if ctx.debug => {
657            out.push(Action::DevForcePowerup(
658                crate::game::powerup::PowerupKind::Frenzy,
659            ));
660        }
661        KeyCode::F(3) if ctx.debug => {
662            out.push(Action::DevForcePowerup(
663                crate::game::powerup::PowerupKind::Buff,
664            ));
665        }
666        KeyCode::F(4) if ctx.debug => {
667            out.push(Action::DevAddCuques(1_000_000.0));
668        }
669        KeyCode::F(5) if ctx.debug => {
670            out.push(Action::DevForcePowerup(
671                crate::game::powerup::PowerupKind::GreenCoin,
672            ));
673        }
674        KeyCode::Char('p') | KeyCode::Char('P') => {
675            // Toggling the panel resets any in-flight confirmation so
676            // closing and reopening Prestige doesn't preserve a stale
677            // pending state.
678            ui.prestige_confirm_pending = false;
679            ui.mode = if matches!(ui.mode, Mode::Prestige) {
680                Mode::Game
681            } else {
682                Mode::Prestige
683            };
684        }
685        // Prestige confirm: check the snapshot for available prestige before
686        // Prestige reset is gated behind an explicit two-step confirm:
687        // `[r]` ONLY arms `prestige_confirm_pending` — confirming requires
688        // a deliberately-different keystroke (`[y]` / Enter) or a click on
689        // the Yes button. Holding / double-tapping `[r]` is the easiest
690        // way to fat-finger a run wipe, so the second `[r]` is a no-op
691        // (it doesn't re-arm or cancel; the player keeps their pending
692        // state and has to actually pick Yes or No).
693        KeyCode::Char('r') | KeyCode::Char('R')
694            if ui.mode == Mode::Prestige
695                && !ui.prestige_confirm_pending
696                && ctx.current.prestige_available() > 0 =>
697        {
698            ui.prestige_confirm_pending = true;
699        }
700        // Confirm the pending prestige reset. `y` / `Y` / Enter all
701        // work as the affirmative. `s` (pt_BR "Sim") is NOT accepted
702        // because it collides with the Stats-mode toggle handler above
703        // — the pt_BR label still says "[Y/Enter]" so the player learns
704        // the keybinding directly.
705        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
706            if ui.mode == Mode::Prestige
707                && ui.prestige_confirm_pending
708                && ctx.current.prestige_available() > 0 =>
709        {
710            out.push(Action::PrestigeReset);
711            ui.prestige_confirm_pending = false;
712            ui.mode = Mode::Game;
713        }
714        // Cancel the pending prestige reset. `n` / `N` works for both
715        // English ("No") and pt_BR ("Não").
716        KeyCode::Char('n') | KeyCode::Char('N')
717            if ui.mode == Mode::Prestige && ui.prestige_confirm_pending =>
718        {
719            ui.prestige_confirm_pending = false;
720        }
721        // Tree-mode controls. Pan via hjkl or arrow keys; Enter buys the
722        // focused node; R refunds the focused node; `0` jumps to the
723        // root anchor, `1` jumps to the last bought node.
724        KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Left if ui.mode == Mode::Tree => {
725            let c = ctx.current.tree.cursor;
726            out.push(Action::TreeFocus(TreeCoord::new(c.x - 1, c.y)));
727        }
728        KeyCode::Char('l') | KeyCode::Char('L') | KeyCode::Right if ui.mode == Mode::Tree => {
729            let c = ctx.current.tree.cursor;
730            out.push(Action::TreeFocus(TreeCoord::new(c.x + 1, c.y)));
731        }
732        KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up if ui.mode == Mode::Tree => {
733            let c = ctx.current.tree.cursor;
734            out.push(Action::TreeFocus(TreeCoord::new(c.x, c.y - 1)));
735        }
736        KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down if ui.mode == Mode::Tree => {
737            let c = ctx.current.tree.cursor;
738            out.push(Action::TreeFocus(TreeCoord::new(c.x, c.y + 1)));
739        }
740        KeyCode::Enter if ui.mode == Mode::Tree => {
741            out.push(Action::TreeBuy(ctx.current.tree.cursor));
742        }
743        KeyCode::Char('r') | KeyCode::Char('R') if ui.mode == Mode::Tree => {
744            out.push(Action::TreeRefund(ctx.current.tree.cursor));
745        }
746        KeyCode::Char('0') if ui.mode == Mode::Tree => {
747            out.push(Action::TreeFocus(TreeCoord::ORIGIN));
748        }
749        KeyCode::Char('1') if ui.mode == Mode::Tree => {
750            let target = ctx.current.tree.last_bought.unwrap_or(TreeCoord::ORIGIN);
751            out.push(Action::TreeFocus(target));
752        }
753        KeyCode::Char('+') | KeyCode::Char('=') => {
754            ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
755        }
756        KeyCode::Char('-') | KeyCode::Char('_') => {
757            ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
758        }
759        // Space ALWAYS fingers the cuque, regardless of which panel is open
760        // — same contract as left-click on the biscuit.
761        KeyCode::Char(' ') => {
762            out.push(Action::ClickCenter);
763        }
764        KeyCode::Char(c) => {
765            if let Some((slot, shifted_sym)) = digit_slot(c) {
766                let buy_10 = shifted_sym || mods.shift;
767                let buy_max = mods.alt || mods.ctrl;
768                match ui.mode {
769                    Mode::Game => {
770                        if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
771                            let qty = if buy_max {
772                                BuyQty::Max
773                            } else if buy_10 {
774                                BuyQty::Ten
775                            } else {
776                                BuyQty::One
777                            };
778                            out.push(Action::BuyFingerer { idx: fid, qty });
779                        }
780                    }
781                    // Tree mode handles its own digits (`0` and `1`) above
782                    // — anything else is ignored.
783                    _ => {
784                        let _ = (slot, buy_10, buy_max);
785                    }
786                }
787            }
788        }
789        _ => {}
790    }
791}
792
793fn digit_slot(c: char) -> Option<(usize, bool)> {
794    match c {
795        '1' => Some((0, false)),
796        '2' => Some((1, false)),
797        '3' => Some((2, false)),
798        '4' => Some((3, false)),
799        '5' => Some((4, false)),
800        '6' => Some((5, false)),
801        '7' => Some((6, false)),
802        '8' => Some((7, false)),
803        '9' => Some((8, false)),
804        '0' => Some((9, false)),
805        '!' => Some((0, true)),
806        '@' => Some((1, true)),
807        '#' => Some((2, true)),
808        '$' => Some((3, true)),
809        '%' => Some((4, true)),
810        '^' => Some((5, true)),
811        '&' => Some((6, true)),
812        '*' => Some((7, true)),
813        '(' => Some((8, true)),
814        ')' => Some((9, true)),
815        _ => None,
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    //! Router tests. The whole point of pulling `process_input_event` into a
822    //! platform-neutral module is that we can exercise it with no terminal,
823    //! no event loop, and no threading. Each test feeds a synthetic event
824    //! and asserts on the produced `Vec<Action>` + `UiState` deltas.
825    //!
826    //! Constructing an `InputContext` requires stubs for the per-frame rects
827    //! and a borrowed `GameState`; helpers below collapse the boilerplate.
828    //! `state_with_lucky()` and `state_with_prestige()` mutate just enough
829    //! of the default to exercise the gates the router cares about.
830    use super::*;
831    use crate::game::powerup::{Powerup, PowerupKind};
832    use crate::sim::{Action, BuyQty};
833    use ratatui::layout::Rect;
834    use std::mem::discriminant;
835
836    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
837        Rect::new(x, y, w, h)
838    }
839
840    /// Builds an `InputContext` over caller-provided rect/row/state slices.
841    /// The lifetimes line up because everything is borrowed from locals
842    /// the test itself owns.
843    #[allow(clippy::too_many_arguments)]
844    fn ctx<'a>(
845        biscuit: Rect,
846        powerup_rects: &'a [(u64, Rect)],
847        play_area: Rect,
848        prestige_reset_rect: Rect,
849        fingerer_rows: &'a [(usize, Rect)],
850        tree_node_rects: &'a [(crate::game::tree::coord::TreeCoord, Rect)],
851        help_hits: &'a [(HelpAction, Rect)],
852        debug: bool,
853        current: &'a GameState,
854    ) -> InputContext<'a> {
855        InputContext {
856            fingerer_rows,
857            tree_node_rects,
858            tree_action_button: None,
859            help_hits,
860            biscuit_rect: biscuit,
861            biscuit_focal: (0, 0),
862            powerup_rects,
863            play_area,
864            prestige_reset_rect,
865            prestige_confirm_yes_rect: Rect::default(),
866            prestige_confirm_no_rect: Rect::default(),
867            debug,
868            current,
869        }
870    }
871
872    fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
873        ctx(
874            Rect::default(),
875            &[],
876            Rect::default(),
877            Rect::default(),
878            &[],
879            &[],
880            &[],
881            false,
882            state,
883        )
884    }
885
886    /// State with a Lucky powerup queued so `[g]` / hit-test clicks have
887    /// something to catch. Returns the `(state, spawn_id)` pair so tests
888    /// that need to override `life_ticks` or hit-test can do so by id.
889    fn state_with_lucky() -> (GameState, u64) {
890        let mut s = GameState::default();
891        let id = s.mint_spawn_id();
892        s.powerups.push(Powerup {
893            kind: PowerupKind::Lucky,
894            spawn_id: id,
895            frac_x: 0.5,
896            frac_y: 0.5,
897            life_ticks: PowerupKind::Lucky.lifetime_ticks(),
898        });
899        (s, id)
900    }
901
902    /// State with enough lifetime cuques to make `prestige_available()` > 0,
903    /// so the prestige-reset confirm rect is "live".
904    fn state_with_prestige() -> GameState {
905        // prestige_available() square-roots `lifetime_cuques / 1e9` and
906        // floors. 4e9 → 2 prestige tokens.
907        GameState {
908            lifetime_cuques: crate::bignum::Mag::from_f64(4_000_000_000.0),
909            ..GameState::default()
910        }
911    }
912
913    fn key(code: KeyCode) -> InputEvent {
914        InputEvent::KeyPress {
915            code,
916            mods: Modifiers::default(),
917        }
918    }
919
920    fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
921        InputEvent::KeyPress {
922            code,
923            mods: Modifiers { shift, alt, ctrl },
924        }
925    }
926
927    fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
928        InputEvent::MouseDown {
929            col,
930            row,
931            button,
932            mods,
933        }
934    }
935
936    // -- Key handling ------------------------------------------------------
937
938    #[test]
939    fn q_key_flips_running_off() {
940        let s = GameState::default();
941        let mut ui = UiState::new();
942        let mut out = Vec::new();
943        process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
944        assert!(!ui.running);
945        assert!(out.is_empty());
946    }
947
948    #[test]
949    fn esc_from_game_is_noop() {
950        // J12: Esc from Game must not quit and must not change mode.
951        let s = GameState::default();
952        let mut ui = UiState::new();
953        let mut out = Vec::new();
954        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
955        assert!(ui.running);
956        assert_eq!(ui.mode, Mode::Game);
957        assert!(out.is_empty());
958    }
959
960    #[test]
961    fn esc_from_stats_returns_to_game() {
962        let s = GameState::default();
963        let mut ui = UiState::new();
964        ui.mode = Mode::Stats;
965        let mut out = Vec::new();
966        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
967        assert_eq!(ui.mode, Mode::Game);
968        assert!(out.is_empty());
969    }
970
971    #[test]
972    fn s_key_toggles_stats() {
973        let s = GameState::default();
974        let mut ui = UiState::new();
975        let mut out = Vec::new();
976        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
977        assert_eq!(ui.mode, Mode::Stats);
978        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
979        assert_eq!(ui.mode, Mode::Game);
980    }
981
982    #[test]
983    fn space_emits_click_center() {
984        let s = GameState::default();
985        let mut ui = UiState::new();
986        let mut out = Vec::new();
987        process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
988        assert_eq!(out.len(), 1);
989        assert!(matches!(out[0], Action::ClickCenter));
990    }
991
992    #[test]
993    fn g_with_no_powerup_is_silent() {
994        let s = GameState::default();
995        let mut ui = UiState::new();
996        let mut out = Vec::new();
997        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
998        assert!(out.is_empty());
999    }
1000
1001    #[test]
1002    fn g_with_powerup_emits_catch() {
1003        let (s, id) = state_with_lucky();
1004        let mut ui = UiState::new();
1005        let mut out = Vec::new();
1006        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
1007        assert!(
1008            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
1009            "expected CatchPowerup({id}), got {out:?}"
1010        );
1011    }
1012
1013    #[test]
1014    fn fkeys_gated_by_debug() {
1015        let s = GameState::default();
1016        let mut ui = UiState::new();
1017        // debug=false → all F-keys silent.
1018        let mut out = Vec::new();
1019        let c = empty_ctx(&s);
1020        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
1021        process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
1022        process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
1023        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
1024        process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
1025        assert!(out.is_empty(), "F-keys must be silent when debug=false");
1026    }
1027
1028    #[test]
1029    fn fkeys_active_when_debug() {
1030        let s = GameState::default();
1031        let mut ui = UiState::new();
1032        let c = ctx(
1033            Rect::default(),
1034            &[],
1035            Rect::default(),
1036            Rect::default(),
1037            &[],
1038            &[],
1039            &[],
1040            true, // debug ON
1041            &s,
1042        );
1043        let mut out = Vec::new();
1044        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
1045        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
1046        process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
1047        assert!(matches!(
1048            out[0],
1049            Action::DevForcePowerup(PowerupKind::Lucky)
1050        ));
1051        assert!(matches!(out[1], Action::DevAddCuques(_)));
1052        assert!(matches!(
1053            out[2],
1054            Action::DevForcePowerup(PowerupKind::GreenCoin)
1055        ));
1056    }
1057
1058    // -- Digit shortcuts: modifier→BuyQty ----------------------------------
1059
1060    fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
1061        ctx(
1062            Rect::default(),
1063            &[],
1064            Rect::default(),
1065            Rect::default(),
1066            rows,
1067            &[],
1068            &[],
1069            false,
1070            state,
1071        )
1072    }
1073
1074    #[test]
1075    fn digit_1_buys_one() {
1076        let s = GameState::default();
1077        let mut ui = UiState::new();
1078        let rows = [(0_usize, rect(0, 0, 1, 1))];
1079        let mut out = Vec::new();
1080        process_input_event(
1081            key(KeyCode::Char('1')),
1082            &mut ui,
1083            &fingerer_row_ctx(&s, &rows),
1084            &mut out,
1085        );
1086        assert!(matches!(
1087            out.as_slice(),
1088            [Action::BuyFingerer {
1089                idx: 0,
1090                qty: BuyQty::One,
1091            }]
1092        ));
1093    }
1094
1095    #[test]
1096    fn shifted_digit_symbol_buys_ten() {
1097        // Terminals emit '!' for Shift+1 without a SHIFT modifier on macOS.
1098        // The router must recognize '!' as the Shift-1 alias.
1099        let s = GameState::default();
1100        let mut ui = UiState::new();
1101        let rows = [(0_usize, rect(0, 0, 1, 1))];
1102        let mut out = Vec::new();
1103        process_input_event(
1104            key(KeyCode::Char('!')),
1105            &mut ui,
1106            &fingerer_row_ctx(&s, &rows),
1107            &mut out,
1108        );
1109        assert!(matches!(
1110            out.as_slice(),
1111            [Action::BuyFingerer {
1112                idx: 0,
1113                qty: BuyQty::Ten,
1114            }]
1115        ));
1116    }
1117
1118    #[test]
1119    fn shift_modifier_on_digit_buys_ten() {
1120        // Some keymaps emit '1' WITH the SHIFT modifier — also valid for ×10.
1121        let s = GameState::default();
1122        let mut ui = UiState::new();
1123        let rows = [(0_usize, rect(0, 0, 1, 1))];
1124        let mut out = Vec::new();
1125        process_input_event(
1126            key_with(KeyCode::Char('1'), true, false, false),
1127            &mut ui,
1128            &fingerer_row_ctx(&s, &rows),
1129            &mut out,
1130        );
1131        assert!(matches!(
1132            out.as_slice(),
1133            [Action::BuyFingerer {
1134                qty: BuyQty::Ten,
1135                ..
1136            }]
1137        ));
1138    }
1139
1140    #[test]
1141    fn alt_or_ctrl_modifier_on_digit_buys_max() {
1142        let s = GameState::default();
1143        let rows = [(0_usize, rect(0, 0, 1, 1))];
1144        for (alt, ctrl) in [(true, false), (false, true)] {
1145            let mut ui = UiState::new();
1146            let mut out = Vec::new();
1147            process_input_event(
1148                key_with(KeyCode::Char('1'), false, alt, ctrl),
1149                &mut ui,
1150                &fingerer_row_ctx(&s, &rows),
1151                &mut out,
1152            );
1153            assert!(
1154                matches!(
1155                    out.as_slice(),
1156                    [Action::BuyFingerer {
1157                        qty: BuyQty::Max,
1158                        ..
1159                    }]
1160                ),
1161                "alt={alt} ctrl={ctrl} should buy max",
1162            );
1163        }
1164    }
1165
1166    #[test]
1167    fn digit_with_no_visible_row_is_silent() {
1168        // Game mode but no fingerer_rows yet (cold frame): pressing 1 must
1169        // not panic or emit an action.
1170        let s = GameState::default();
1171        let mut ui = UiState::new();
1172        let mut out = Vec::new();
1173        process_input_event(
1174            key(KeyCode::Char('1')),
1175            &mut ui,
1176            &fingerer_row_ctx(&s, &[]),
1177            &mut out,
1178        );
1179        assert!(out.is_empty());
1180    }
1181
1182    // -- Mouse button semantics --------------------------------------------
1183
1184    #[test]
1185    fn left_click_on_biscuit_emits_click() {
1186        let s = GameState::default();
1187        let mut ui = UiState::new();
1188        let c = ctx(
1189            rect(10, 5, 30, 20),
1190            &[],
1191            rect(0, 0, 100, 30),
1192            Rect::default(),
1193            &[],
1194            &[],
1195            &[],
1196            false,
1197            &s,
1198        );
1199        let mut out = Vec::new();
1200        process_input_event(
1201            mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
1202            &mut ui,
1203            &c,
1204            &mut out,
1205        );
1206        assert!(
1207            matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
1208            "got {:?}",
1209            out
1210        );
1211    }
1212
1213    #[test]
1214    fn right_click_on_biscuit_is_noop() {
1215        // Right-click on the biscuit must not finger the cuque (avoids
1216        // accidental clicks). Specifically: no Click action, no misclick.
1217        let s = GameState::default();
1218        let mut ui = UiState::new();
1219        let c = ctx(
1220            rect(10, 5, 30, 20),
1221            &[],
1222            rect(0, 0, 100, 30),
1223            Rect::default(),
1224            &[],
1225            &[],
1226            &[],
1227            false,
1228            &s,
1229        );
1230        let mut out = Vec::new();
1231        process_input_event(
1232            mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
1233            &mut ui,
1234            &c,
1235            &mut out,
1236        );
1237        assert!(out.is_empty(), "got {:?}", out);
1238    }
1239
1240    #[test]
1241    fn left_click_on_powerup_emits_catch() {
1242        let (s, id) = state_with_lucky();
1243        let mut ui = UiState::new();
1244        let powerup_rects = [(id, rect(50, 12, 4, 2))];
1245        let c = ctx(
1246            Rect::default(),
1247            &powerup_rects,
1248            rect(0, 0, 100, 30),
1249            Rect::default(),
1250            &[],
1251            &[],
1252            &[],
1253            false,
1254            &s,
1255        );
1256        let mut out = Vec::new();
1257        process_input_event(
1258            mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
1259            &mut ui,
1260            &c,
1261            &mut out,
1262        );
1263        assert!(
1264            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
1265            "got {out:?}"
1266        );
1267    }
1268
1269    #[test]
1270    fn left_click_on_powerup_only_catches_one_under_cursor() {
1271        // Two powerups on screen; clicking the second's rect catches only
1272        // it. Multiple powerups coexist — clicking one doesn't vacuum up
1273        // any others, regardless of kind.
1274        let mut s = GameState::default();
1275        let lucky_id = s.mint_spawn_id();
1276        s.powerups.push(Powerup {
1277            kind: PowerupKind::Lucky,
1278            spawn_id: lucky_id,
1279            frac_x: 0.5,
1280            frac_y: 0.5,
1281            life_ticks: 100,
1282        });
1283        let green_id = s.mint_spawn_id();
1284        s.powerups.push(Powerup {
1285            kind: PowerupKind::GreenCoin,
1286            spawn_id: green_id,
1287            frac_x: 0.5,
1288            frac_y: 0.5,
1289            life_ticks: 100,
1290        });
1291        let mut ui = UiState::new();
1292        let powerup_rects = [
1293            (lucky_id, rect(50, 12, 5, 3)),
1294            (green_id, rect(70, 12, 5, 3)),
1295        ];
1296        let c = ctx(
1297            Rect::default(),
1298            &powerup_rects,
1299            rect(0, 0, 100, 30),
1300            Rect::default(),
1301            &[],
1302            &[],
1303            &[],
1304            false,
1305            &s,
1306        );
1307        let mut out = Vec::new();
1308        process_input_event(
1309            mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
1310            &mut ui,
1311            &c,
1312            &mut out,
1313        );
1314        assert!(
1315            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1316            "got {out:?}"
1317        );
1318    }
1319
1320    #[test]
1321    fn g_with_two_kinds_picks_lower_life_ticks() {
1322        // `g` should grab the most-urgent powerup first when multiples are
1323        // on screen, regardless of kind. Min by `life_ticks`.
1324        let mut s = GameState::default();
1325        let lucky_id = s.mint_spawn_id();
1326        s.powerups.push(Powerup {
1327            kind: PowerupKind::Lucky,
1328            spawn_id: lucky_id,
1329            frac_x: 0.5,
1330            frac_y: 0.5,
1331            life_ticks: 50,
1332        });
1333        let green_id = s.mint_spawn_id();
1334        s.powerups.push(Powerup {
1335            kind: PowerupKind::GreenCoin,
1336            spawn_id: green_id,
1337            frac_x: 0.5,
1338            frac_y: 0.5,
1339            life_ticks: 200,
1340        });
1341        let c = empty_ctx(&s);
1342        let mut ui = UiState::new();
1343        let mut out = Vec::new();
1344        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1345        assert!(
1346            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == lucky_id),
1347            "lower-life Lucky should win, got {out:?}"
1348        );
1349
1350        // Flip the lifetimes — green is now the urgent one.
1351        let mut s = GameState::default();
1352        let lucky_id = s.mint_spawn_id();
1353        s.powerups.push(Powerup {
1354            kind: PowerupKind::Lucky,
1355            spawn_id: lucky_id,
1356            frac_x: 0.5,
1357            frac_y: 0.5,
1358            life_ticks: 200,
1359        });
1360        let green_id = s.mint_spawn_id();
1361        s.powerups.push(Powerup {
1362            kind: PowerupKind::GreenCoin,
1363            spawn_id: green_id,
1364            frac_x: 0.5,
1365            frac_y: 0.5,
1366            life_ticks: 50,
1367        });
1368        let c = empty_ctx(&s);
1369        let mut out = Vec::new();
1370        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1371        assert!(
1372            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1373            "lower-life GreenCoin should win, got {out:?}"
1374        );
1375    }
1376
1377    #[test]
1378    fn right_click_on_powerup_also_catches() {
1379        // The marker is small and reflex right-clicks shouldn't waste it.
1380        let (s, id) = state_with_lucky();
1381        let mut ui = UiState::new();
1382        let powerup_rects = [(id, rect(50, 12, 4, 2))];
1383        let c = ctx(
1384            Rect::default(),
1385            &powerup_rects,
1386            rect(0, 0, 100, 30),
1387            Rect::default(),
1388            &[],
1389            &[],
1390            &[],
1391            false,
1392            &s,
1393        );
1394        let mut out = Vec::new();
1395        process_input_event(
1396            mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
1397            &mut ui,
1398            &c,
1399            &mut out,
1400        );
1401        assert!(
1402            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
1403            "got {out:?}"
1404        );
1405    }
1406
1407    #[test]
1408    fn left_click_on_fingerer_row_buys_one() {
1409        let s = GameState::default();
1410        let mut ui = UiState::new();
1411        let rows = [(2_usize, rect(100, 5, 38, 3))];
1412        let c = ctx(
1413            Rect::default(),
1414            &[],
1415            Rect::default(),
1416            Rect::default(),
1417            &rows,
1418            &[],
1419            &[],
1420            false,
1421            &s,
1422        );
1423        let mut out = Vec::new();
1424        process_input_event(
1425            mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1426            &mut ui,
1427            &c,
1428            &mut out,
1429        );
1430        assert!(matches!(
1431            out.as_slice(),
1432            [Action::BuyFingerer {
1433                idx: 2,
1434                qty: BuyQty::One,
1435            }]
1436        ));
1437    }
1438
1439    #[test]
1440    fn right_click_on_fingerer_row_buys_max() {
1441        // J15: right-click is the always-Max affordance (modifiers ignored).
1442        let s = GameState::default();
1443        let mut ui = UiState::new();
1444        let rows = [(2_usize, rect(100, 5, 38, 3))];
1445        let c = ctx(
1446            Rect::default(),
1447            &[],
1448            Rect::default(),
1449            Rect::default(),
1450            &rows,
1451            &[],
1452            &[],
1453            false,
1454            &s,
1455        );
1456        let mut out = Vec::new();
1457        process_input_event(
1458            mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1459            &mut ui,
1460            &c,
1461            &mut out,
1462        );
1463        assert!(matches!(
1464            out.as_slice(),
1465            [Action::BuyFingerer {
1466                qty: BuyQty::Max,
1467                ..
1468            }]
1469        ));
1470    }
1471
1472    #[test]
1473    fn shift_left_click_on_fingerer_row_buys_ten() {
1474        let s = GameState::default();
1475        let mut ui = UiState::new();
1476        let rows = [(2_usize, rect(100, 5, 38, 3))];
1477        let c = ctx(
1478            Rect::default(),
1479            &[],
1480            Rect::default(),
1481            Rect::default(),
1482            &rows,
1483            &[],
1484            &[],
1485            false,
1486            &s,
1487        );
1488        let mut out = Vec::new();
1489        let mods = Modifiers {
1490            shift: true,
1491            ..Modifiers::default()
1492        };
1493        process_input_event(
1494            mouse_down(110, 6, MouseButton::Left, mods),
1495            &mut ui,
1496            &c,
1497            &mut out,
1498        );
1499        assert!(matches!(
1500            out.as_slice(),
1501            [Action::BuyFingerer {
1502                qty: BuyQty::Ten,
1503                ..
1504            }]
1505        ));
1506    }
1507
1508    // -- Misclick gating ---------------------------------------------------
1509
1510    #[test]
1511    fn dead_zone_left_click_inside_play_area_emits_misclick() {
1512        // Click in the empty space of the play area (not biscuit, not row).
1513        let s = GameState::default();
1514        let mut ui = UiState::new();
1515        let c = ctx(
1516            rect(40, 10, 20, 10), // biscuit far from click
1517            &[],
1518            rect(0, 0, 100, 30), // play_area covers the click
1519            Rect::default(),
1520            &[],
1521            &[],
1522            &[],
1523            false,
1524            &s,
1525        );
1526        let mut out = Vec::new();
1527        process_input_event(
1528            mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1529            &mut ui,
1530            &c,
1531            &mut out,
1532        );
1533        assert!(
1534            matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1535            "got {:?}",
1536            out
1537        );
1538    }
1539
1540    #[test]
1541    fn click_outside_play_area_does_not_misclick() {
1542        // M3: clicks on inert UI chrome (HUD title, sidebar, debug pane)
1543        // must NOT spawn a misclick particle.
1544        let s = GameState::default();
1545        let mut ui = UiState::new();
1546        let c = ctx(
1547            rect(40, 10, 20, 10),
1548            &[],
1549            rect(0, 0, 100, 30), // play area capped at col 100
1550            Rect::default(),
1551            &[],
1552            &[],
1553            &[],
1554            false,
1555            &s,
1556        );
1557        let mut out = Vec::new();
1558        process_input_event(
1559            mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1560            &mut ui,
1561            &c,
1562            &mut out,
1563        );
1564        assert!(out.is_empty(), "got {:?}", out);
1565    }
1566
1567    #[test]
1568    fn right_click_in_dead_zone_is_silent() {
1569        // Right-click on nothing actionable is a true no-op (no misclick ack).
1570        let s = GameState::default();
1571        let mut ui = UiState::new();
1572        let c = ctx(
1573            rect(40, 10, 20, 10),
1574            &[],
1575            rect(0, 0, 100, 30),
1576            Rect::default(),
1577            &[],
1578            &[],
1579            &[],
1580            false,
1581            &s,
1582        );
1583        let mut out = Vec::new();
1584        process_input_event(
1585            mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1586            &mut ui,
1587            &c,
1588            &mut out,
1589        );
1590        assert!(out.is_empty());
1591    }
1592
1593    // -- Help-bar clickable hints ------------------------------------------
1594
1595    #[test]
1596    fn click_quit_hint_flips_running() {
1597        let s = GameState::default();
1598        let mut ui = UiState::new();
1599        let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1600        let c = ctx(
1601            Rect::default(),
1602            &[],
1603            rect(0, 0, 100, 30),
1604            Rect::default(),
1605            &[],
1606            &[],
1607            &hits,
1608            false,
1609            &s,
1610        );
1611        let mut out = Vec::new();
1612        process_input_event(
1613            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1614            &mut ui,
1615            &c,
1616            &mut out,
1617        );
1618        assert!(!ui.running);
1619        assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1620    }
1621
1622    #[test]
1623    fn click_open_mode_hint_toggles_mode() {
1624        let s = GameState::default();
1625        let mut ui = UiState::new();
1626        let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1627        let c = ctx(
1628            Rect::default(),
1629            &[],
1630            rect(0, 0, 100, 30),
1631            Rect::default(),
1632            &[],
1633            &[],
1634            &hits,
1635            false,
1636            &s,
1637        );
1638        let mut out = Vec::new();
1639        process_input_event(
1640            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1641            &mut ui,
1642            &c,
1643            &mut out,
1644        );
1645        assert_eq!(ui.mode, Mode::Stats);
1646    }
1647
1648    // -- Prestige reset rect -----------------------------------------------
1649
1650    #[test]
1651    fn prestige_reset_rect_unavailable_does_not_reset() {
1652        // With prestige_available() == 0, clicking the confirm rect must
1653        // NOT produce a PrestigeReset action. The click can still fall
1654        // through to a Misclick if it's in the play area (that's by
1655        // design — it's a dead-zone click) but never to a reset.
1656        let s = GameState::default(); // prestige_available() = 0
1657        let mut ui = UiState::new();
1658        let c = ctx(
1659            Rect::default(),
1660            &[],
1661            rect(0, 0, 100, 30),
1662            rect(40, 15, 30, 1), // prestige_reset_rect at this position
1663            &[],
1664            &[],
1665            &[],
1666            false,
1667            &s,
1668        );
1669        let mut out = Vec::new();
1670        process_input_event(
1671            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1672            &mut ui,
1673            &c,
1674            &mut out,
1675        );
1676        assert!(
1677            !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1678            "no PrestigeReset when unavailable; got {:?}",
1679            out
1680        );
1681        assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1682    }
1683
1684    #[test]
1685    fn prestige_reset_rect_arms_confirmation_then_yes_emits_action() {
1686        // Two-step confirm: first click on the in-panel reset rect just
1687        // arms `prestige_confirm_pending`; the reset only fires on the
1688        // second click (the explicit Yes button).
1689        let s = state_with_prestige();
1690        let mut ui = UiState::new();
1691        ui.mode = Mode::Prestige;
1692        let mut c = ctx(
1693            Rect::default(),
1694            &[],
1695            rect(0, 0, 100, 30),
1696            rect(40, 15, 30, 1),
1697            &[],
1698            &[],
1699            &[],
1700            false,
1701            &s,
1702        );
1703        // First click: arms.
1704        let mut out = Vec::new();
1705        process_input_event(
1706            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1707            &mut ui,
1708            &c,
1709            &mut out,
1710        );
1711        assert!(
1712            !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1713            "first click on reset rect must not emit PrestigeReset; got {:?}",
1714            out
1715        );
1716        assert!(
1717            ui.prestige_confirm_pending,
1718            "first click should arm the confirmation"
1719        );
1720        assert_eq!(ui.mode, Mode::Prestige, "panel stays open while confirming");
1721        // Second click: hits the Yes-rect — actual reset fires.
1722        c.prestige_confirm_yes_rect = rect(40, 18, 30, 1);
1723        let mut out = Vec::new();
1724        process_input_event(
1725            mouse_down(50, 18, MouseButton::Left, Modifiers::default()),
1726            &mut ui,
1727            &c,
1728            &mut out,
1729        );
1730        assert_eq!(out.len(), 1);
1731        assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset));
1732        assert!(
1733            !ui.prestige_confirm_pending,
1734            "pending cleared after confirm"
1735        );
1736        assert_eq!(
1737            ui.mode,
1738            Mode::Game,
1739            "panel auto-closes after prestige confirm"
1740        );
1741    }
1742
1743    // -- Wheel zoom --------------------------------------------------------
1744
1745    #[test]
1746    fn wheel_down_inside_play_area_increases_zoom_idx() {
1747        let s = GameState::default();
1748        let mut ui = UiState::new();
1749        let c = ctx(
1750            Rect::default(),
1751            &[],
1752            rect(0, 0, 100, 30),
1753            Rect::default(),
1754            &[],
1755            &[],
1756            &[],
1757            false,
1758            &s,
1759        );
1760        let mut out = Vec::new();
1761        process_input_event(
1762            InputEvent::Wheel {
1763                col: 50,
1764                row: 15,
1765                delta: WheelDelta::Down,
1766            },
1767            &mut ui,
1768            &c,
1769            &mut out,
1770        );
1771        assert_eq!(ui.zoom_idx, 1);
1772    }
1773
1774    #[test]
1775    fn wheel_outside_play_area_does_not_zoom() {
1776        // Wheel events on the right-hand sidebar must not zoom the biscuit.
1777        let s = GameState::default();
1778        let mut ui = UiState::new();
1779        let c = ctx(
1780            Rect::default(),
1781            &[],
1782            rect(0, 0, 100, 30),
1783            Rect::default(),
1784            &[],
1785            &[],
1786            &[],
1787            false,
1788            &s,
1789        );
1790        let mut out = Vec::new();
1791        process_input_event(
1792            InputEvent::Wheel {
1793                col: 120,
1794                row: 10,
1795                delta: WheelDelta::Down,
1796            },
1797            &mut ui,
1798            &c,
1799            &mut out,
1800        );
1801        assert_eq!(ui.zoom_idx, 0);
1802    }
1803
1804    #[test]
1805    fn wheel_up_saturates_at_zero() {
1806        let s = GameState::default();
1807        let mut ui = UiState::new();
1808        ui.zoom_idx = 0;
1809        let c = ctx(
1810            Rect::default(),
1811            &[],
1812            rect(0, 0, 100, 30),
1813            Rect::default(),
1814            &[],
1815            &[],
1816            &[],
1817            false,
1818            &s,
1819        );
1820        let mut out = Vec::new();
1821        process_input_event(
1822            InputEvent::Wheel {
1823                col: 50,
1824                row: 15,
1825                delta: WheelDelta::Up,
1826            },
1827            &mut ui,
1828            &c,
1829            &mut out,
1830        );
1831        assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1832    }
1833
1834    #[test]
1835    fn wheel_down_caps_at_last_level() {
1836        let s = GameState::default();
1837        let mut ui = UiState::new();
1838        let last = crate::ui::biscuit::level_count() - 1;
1839        ui.zoom_idx = last;
1840        let c = ctx(
1841            Rect::default(),
1842            &[],
1843            rect(0, 0, 100, 30),
1844            Rect::default(),
1845            &[],
1846            &[],
1847            &[],
1848            false,
1849            &s,
1850        );
1851        let mut out = Vec::new();
1852        process_input_event(
1853            InputEvent::Wheel {
1854                col: 50,
1855                row: 15,
1856                delta: WheelDelta::Down,
1857            },
1858            &mut ui,
1859            &c,
1860            &mut out,
1861        );
1862        assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1863    }
1864
1865    // -- Mouse position tracking -------------------------------------------
1866
1867    #[test]
1868    fn mouse_moved_updates_last_position() {
1869        let s = GameState::default();
1870        let mut ui = UiState::new();
1871        let mut out = Vec::new();
1872        process_input_event(
1873            InputEvent::MouseMoved { col: 42, row: 7 },
1874            &mut ui,
1875            &empty_ctx(&s),
1876            &mut out,
1877        );
1878        assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1879        assert!(out.is_empty());
1880    }
1881
1882    #[test]
1883    fn mouse_down_updates_last_position_before_dispatch() {
1884        // The hover renderer reads `last_mouse_pos` next frame; a click
1885        // should leave the cursor "where it landed" so the row beneath the
1886        // click is highlighted.
1887        let s = GameState::default();
1888        let mut ui = UiState::new();
1889        let c = ctx(
1890            rect(40, 10, 20, 10),
1891            &[],
1892            rect(0, 0, 100, 30),
1893            Rect::default(),
1894            &[],
1895            &[],
1896            &[],
1897            false,
1898            &s,
1899        );
1900        let mut out = Vec::new();
1901        process_input_event(
1902            mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1903            &mut ui,
1904            &c,
1905            &mut out,
1906        );
1907        assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1908    }
1909}