1use ratatui::layout::Rect;
21
22use crate::game::state::GameState;
23use crate::sim::{Action, BuyQty};
24use crate::ui::{HelpAction, Mode};
25
26#[derive(Clone, Debug)]
30pub enum InputEvent {
31 KeyPress { code: KeyCode, mods: Modifiers },
34 MouseDown {
36 col: u16,
37 row: u16,
38 button: MouseButton,
39 mods: Modifiers,
40 },
41 MouseMoved { col: u16, row: u16 },
44 Wheel {
47 col: u16,
48 row: u16,
49 delta: WheelDelta,
50 },
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum KeyCode {
57 Char(char),
58 Esc,
59 F(u8),
60}
61
62#[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
85pub 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
111pub 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 biscuit_focal: (u16, u16),
123 pub powerup_rects: &'a [(u64, Rect)],
129 pub play_area: Rect,
130 pub prestige_reset_rect: Rect,
131 pub debug: bool,
132 pub current: &'a GameState,
133}
134
135impl<'a> InputContext<'a> {
136 pub fn from_layout(
146 layout: &'a crate::ui::DrawOutput,
147 current: &'a GameState,
148 debug: bool,
149 ) -> Self {
150 InputContext {
151 fingerer_rows: &layout.fingerer_rows,
152 upgrade_rows: &layout.upgrade_rows,
153 help_hits: &layout.help_hits,
154 biscuit_rect: layout.biscuit_rect,
155 biscuit_focal: layout.biscuit_focal,
156 powerup_rects: &layout.powerup_rects,
157 play_area: layout.play_area,
158 prestige_reset_rect: layout.prestige_reset_rect,
159 debug,
160 current,
161 }
162 }
163}
164
165pub fn process_input_event(
171 ev: InputEvent,
172 ui: &mut UiState,
173 ctx: &InputContext,
174 out: &mut Vec<Action>,
175) {
176 match ev {
177 InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
178 InputEvent::MouseDown {
179 col,
180 row,
181 button,
182 mods,
183 } => {
184 ui.last_mouse_pos = Some((col, row));
185 if try_help_click(col, row, ui, ctx, out) {
190 return;
191 }
192 handle_click(col, row, button, mods, ui, ctx, out);
193 }
194 InputEvent::MouseMoved { col, row } => {
195 ui.last_mouse_pos = Some((col, row));
198 }
199 InputEvent::Wheel { col, row, delta } => {
200 if !in_play_area(col, row, ctx.play_area) {
205 return;
206 }
207 match delta {
208 WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
209 WheelDelta::Down => {
210 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
211 }
212 }
213 }
214 }
215}
216
217fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
222 if play_area.width == 0 || play_area.height == 0 {
223 return true;
224 }
225 col >= play_area.x
226 && col < play_area.x + play_area.width
227 && row >= play_area.y
228 && row < play_area.y + play_area.height
229}
230
231fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
232 rect.width > 0
233 && rect.height > 0
234 && col >= rect.x
235 && col < rect.x + rect.width
236 && row >= rect.y
237 && row < rect.y + rect.height
238}
239
240fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
245 if let Some(p) = ctx.current.powerups.iter().min_by_key(|p| p.life_ticks) {
246 out.push(Action::CatchPowerup(p.spawn_id));
247 }
248}
249
250fn click_buy_qty(mods: Modifiers) -> BuyQty {
251 if mods.alt || mods.ctrl {
252 BuyQty::Max
253 } else if mods.shift {
254 BuyQty::Ten
255 } else {
256 BuyQty::One
257 }
258}
259
260fn try_help_click(
264 col: u16,
265 row: u16,
266 ui: &mut UiState,
267 ctx: &InputContext,
268 out: &mut Vec<Action>,
269) -> bool {
270 if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
274 out.push(Action::PrestigeReset);
275 ui.mode = Mode::Game;
276 return true;
277 }
278 for &(action, rect) in ctx.help_hits {
279 if !rect_contains(rect, col, row) {
280 continue;
281 }
282 match action {
283 HelpAction::OpenMode(target) => {
284 ui.mode = if ui.mode == target {
287 Mode::Game
288 } else {
289 target
290 };
291 }
292 HelpAction::GrabGolden => {
293 push_grab_most_urgent(ctx, out);
296 }
297 HelpAction::PrestigeReset => {
298 if ctx.current.prestige_available() > 0 {
299 out.push(Action::PrestigeReset);
300 ui.mode = Mode::Game;
301 }
302 }
303 HelpAction::Quit => {
304 ui.running = false;
305 }
306 }
307 return true;
308 }
309 false
310}
311
312fn handle_click(
313 col: u16,
314 row: u16,
315 button: MouseButton,
316 mods: Modifiers,
317 ui: &UiState,
318 ctx: &InputContext,
319 out: &mut Vec<Action>,
320) {
321 for &(id, rect) in ctx.powerup_rects {
330 if rect_contains(rect, col, row) {
331 out.push(Action::CatchPowerup(id));
332 return;
333 }
334 }
335 if rect_contains(ctx.biscuit_rect, col, row) {
339 if button == MouseButton::Left {
340 out.push(Action::Click { col, row });
341 }
342 return;
343 }
344 if ui.mode == Mode::Game {
349 for &(idx, r) in ctx.fingerer_rows {
350 if rect_contains(r, col, row) {
351 let qty = if button == MouseButton::Right {
352 BuyQty::Max
353 } else {
354 click_buy_qty(mods)
355 };
356 out.push(Action::BuyFingerer { idx, qty });
357 return;
358 }
359 }
360 }
361 if ui.mode == Mode::Upgrades {
364 for &(idx, r) in ctx.upgrade_rows {
365 if rect_contains(r, col, row) {
366 out.push(Action::BuyUpgrade(idx));
367 return;
368 }
369 }
370 }
371 if button != MouseButton::Left {
383 return;
384 }
385 if !rect_contains(ctx.play_area, col, row) {
386 return;
387 }
388 if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
389 return;
390 }
391 out.push(Action::Misclick { col, row });
392}
393
394fn handle_key(
395 code: KeyCode,
396 mods: Modifiers,
397 ui: &mut UiState,
398 ctx: &InputContext,
399 out: &mut Vec<Action>,
400) {
401 match code {
402 KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
407 KeyCode::Esc => match ui.mode {
412 Mode::Game => {}
413 _ => ui.mode = Mode::Game,
414 },
415 KeyCode::Char('s') | KeyCode::Char('S') => {
416 ui.mode = if matches!(ui.mode, Mode::Stats) {
417 Mode::Game
418 } else {
419 Mode::Stats
420 };
421 }
422 KeyCode::Char('a') | KeyCode::Char('A') => {
423 ui.mode = if matches!(ui.mode, Mode::Achievements) {
424 Mode::Game
425 } else {
426 Mode::Achievements
427 };
428 }
429 KeyCode::Char('u') | KeyCode::Char('U') => {
430 ui.mode = if matches!(ui.mode, Mode::Upgrades) {
431 Mode::Game
432 } else {
433 Mode::Upgrades
434 };
435 }
436 KeyCode::Char('g') | KeyCode::Char('G') => {
442 push_grab_most_urgent(ctx, out);
443 }
444 KeyCode::F(8) if ctx.debug => {
450 out.push(Action::DevForcePowerup(
451 crate::game::powerup::PowerupKind::Lucky,
452 ));
453 }
454 KeyCode::F(2) if ctx.debug => {
455 out.push(Action::DevForcePowerup(
456 crate::game::powerup::PowerupKind::Frenzy,
457 ));
458 }
459 KeyCode::F(3) if ctx.debug => {
460 out.push(Action::DevForcePowerup(
461 crate::game::powerup::PowerupKind::Buff,
462 ));
463 }
464 KeyCode::F(4) if ctx.debug => {
465 out.push(Action::DevAddCuques(1_000_000.0));
466 }
467 KeyCode::F(5) if ctx.debug => {
468 out.push(Action::DevForcePowerup(
469 crate::game::powerup::PowerupKind::GreenCoin,
470 ));
471 }
472 KeyCode::Char('p') | KeyCode::Char('P') => {
473 ui.mode = if matches!(ui.mode, Mode::Prestige) {
474 Mode::Game
475 } else {
476 Mode::Prestige
477 };
478 }
479 KeyCode::Char('r') | KeyCode::Char('R')
484 if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
485 {
486 out.push(Action::PrestigeReset);
487 ui.mode = Mode::Game;
488 }
489 KeyCode::Char('+') | KeyCode::Char('=') => {
490 ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
491 }
492 KeyCode::Char('-') | KeyCode::Char('_') => {
493 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
494 }
495 KeyCode::Char(' ') => {
498 out.push(Action::ClickCenter);
499 }
500 KeyCode::Char(c) => {
501 if let Some((slot, shifted_sym)) = digit_slot(c) {
502 let buy_10 = shifted_sym || mods.shift;
503 let buy_max = mods.alt || mods.ctrl;
504 match ui.mode {
505 Mode::Game => {
506 if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
507 let qty = if buy_max {
508 BuyQty::Max
509 } else if buy_10 {
510 BuyQty::Ten
511 } else {
512 BuyQty::One
513 };
514 out.push(Action::BuyFingerer { idx: fid, qty });
515 }
516 }
517 Mode::Upgrades => {
518 if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
519 out.push(Action::BuyUpgrade(u_idx));
520 }
521 }
522 _ => {}
523 }
524 }
525 }
526 _ => {}
527 }
528}
529
530fn digit_slot(c: char) -> Option<(usize, bool)> {
531 match c {
532 '1' => Some((0, false)),
533 '2' => Some((1, false)),
534 '3' => Some((2, false)),
535 '4' => Some((3, false)),
536 '5' => Some((4, false)),
537 '6' => Some((5, false)),
538 '7' => Some((6, false)),
539 '8' => Some((7, false)),
540 '9' => Some((8, false)),
541 '0' => Some((9, false)),
542 '!' => Some((0, true)),
543 '@' => Some((1, true)),
544 '#' => Some((2, true)),
545 '$' => Some((3, true)),
546 '%' => Some((4, true)),
547 '^' => Some((5, true)),
548 '&' => Some((6, true)),
549 '*' => Some((7, true)),
550 '(' => Some((8, true)),
551 ')' => Some((9, true)),
552 _ => None,
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
568 use crate::game::powerup::{Powerup, PowerupKind};
569 use crate::sim::{Action, BuyQty};
570 use ratatui::layout::Rect;
571 use std::mem::discriminant;
572
573 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
574 Rect::new(x, y, w, h)
575 }
576
577 #[allow(clippy::too_many_arguments)]
581 fn ctx<'a>(
582 biscuit: Rect,
583 powerup_rects: &'a [(u64, Rect)],
584 play_area: Rect,
585 prestige_reset_rect: Rect,
586 fingerer_rows: &'a [(usize, Rect)],
587 upgrade_rows: &'a [(usize, Rect)],
588 help_hits: &'a [(HelpAction, Rect)],
589 debug: bool,
590 current: &'a GameState,
591 ) -> InputContext<'a> {
592 InputContext {
593 fingerer_rows,
594 upgrade_rows,
595 help_hits,
596 biscuit_rect: biscuit,
597 biscuit_focal: (0, 0),
598 powerup_rects,
599 play_area,
600 prestige_reset_rect,
601 debug,
602 current,
603 }
604 }
605
606 fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
607 ctx(
608 Rect::default(),
609 &[],
610 Rect::default(),
611 Rect::default(),
612 &[],
613 &[],
614 &[],
615 false,
616 state,
617 )
618 }
619
620 fn state_with_lucky() -> (GameState, u64) {
624 let mut s = GameState::default();
625 let id = s.mint_spawn_id();
626 s.powerups.push(Powerup {
627 kind: PowerupKind::Lucky,
628 spawn_id: id,
629 frac_x: 0.5,
630 frac_y: 0.5,
631 life_ticks: PowerupKind::Lucky.lifetime_ticks(),
632 });
633 (s, id)
634 }
635
636 fn state_with_prestige() -> GameState {
639 GameState {
642 lifetime_cuques: 4_000_000_000.0,
643 ..GameState::default()
644 }
645 }
646
647 fn key(code: KeyCode) -> InputEvent {
648 InputEvent::KeyPress {
649 code,
650 mods: Modifiers::default(),
651 }
652 }
653
654 fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
655 InputEvent::KeyPress {
656 code,
657 mods: Modifiers { shift, alt, ctrl },
658 }
659 }
660
661 fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
662 InputEvent::MouseDown {
663 col,
664 row,
665 button,
666 mods,
667 }
668 }
669
670 #[test]
673 fn q_key_flips_running_off() {
674 let s = GameState::default();
675 let mut ui = UiState::new();
676 let mut out = Vec::new();
677 process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
678 assert!(!ui.running);
679 assert!(out.is_empty());
680 }
681
682 #[test]
683 fn esc_from_game_is_noop() {
684 let s = GameState::default();
686 let mut ui = UiState::new();
687 let mut out = Vec::new();
688 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
689 assert!(ui.running);
690 assert_eq!(ui.mode, Mode::Game);
691 assert!(out.is_empty());
692 }
693
694 #[test]
695 fn esc_from_stats_returns_to_game() {
696 let s = GameState::default();
697 let mut ui = UiState::new();
698 ui.mode = Mode::Stats;
699 let mut out = Vec::new();
700 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
701 assert_eq!(ui.mode, Mode::Game);
702 assert!(out.is_empty());
703 }
704
705 #[test]
706 fn s_key_toggles_stats() {
707 let s = GameState::default();
708 let mut ui = UiState::new();
709 let mut out = Vec::new();
710 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
711 assert_eq!(ui.mode, Mode::Stats);
712 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
713 assert_eq!(ui.mode, Mode::Game);
714 }
715
716 #[test]
717 fn space_emits_click_center() {
718 let s = GameState::default();
719 let mut ui = UiState::new();
720 let mut out = Vec::new();
721 process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
722 assert_eq!(out.len(), 1);
723 assert!(matches!(out[0], Action::ClickCenter));
724 }
725
726 #[test]
727 fn g_with_no_powerup_is_silent() {
728 let s = GameState::default();
729 let mut ui = UiState::new();
730 let mut out = Vec::new();
731 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
732 assert!(out.is_empty());
733 }
734
735 #[test]
736 fn g_with_powerup_emits_catch() {
737 let (s, id) = state_with_lucky();
738 let mut ui = UiState::new();
739 let mut out = Vec::new();
740 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
741 assert!(
742 matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
743 "expected CatchPowerup({id}), got {out:?}"
744 );
745 }
746
747 #[test]
748 fn fkeys_gated_by_debug() {
749 let s = GameState::default();
750 let mut ui = UiState::new();
751 let mut out = Vec::new();
753 let c = empty_ctx(&s);
754 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
755 process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
756 process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
757 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
758 process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
759 assert!(out.is_empty(), "F-keys must be silent when debug=false");
760 }
761
762 #[test]
763 fn fkeys_active_when_debug() {
764 let s = GameState::default();
765 let mut ui = UiState::new();
766 let c = ctx(
767 Rect::default(),
768 &[],
769 Rect::default(),
770 Rect::default(),
771 &[],
772 &[],
773 &[],
774 true, &s,
776 );
777 let mut out = Vec::new();
778 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
779 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
780 process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
781 assert!(matches!(
782 out[0],
783 Action::DevForcePowerup(PowerupKind::Lucky)
784 ));
785 assert!(matches!(out[1], Action::DevAddCuques(_)));
786 assert!(matches!(
787 out[2],
788 Action::DevForcePowerup(PowerupKind::GreenCoin)
789 ));
790 }
791
792 fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
795 ctx(
796 Rect::default(),
797 &[],
798 Rect::default(),
799 Rect::default(),
800 rows,
801 &[],
802 &[],
803 false,
804 state,
805 )
806 }
807
808 #[test]
809 fn digit_1_buys_one() {
810 let s = GameState::default();
811 let mut ui = UiState::new();
812 let rows = [(0_usize, rect(0, 0, 1, 1))];
813 let mut out = Vec::new();
814 process_input_event(
815 key(KeyCode::Char('1')),
816 &mut ui,
817 &fingerer_row_ctx(&s, &rows),
818 &mut out,
819 );
820 assert!(matches!(
821 out.as_slice(),
822 [Action::BuyFingerer {
823 idx: 0,
824 qty: BuyQty::One,
825 }]
826 ));
827 }
828
829 #[test]
830 fn shifted_digit_symbol_buys_ten() {
831 let s = GameState::default();
834 let mut ui = UiState::new();
835 let rows = [(0_usize, rect(0, 0, 1, 1))];
836 let mut out = Vec::new();
837 process_input_event(
838 key(KeyCode::Char('!')),
839 &mut ui,
840 &fingerer_row_ctx(&s, &rows),
841 &mut out,
842 );
843 assert!(matches!(
844 out.as_slice(),
845 [Action::BuyFingerer {
846 idx: 0,
847 qty: BuyQty::Ten,
848 }]
849 ));
850 }
851
852 #[test]
853 fn shift_modifier_on_digit_buys_ten() {
854 let s = GameState::default();
856 let mut ui = UiState::new();
857 let rows = [(0_usize, rect(0, 0, 1, 1))];
858 let mut out = Vec::new();
859 process_input_event(
860 key_with(KeyCode::Char('1'), true, false, false),
861 &mut ui,
862 &fingerer_row_ctx(&s, &rows),
863 &mut out,
864 );
865 assert!(matches!(
866 out.as_slice(),
867 [Action::BuyFingerer {
868 qty: BuyQty::Ten,
869 ..
870 }]
871 ));
872 }
873
874 #[test]
875 fn alt_or_ctrl_modifier_on_digit_buys_max() {
876 let s = GameState::default();
877 let rows = [(0_usize, rect(0, 0, 1, 1))];
878 for (alt, ctrl) in [(true, false), (false, true)] {
879 let mut ui = UiState::new();
880 let mut out = Vec::new();
881 process_input_event(
882 key_with(KeyCode::Char('1'), false, alt, ctrl),
883 &mut ui,
884 &fingerer_row_ctx(&s, &rows),
885 &mut out,
886 );
887 assert!(
888 matches!(
889 out.as_slice(),
890 [Action::BuyFingerer {
891 qty: BuyQty::Max,
892 ..
893 }]
894 ),
895 "alt={alt} ctrl={ctrl} should buy max",
896 );
897 }
898 }
899
900 #[test]
901 fn digit_with_no_visible_row_is_silent() {
902 let s = GameState::default();
905 let mut ui = UiState::new();
906 let mut out = Vec::new();
907 process_input_event(
908 key(KeyCode::Char('1')),
909 &mut ui,
910 &fingerer_row_ctx(&s, &[]),
911 &mut out,
912 );
913 assert!(out.is_empty());
914 }
915
916 #[test]
919 fn left_click_on_biscuit_emits_click() {
920 let s = GameState::default();
921 let mut ui = UiState::new();
922 let c = ctx(
923 rect(10, 5, 30, 20),
924 &[],
925 rect(0, 0, 100, 30),
926 Rect::default(),
927 &[],
928 &[],
929 &[],
930 false,
931 &s,
932 );
933 let mut out = Vec::new();
934 process_input_event(
935 mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
936 &mut ui,
937 &c,
938 &mut out,
939 );
940 assert!(
941 matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
942 "got {:?}",
943 out
944 );
945 }
946
947 #[test]
948 fn right_click_on_biscuit_is_noop() {
949 let s = GameState::default();
952 let mut ui = UiState::new();
953 let c = ctx(
954 rect(10, 5, 30, 20),
955 &[],
956 rect(0, 0, 100, 30),
957 Rect::default(),
958 &[],
959 &[],
960 &[],
961 false,
962 &s,
963 );
964 let mut out = Vec::new();
965 process_input_event(
966 mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
967 &mut ui,
968 &c,
969 &mut out,
970 );
971 assert!(out.is_empty(), "got {:?}", out);
972 }
973
974 #[test]
975 fn left_click_on_powerup_emits_catch() {
976 let (s, id) = state_with_lucky();
977 let mut ui = UiState::new();
978 let powerup_rects = [(id, rect(50, 12, 4, 2))];
979 let c = ctx(
980 Rect::default(),
981 &powerup_rects,
982 rect(0, 0, 100, 30),
983 Rect::default(),
984 &[],
985 &[],
986 &[],
987 false,
988 &s,
989 );
990 let mut out = Vec::new();
991 process_input_event(
992 mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
993 &mut ui,
994 &c,
995 &mut out,
996 );
997 assert!(
998 matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
999 "got {out:?}"
1000 );
1001 }
1002
1003 #[test]
1004 fn left_click_on_powerup_only_catches_one_under_cursor() {
1005 let mut s = GameState::default();
1009 let lucky_id = s.mint_spawn_id();
1010 s.powerups.push(Powerup {
1011 kind: PowerupKind::Lucky,
1012 spawn_id: lucky_id,
1013 frac_x: 0.5,
1014 frac_y: 0.5,
1015 life_ticks: 100,
1016 });
1017 let green_id = s.mint_spawn_id();
1018 s.powerups.push(Powerup {
1019 kind: PowerupKind::GreenCoin,
1020 spawn_id: green_id,
1021 frac_x: 0.5,
1022 frac_y: 0.5,
1023 life_ticks: 100,
1024 });
1025 let mut ui = UiState::new();
1026 let powerup_rects = [
1027 (lucky_id, rect(50, 12, 5, 3)),
1028 (green_id, rect(70, 12, 5, 3)),
1029 ];
1030 let c = ctx(
1031 Rect::default(),
1032 &powerup_rects,
1033 rect(0, 0, 100, 30),
1034 Rect::default(),
1035 &[],
1036 &[],
1037 &[],
1038 false,
1039 &s,
1040 );
1041 let mut out = Vec::new();
1042 process_input_event(
1043 mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
1044 &mut ui,
1045 &c,
1046 &mut out,
1047 );
1048 assert!(
1049 matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1050 "got {out:?}"
1051 );
1052 }
1053
1054 #[test]
1055 fn g_with_two_kinds_picks_lower_life_ticks() {
1056 let mut s = GameState::default();
1059 let lucky_id = s.mint_spawn_id();
1060 s.powerups.push(Powerup {
1061 kind: PowerupKind::Lucky,
1062 spawn_id: lucky_id,
1063 frac_x: 0.5,
1064 frac_y: 0.5,
1065 life_ticks: 50,
1066 });
1067 let green_id = s.mint_spawn_id();
1068 s.powerups.push(Powerup {
1069 kind: PowerupKind::GreenCoin,
1070 spawn_id: green_id,
1071 frac_x: 0.5,
1072 frac_y: 0.5,
1073 life_ticks: 200,
1074 });
1075 let c = empty_ctx(&s);
1076 let mut ui = UiState::new();
1077 let mut out = Vec::new();
1078 process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1079 assert!(
1080 matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == lucky_id),
1081 "lower-life Lucky should win, got {out:?}"
1082 );
1083
1084 let mut s = GameState::default();
1086 let lucky_id = s.mint_spawn_id();
1087 s.powerups.push(Powerup {
1088 kind: PowerupKind::Lucky,
1089 spawn_id: lucky_id,
1090 frac_x: 0.5,
1091 frac_y: 0.5,
1092 life_ticks: 200,
1093 });
1094 let green_id = s.mint_spawn_id();
1095 s.powerups.push(Powerup {
1096 kind: PowerupKind::GreenCoin,
1097 spawn_id: green_id,
1098 frac_x: 0.5,
1099 frac_y: 0.5,
1100 life_ticks: 50,
1101 });
1102 let c = empty_ctx(&s);
1103 let mut out = Vec::new();
1104 process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1105 assert!(
1106 matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1107 "lower-life GreenCoin should win, got {out:?}"
1108 );
1109 }
1110
1111 #[test]
1112 fn right_click_on_powerup_also_catches() {
1113 let (s, id) = state_with_lucky();
1115 let mut ui = UiState::new();
1116 let powerup_rects = [(id, rect(50, 12, 4, 2))];
1117 let c = ctx(
1118 Rect::default(),
1119 &powerup_rects,
1120 rect(0, 0, 100, 30),
1121 Rect::default(),
1122 &[],
1123 &[],
1124 &[],
1125 false,
1126 &s,
1127 );
1128 let mut out = Vec::new();
1129 process_input_event(
1130 mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
1131 &mut ui,
1132 &c,
1133 &mut out,
1134 );
1135 assert!(
1136 matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
1137 "got {out:?}"
1138 );
1139 }
1140
1141 #[test]
1142 fn left_click_on_fingerer_row_buys_one() {
1143 let s = GameState::default();
1144 let mut ui = UiState::new();
1145 let rows = [(2_usize, rect(100, 5, 38, 3))];
1146 let c = ctx(
1147 Rect::default(),
1148 &[],
1149 Rect::default(),
1150 Rect::default(),
1151 &rows,
1152 &[],
1153 &[],
1154 false,
1155 &s,
1156 );
1157 let mut out = Vec::new();
1158 process_input_event(
1159 mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1160 &mut ui,
1161 &c,
1162 &mut out,
1163 );
1164 assert!(matches!(
1165 out.as_slice(),
1166 [Action::BuyFingerer {
1167 idx: 2,
1168 qty: BuyQty::One,
1169 }]
1170 ));
1171 }
1172
1173 #[test]
1174 fn right_click_on_fingerer_row_buys_max() {
1175 let s = GameState::default();
1177 let mut ui = UiState::new();
1178 let rows = [(2_usize, rect(100, 5, 38, 3))];
1179 let c = ctx(
1180 Rect::default(),
1181 &[],
1182 Rect::default(),
1183 Rect::default(),
1184 &rows,
1185 &[],
1186 &[],
1187 false,
1188 &s,
1189 );
1190 let mut out = Vec::new();
1191 process_input_event(
1192 mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1193 &mut ui,
1194 &c,
1195 &mut out,
1196 );
1197 assert!(matches!(
1198 out.as_slice(),
1199 [Action::BuyFingerer {
1200 qty: BuyQty::Max,
1201 ..
1202 }]
1203 ));
1204 }
1205
1206 #[test]
1207 fn shift_left_click_on_fingerer_row_buys_ten() {
1208 let s = GameState::default();
1209 let mut ui = UiState::new();
1210 let rows = [(2_usize, rect(100, 5, 38, 3))];
1211 let c = ctx(
1212 Rect::default(),
1213 &[],
1214 Rect::default(),
1215 Rect::default(),
1216 &rows,
1217 &[],
1218 &[],
1219 false,
1220 &s,
1221 );
1222 let mut out = Vec::new();
1223 let mods = Modifiers {
1224 shift: true,
1225 ..Modifiers::default()
1226 };
1227 process_input_event(
1228 mouse_down(110, 6, MouseButton::Left, mods),
1229 &mut ui,
1230 &c,
1231 &mut out,
1232 );
1233 assert!(matches!(
1234 out.as_slice(),
1235 [Action::BuyFingerer {
1236 qty: BuyQty::Ten,
1237 ..
1238 }]
1239 ));
1240 }
1241
1242 #[test]
1245 fn dead_zone_left_click_inside_play_area_emits_misclick() {
1246 let s = GameState::default();
1248 let mut ui = UiState::new();
1249 let c = ctx(
1250 rect(40, 10, 20, 10), &[],
1252 rect(0, 0, 100, 30), Rect::default(),
1254 &[],
1255 &[],
1256 &[],
1257 false,
1258 &s,
1259 );
1260 let mut out = Vec::new();
1261 process_input_event(
1262 mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1263 &mut ui,
1264 &c,
1265 &mut out,
1266 );
1267 assert!(
1268 matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1269 "got {:?}",
1270 out
1271 );
1272 }
1273
1274 #[test]
1275 fn click_outside_play_area_does_not_misclick() {
1276 let s = GameState::default();
1279 let mut ui = UiState::new();
1280 let c = ctx(
1281 rect(40, 10, 20, 10),
1282 &[],
1283 rect(0, 0, 100, 30), Rect::default(),
1285 &[],
1286 &[],
1287 &[],
1288 false,
1289 &s,
1290 );
1291 let mut out = Vec::new();
1292 process_input_event(
1293 mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1294 &mut ui,
1295 &c,
1296 &mut out,
1297 );
1298 assert!(out.is_empty(), "got {:?}", out);
1299 }
1300
1301 #[test]
1302 fn right_click_in_dead_zone_is_silent() {
1303 let s = GameState::default();
1305 let mut ui = UiState::new();
1306 let c = ctx(
1307 rect(40, 10, 20, 10),
1308 &[],
1309 rect(0, 0, 100, 30),
1310 Rect::default(),
1311 &[],
1312 &[],
1313 &[],
1314 false,
1315 &s,
1316 );
1317 let mut out = Vec::new();
1318 process_input_event(
1319 mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1320 &mut ui,
1321 &c,
1322 &mut out,
1323 );
1324 assert!(out.is_empty());
1325 }
1326
1327 #[test]
1330 fn click_quit_hint_flips_running() {
1331 let s = GameState::default();
1332 let mut ui = UiState::new();
1333 let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1334 let c = ctx(
1335 Rect::default(),
1336 &[],
1337 rect(0, 0, 100, 30),
1338 Rect::default(),
1339 &[],
1340 &[],
1341 &hits,
1342 false,
1343 &s,
1344 );
1345 let mut out = Vec::new();
1346 process_input_event(
1347 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1348 &mut ui,
1349 &c,
1350 &mut out,
1351 );
1352 assert!(!ui.running);
1353 assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1354 }
1355
1356 #[test]
1357 fn click_open_mode_hint_toggles_mode() {
1358 let s = GameState::default();
1359 let mut ui = UiState::new();
1360 let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1361 let c = ctx(
1362 Rect::default(),
1363 &[],
1364 rect(0, 0, 100, 30),
1365 Rect::default(),
1366 &[],
1367 &[],
1368 &hits,
1369 false,
1370 &s,
1371 );
1372 let mut out = Vec::new();
1373 process_input_event(
1374 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1375 &mut ui,
1376 &c,
1377 &mut out,
1378 );
1379 assert_eq!(ui.mode, Mode::Stats);
1380 }
1381
1382 #[test]
1385 fn prestige_reset_rect_unavailable_does_not_reset() {
1386 let s = GameState::default(); let mut ui = UiState::new();
1392 let c = ctx(
1393 Rect::default(),
1394 &[],
1395 rect(0, 0, 100, 30),
1396 rect(40, 15, 30, 1), &[],
1398 &[],
1399 &[],
1400 false,
1401 &s,
1402 );
1403 let mut out = Vec::new();
1404 process_input_event(
1405 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1406 &mut ui,
1407 &c,
1408 &mut out,
1409 );
1410 assert!(
1411 !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1412 "no PrestigeReset when unavailable; got {:?}",
1413 out
1414 );
1415 assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1416 }
1417
1418 #[test]
1419 fn prestige_reset_rect_available_emits_action() {
1420 let s = state_with_prestige();
1421 let mut ui = UiState::new();
1422 ui.mode = Mode::Prestige;
1423 let c = ctx(
1424 Rect::default(),
1425 &[],
1426 rect(0, 0, 100, 30),
1427 rect(40, 15, 30, 1),
1428 &[],
1429 &[],
1430 &[],
1431 false,
1432 &s,
1433 );
1434 let mut out = Vec::new();
1435 process_input_event(
1436 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1437 &mut ui,
1438 &c,
1439 &mut out,
1440 );
1441 assert_eq!(out.len(), 1);
1442 assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1443 assert_eq!(
1444 ui.mode,
1445 Mode::Game,
1446 "panel auto-closes after prestige confirm",
1447 );
1448 }
1449
1450 #[test]
1453 fn wheel_down_inside_play_area_increases_zoom_idx() {
1454 let s = GameState::default();
1455 let mut ui = UiState::new();
1456 let c = ctx(
1457 Rect::default(),
1458 &[],
1459 rect(0, 0, 100, 30),
1460 Rect::default(),
1461 &[],
1462 &[],
1463 &[],
1464 false,
1465 &s,
1466 );
1467 let mut out = Vec::new();
1468 process_input_event(
1469 InputEvent::Wheel {
1470 col: 50,
1471 row: 15,
1472 delta: WheelDelta::Down,
1473 },
1474 &mut ui,
1475 &c,
1476 &mut out,
1477 );
1478 assert_eq!(ui.zoom_idx, 1);
1479 }
1480
1481 #[test]
1482 fn wheel_outside_play_area_does_not_zoom() {
1483 let s = GameState::default();
1485 let mut ui = UiState::new();
1486 let c = ctx(
1487 Rect::default(),
1488 &[],
1489 rect(0, 0, 100, 30),
1490 Rect::default(),
1491 &[],
1492 &[],
1493 &[],
1494 false,
1495 &s,
1496 );
1497 let mut out = Vec::new();
1498 process_input_event(
1499 InputEvent::Wheel {
1500 col: 120,
1501 row: 10,
1502 delta: WheelDelta::Down,
1503 },
1504 &mut ui,
1505 &c,
1506 &mut out,
1507 );
1508 assert_eq!(ui.zoom_idx, 0);
1509 }
1510
1511 #[test]
1512 fn wheel_up_saturates_at_zero() {
1513 let s = GameState::default();
1514 let mut ui = UiState::new();
1515 ui.zoom_idx = 0;
1516 let c = ctx(
1517 Rect::default(),
1518 &[],
1519 rect(0, 0, 100, 30),
1520 Rect::default(),
1521 &[],
1522 &[],
1523 &[],
1524 false,
1525 &s,
1526 );
1527 let mut out = Vec::new();
1528 process_input_event(
1529 InputEvent::Wheel {
1530 col: 50,
1531 row: 15,
1532 delta: WheelDelta::Up,
1533 },
1534 &mut ui,
1535 &c,
1536 &mut out,
1537 );
1538 assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1539 }
1540
1541 #[test]
1542 fn wheel_down_caps_at_last_level() {
1543 let s = GameState::default();
1544 let mut ui = UiState::new();
1545 let last = crate::ui::biscuit::level_count() - 1;
1546 ui.zoom_idx = last;
1547 let c = ctx(
1548 Rect::default(),
1549 &[],
1550 rect(0, 0, 100, 30),
1551 Rect::default(),
1552 &[],
1553 &[],
1554 &[],
1555 false,
1556 &s,
1557 );
1558 let mut out = Vec::new();
1559 process_input_event(
1560 InputEvent::Wheel {
1561 col: 50,
1562 row: 15,
1563 delta: WheelDelta::Down,
1564 },
1565 &mut ui,
1566 &c,
1567 &mut out,
1568 );
1569 assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1570 }
1571
1572 #[test]
1575 fn mouse_moved_updates_last_position() {
1576 let s = GameState::default();
1577 let mut ui = UiState::new();
1578 let mut out = Vec::new();
1579 process_input_event(
1580 InputEvent::MouseMoved { col: 42, row: 7 },
1581 &mut ui,
1582 &empty_ctx(&s),
1583 &mut out,
1584 );
1585 assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1586 assert!(out.is_empty());
1587 }
1588
1589 #[test]
1590 fn mouse_down_updates_last_position_before_dispatch() {
1591 let s = GameState::default();
1595 let mut ui = UiState::new();
1596 let c = ctx(
1597 rect(40, 10, 20, 10),
1598 &[],
1599 rect(0, 0, 100, 30),
1600 Rect::default(),
1601 &[],
1602 &[],
1603 &[],
1604 false,
1605 &s,
1606 );
1607 let mut out = Vec::new();
1608 process_input_event(
1609 mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1610 &mut ui,
1611 &c,
1612 &mut out,
1613 );
1614 assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1615 }
1616}