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 golden_rects: [Rect; 3],
127 pub green_coin_rect: Rect,
130 pub play_area: Rect,
131 pub prestige_reset_rect: Rect,
132 pub debug: bool,
133 pub current: &'a GameState,
134}
135
136impl<'a> InputContext<'a> {
137 pub fn from_layout(
147 layout: &'a crate::ui::DrawOutput,
148 current: &'a GameState,
149 debug: bool,
150 ) -> Self {
151 InputContext {
152 fingerer_rows: &layout.fingerer_rows,
153 upgrade_rows: &layout.upgrade_rows,
154 help_hits: &layout.help_hits,
155 biscuit_rect: layout.biscuit_rect,
156 biscuit_focal: layout.biscuit_focal,
157 golden_rects: layout.golden_rects,
158 green_coin_rect: layout.green_coin_rect,
159 play_area: layout.play_area,
160 prestige_reset_rect: layout.prestige_reset_rect,
161 debug,
162 current,
163 }
164 }
165}
166
167pub fn process_input_event(
173 ev: InputEvent,
174 ui: &mut UiState,
175 ctx: &InputContext,
176 out: &mut Vec<Action>,
177) {
178 match ev {
179 InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
180 InputEvent::MouseDown {
181 col,
182 row,
183 button,
184 mods,
185 } => {
186 ui.last_mouse_pos = Some((col, row));
187 if try_help_click(col, row, ui, ctx, out) {
192 return;
193 }
194 handle_click(col, row, button, mods, ui, ctx, out);
195 }
196 InputEvent::MouseMoved { col, row } => {
197 ui.last_mouse_pos = Some((col, row));
200 }
201 InputEvent::Wheel { col, row, delta } => {
202 if !in_play_area(col, row, ctx.play_area) {
207 return;
208 }
209 match delta {
210 WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
211 WheelDelta::Down => {
212 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
213 }
214 }
215 }
216 }
217}
218
219fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
224 if play_area.width == 0 || play_area.height == 0 {
225 return true;
226 }
227 col >= play_area.x
228 && col < play_area.x + play_area.width
229 && row >= play_area.y
230 && row < play_area.y + play_area.height
231}
232
233fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
234 rect.width > 0
235 && rect.height > 0
236 && col >= rect.x
237 && col < rect.x + rect.width
238 && row >= rect.y
239 && row < rect.y + rect.height
240}
241
242fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
247 use crate::game::golden::GoldenVariant;
248 let mut best: Option<(u32, Action)> = None;
249 for variant in GoldenVariant::ALL {
250 if let Some(g) = ctx.current.goldens[variant as usize].as_ref() {
251 let action = Action::CatchGolden(variant);
252 best = match best {
253 Some((life, _)) if life <= g.life_ticks => best,
254 _ => Some((g.life_ticks, action)),
255 };
256 }
257 }
258 if let Some(c) = ctx.current.green_coin.as_ref() {
259 let action = Action::CatchGreenCoin;
260 best = match best {
261 Some((life, _)) if life <= c.life_ticks => best,
262 _ => Some((c.life_ticks, action)),
263 };
264 }
265 if let Some((_, a)) = best {
266 out.push(a);
267 }
268}
269
270fn click_buy_qty(mods: Modifiers) -> BuyQty {
271 if mods.alt || mods.ctrl {
272 BuyQty::Max
273 } else if mods.shift {
274 BuyQty::Ten
275 } else {
276 BuyQty::One
277 }
278}
279
280fn try_help_click(
284 col: u16,
285 row: u16,
286 ui: &mut UiState,
287 ctx: &InputContext,
288 out: &mut Vec<Action>,
289) -> bool {
290 if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
294 out.push(Action::PrestigeReset);
295 ui.mode = Mode::Game;
296 return true;
297 }
298 for &(action, rect) in ctx.help_hits {
299 if !rect_contains(rect, col, row) {
300 continue;
301 }
302 match action {
303 HelpAction::OpenMode(target) => {
304 ui.mode = if ui.mode == target {
307 Mode::Game
308 } else {
309 target
310 };
311 }
312 HelpAction::GrabGolden => {
313 push_grab_most_urgent(ctx, out);
316 }
317 HelpAction::PrestigeReset => {
318 if ctx.current.prestige_available() > 0 {
319 out.push(Action::PrestigeReset);
320 ui.mode = Mode::Game;
321 }
322 }
323 HelpAction::Quit => {
324 ui.running = false;
325 }
326 }
327 return true;
328 }
329 false
330}
331
332fn handle_click(
333 col: u16,
334 row: u16,
335 button: MouseButton,
336 mods: Modifiers,
337 ui: &UiState,
338 ctx: &InputContext,
339 out: &mut Vec<Action>,
340) {
341 for variant in crate::game::golden::GoldenVariant::ALL {
350 if rect_contains(ctx.golden_rects[variant as usize], col, row) {
351 out.push(Action::CatchGolden(variant));
352 return;
353 }
354 }
355 if rect_contains(ctx.green_coin_rect, col, row) {
356 out.push(Action::CatchGreenCoin);
357 return;
358 }
359 if rect_contains(ctx.biscuit_rect, col, row) {
363 if button == MouseButton::Left {
364 out.push(Action::Click { col, row });
365 }
366 return;
367 }
368 if ui.mode == Mode::Game {
373 for &(idx, r) in ctx.fingerer_rows {
374 if rect_contains(r, col, row) {
375 let qty = if button == MouseButton::Right {
376 BuyQty::Max
377 } else {
378 click_buy_qty(mods)
379 };
380 out.push(Action::BuyFingerer { idx, qty });
381 return;
382 }
383 }
384 }
385 if ui.mode == Mode::Upgrades {
388 for &(idx, r) in ctx.upgrade_rows {
389 if rect_contains(r, col, row) {
390 out.push(Action::BuyUpgrade(idx));
391 return;
392 }
393 }
394 }
395 if button != MouseButton::Left {
407 return;
408 }
409 if !rect_contains(ctx.play_area, col, row) {
410 return;
411 }
412 if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
413 return;
414 }
415 out.push(Action::Misclick { col, row });
416}
417
418fn handle_key(
419 code: KeyCode,
420 mods: Modifiers,
421 ui: &mut UiState,
422 ctx: &InputContext,
423 out: &mut Vec<Action>,
424) {
425 match code {
426 KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
431 KeyCode::Esc => match ui.mode {
436 Mode::Game => {}
437 _ => ui.mode = Mode::Game,
438 },
439 KeyCode::Char('s') | KeyCode::Char('S') => {
440 ui.mode = if matches!(ui.mode, Mode::Stats) {
441 Mode::Game
442 } else {
443 Mode::Stats
444 };
445 }
446 KeyCode::Char('a') | KeyCode::Char('A') => {
447 ui.mode = if matches!(ui.mode, Mode::Achievements) {
448 Mode::Game
449 } else {
450 Mode::Achievements
451 };
452 }
453 KeyCode::Char('u') | KeyCode::Char('U') => {
454 ui.mode = if matches!(ui.mode, Mode::Upgrades) {
455 Mode::Game
456 } else {
457 Mode::Upgrades
458 };
459 }
460 KeyCode::Char('g') | KeyCode::Char('G') => {
466 push_grab_most_urgent(ctx, out);
467 }
468 KeyCode::F(8) if ctx.debug => {
474 out.push(Action::DevForceGolden(
475 crate::game::golden::GoldenVariant::Lucky,
476 ));
477 }
478 KeyCode::F(2) if ctx.debug => {
479 out.push(Action::DevForceGolden(
480 crate::game::golden::GoldenVariant::Frenzy,
481 ));
482 }
483 KeyCode::F(3) if ctx.debug => {
484 out.push(Action::DevForceGolden(
485 crate::game::golden::GoldenVariant::Buff,
486 ));
487 }
488 KeyCode::F(4) if ctx.debug => {
489 out.push(Action::DevAddCuques(1_000_000.0));
490 }
491 KeyCode::F(5) if ctx.debug => {
492 out.push(Action::DevSpawnGreenCoin);
493 }
494 KeyCode::Char('p') | KeyCode::Char('P') => {
495 ui.mode = if matches!(ui.mode, Mode::Prestige) {
496 Mode::Game
497 } else {
498 Mode::Prestige
499 };
500 }
501 KeyCode::Char('r') | KeyCode::Char('R')
506 if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
507 {
508 out.push(Action::PrestigeReset);
509 ui.mode = Mode::Game;
510 }
511 KeyCode::Char('+') | KeyCode::Char('=') => {
512 ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
513 }
514 KeyCode::Char('-') | KeyCode::Char('_') => {
515 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
516 }
517 KeyCode::Char(' ') => {
520 out.push(Action::ClickCenter);
521 }
522 KeyCode::Char(c) => {
523 if let Some((slot, shifted_sym)) = digit_slot(c) {
524 let buy_10 = shifted_sym || mods.shift;
525 let buy_max = mods.alt || mods.ctrl;
526 match ui.mode {
527 Mode::Game => {
528 if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
529 let qty = if buy_max {
530 BuyQty::Max
531 } else if buy_10 {
532 BuyQty::Ten
533 } else {
534 BuyQty::One
535 };
536 out.push(Action::BuyFingerer { idx: fid, qty });
537 }
538 }
539 Mode::Upgrades => {
540 if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
541 out.push(Action::BuyUpgrade(u_idx));
542 }
543 }
544 _ => {}
545 }
546 }
547 }
548 _ => {}
549 }
550}
551
552fn digit_slot(c: char) -> Option<(usize, bool)> {
553 match c {
554 '1' => Some((0, false)),
555 '2' => Some((1, false)),
556 '3' => Some((2, false)),
557 '4' => Some((3, false)),
558 '5' => Some((4, false)),
559 '6' => Some((5, false)),
560 '7' => Some((6, false)),
561 '8' => Some((7, false)),
562 '9' => Some((8, false)),
563 '0' => Some((9, false)),
564 '!' => Some((0, true)),
565 '@' => Some((1, true)),
566 '#' => Some((2, true)),
567 '$' => Some((3, true)),
568 '%' => Some((4, true)),
569 '^' => Some((5, true)),
570 '&' => Some((6, true)),
571 '*' => Some((7, true)),
572 '(' => Some((8, true)),
573 ')' => Some((9, true)),
574 _ => None,
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
590 use crate::game::golden::{self, GoldenVariant};
591 use crate::sim::{Action, BuyQty};
592 use ratatui::layout::Rect;
593 use std::mem::discriminant;
594
595 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
596 Rect::new(x, y, w, h)
597 }
598
599 #[allow(clippy::too_many_arguments)]
603 fn ctx<'a>(
604 biscuit: Rect,
605 golden_rect: Rect,
606 play_area: Rect,
607 prestige_reset_rect: Rect,
608 fingerer_rows: &'a [(usize, Rect)],
609 upgrade_rows: &'a [(usize, Rect)],
610 help_hits: &'a [(HelpAction, Rect)],
611 debug: bool,
612 current: &'a GameState,
613 ) -> InputContext<'a> {
614 let mut golden_rects = [Rect::default(); 3];
617 golden_rects[GoldenVariant::Lucky as usize] = golden_rect;
618 InputContext {
619 fingerer_rows,
620 upgrade_rows,
621 help_hits,
622 biscuit_rect: biscuit,
623 biscuit_focal: (0, 0),
624 golden_rects,
625 green_coin_rect: Rect::default(),
626 play_area,
627 prestige_reset_rect,
628 debug,
629 current,
630 }
631 }
632
633 fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
634 ctx(
635 Rect::default(),
636 Rect::default(),
637 Rect::default(),
638 Rect::default(),
639 &[],
640 &[],
641 &[],
642 false,
643 state,
644 )
645 }
646
647 fn state_with_golden() -> GameState {
650 let mut g = golden::spawn_in(rect(10, 10, 20, 10));
651 g.variant = GoldenVariant::Lucky;
652 let mut s = GameState::default();
653 s.goldens[GoldenVariant::Lucky as usize] = Some(g);
654 s
655 }
656
657 fn state_with_prestige() -> GameState {
660 GameState {
663 lifetime_cuques: 4_000_000_000.0,
664 ..GameState::default()
665 }
666 }
667
668 fn key(code: KeyCode) -> InputEvent {
669 InputEvent::KeyPress {
670 code,
671 mods: Modifiers::default(),
672 }
673 }
674
675 fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
676 InputEvent::KeyPress {
677 code,
678 mods: Modifiers { shift, alt, ctrl },
679 }
680 }
681
682 fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
683 InputEvent::MouseDown {
684 col,
685 row,
686 button,
687 mods,
688 }
689 }
690
691 #[test]
694 fn q_key_flips_running_off() {
695 let s = GameState::default();
696 let mut ui = UiState::new();
697 let mut out = Vec::new();
698 process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
699 assert!(!ui.running);
700 assert!(out.is_empty());
701 }
702
703 #[test]
704 fn esc_from_game_is_noop() {
705 let s = GameState::default();
707 let mut ui = UiState::new();
708 let mut out = Vec::new();
709 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
710 assert!(ui.running);
711 assert_eq!(ui.mode, Mode::Game);
712 assert!(out.is_empty());
713 }
714
715 #[test]
716 fn esc_from_stats_returns_to_game() {
717 let s = GameState::default();
718 let mut ui = UiState::new();
719 ui.mode = Mode::Stats;
720 let mut out = Vec::new();
721 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
722 assert_eq!(ui.mode, Mode::Game);
723 assert!(out.is_empty());
724 }
725
726 #[test]
727 fn s_key_toggles_stats() {
728 let s = GameState::default();
729 let mut ui = UiState::new();
730 let mut out = Vec::new();
731 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
732 assert_eq!(ui.mode, Mode::Stats);
733 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
734 assert_eq!(ui.mode, Mode::Game);
735 }
736
737 #[test]
738 fn space_emits_click_center() {
739 let s = GameState::default();
740 let mut ui = UiState::new();
741 let mut out = Vec::new();
742 process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
743 assert_eq!(out.len(), 1);
744 assert!(matches!(out[0], Action::ClickCenter));
745 }
746
747 #[test]
748 fn g_with_no_golden_is_silent() {
749 let s = GameState::default();
750 let mut ui = UiState::new();
751 let mut out = Vec::new();
752 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
753 assert!(out.is_empty());
754 }
755
756 #[test]
757 fn g_with_golden_emits_catch() {
758 let s = state_with_golden();
759 let mut ui = UiState::new();
760 let mut out = Vec::new();
761 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
762 assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
763 }
764
765 #[test]
766 fn fkeys_gated_by_debug() {
767 let s = GameState::default();
768 let mut ui = UiState::new();
769 let mut out = Vec::new();
771 let c = empty_ctx(&s);
772 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
773 process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
774 process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
775 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
776 assert!(out.is_empty(), "F-keys must be silent when debug=false");
777 }
778
779 #[test]
780 fn fkeys_active_when_debug() {
781 let s = GameState::default();
782 let mut ui = UiState::new();
783 let c = ctx(
784 Rect::default(),
785 Rect::default(),
786 Rect::default(),
787 Rect::default(),
788 &[],
789 &[],
790 &[],
791 true, &s,
793 );
794 let mut out = Vec::new();
795 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
796 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
797 assert!(matches!(
798 out[0],
799 Action::DevForceGolden(GoldenVariant::Lucky)
800 ));
801 assert!(matches!(out[1], Action::DevAddCuques(_)));
802 }
803
804 fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
807 ctx(
808 Rect::default(),
809 Rect::default(),
810 Rect::default(),
811 Rect::default(),
812 rows,
813 &[],
814 &[],
815 false,
816 state,
817 )
818 }
819
820 #[test]
821 fn digit_1_buys_one() {
822 let s = GameState::default();
823 let mut ui = UiState::new();
824 let rows = [(0_usize, rect(0, 0, 1, 1))];
825 let mut out = Vec::new();
826 process_input_event(
827 key(KeyCode::Char('1')),
828 &mut ui,
829 &fingerer_row_ctx(&s, &rows),
830 &mut out,
831 );
832 assert!(matches!(
833 out.as_slice(),
834 [Action::BuyFingerer {
835 idx: 0,
836 qty: BuyQty::One,
837 }]
838 ));
839 }
840
841 #[test]
842 fn shifted_digit_symbol_buys_ten() {
843 let s = GameState::default();
846 let mut ui = UiState::new();
847 let rows = [(0_usize, rect(0, 0, 1, 1))];
848 let mut out = Vec::new();
849 process_input_event(
850 key(KeyCode::Char('!')),
851 &mut ui,
852 &fingerer_row_ctx(&s, &rows),
853 &mut out,
854 );
855 assert!(matches!(
856 out.as_slice(),
857 [Action::BuyFingerer {
858 idx: 0,
859 qty: BuyQty::Ten,
860 }]
861 ));
862 }
863
864 #[test]
865 fn shift_modifier_on_digit_buys_ten() {
866 let s = GameState::default();
868 let mut ui = UiState::new();
869 let rows = [(0_usize, rect(0, 0, 1, 1))];
870 let mut out = Vec::new();
871 process_input_event(
872 key_with(KeyCode::Char('1'), true, false, false),
873 &mut ui,
874 &fingerer_row_ctx(&s, &rows),
875 &mut out,
876 );
877 assert!(matches!(
878 out.as_slice(),
879 [Action::BuyFingerer {
880 qty: BuyQty::Ten,
881 ..
882 }]
883 ));
884 }
885
886 #[test]
887 fn alt_or_ctrl_modifier_on_digit_buys_max() {
888 let s = GameState::default();
889 let rows = [(0_usize, rect(0, 0, 1, 1))];
890 for (alt, ctrl) in [(true, false), (false, true)] {
891 let mut ui = UiState::new();
892 let mut out = Vec::new();
893 process_input_event(
894 key_with(KeyCode::Char('1'), false, alt, ctrl),
895 &mut ui,
896 &fingerer_row_ctx(&s, &rows),
897 &mut out,
898 );
899 assert!(
900 matches!(
901 out.as_slice(),
902 [Action::BuyFingerer {
903 qty: BuyQty::Max,
904 ..
905 }]
906 ),
907 "alt={alt} ctrl={ctrl} should buy max",
908 );
909 }
910 }
911
912 #[test]
913 fn digit_with_no_visible_row_is_silent() {
914 let s = GameState::default();
917 let mut ui = UiState::new();
918 let mut out = Vec::new();
919 process_input_event(
920 key(KeyCode::Char('1')),
921 &mut ui,
922 &fingerer_row_ctx(&s, &[]),
923 &mut out,
924 );
925 assert!(out.is_empty());
926 }
927
928 #[test]
931 fn left_click_on_biscuit_emits_click() {
932 let s = GameState::default();
933 let mut ui = UiState::new();
934 let c = ctx(
935 rect(10, 5, 30, 20),
936 Rect::default(),
937 rect(0, 0, 100, 30),
938 Rect::default(),
939 &[],
940 &[],
941 &[],
942 false,
943 &s,
944 );
945 let mut out = Vec::new();
946 process_input_event(
947 mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
948 &mut ui,
949 &c,
950 &mut out,
951 );
952 assert!(
953 matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
954 "got {:?}",
955 out
956 );
957 }
958
959 #[test]
960 fn right_click_on_biscuit_is_noop() {
961 let s = GameState::default();
964 let mut ui = UiState::new();
965 let c = ctx(
966 rect(10, 5, 30, 20),
967 Rect::default(),
968 rect(0, 0, 100, 30),
969 Rect::default(),
970 &[],
971 &[],
972 &[],
973 false,
974 &s,
975 );
976 let mut out = Vec::new();
977 process_input_event(
978 mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
979 &mut ui,
980 &c,
981 &mut out,
982 );
983 assert!(out.is_empty(), "got {:?}", out);
984 }
985
986 #[test]
987 fn left_click_on_golden_emits_catch() {
988 let s = state_with_golden();
989 let mut ui = UiState::new();
990 let c = ctx(
991 Rect::default(),
992 rect(50, 12, 4, 2),
993 rect(0, 0, 100, 30),
994 Rect::default(),
995 &[],
996 &[],
997 &[],
998 false,
999 &s,
1000 );
1001 let mut out = Vec::new();
1002 process_input_event(
1003 mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
1004 &mut ui,
1005 &c,
1006 &mut out,
1007 );
1008 assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1009 }
1010
1011 #[test]
1012 fn left_click_on_green_coin_emits_green_catch_only() {
1013 let mut s = state_with_golden();
1017 s.green_coin = Some(crate::game::green_coin::GreenCoin {
1018 frac_x: 0.5,
1019 frac_y: 0.5,
1020 life_ticks: 100,
1021 });
1022 let mut ui = UiState::new();
1023 let mut c = ctx(
1024 Rect::default(),
1025 rect(50, 12, 4, 2),
1026 rect(0, 0, 100, 30),
1027 Rect::default(),
1028 &[],
1029 &[],
1030 &[],
1031 false,
1032 &s,
1033 );
1034 c.green_coin_rect = rect(70, 12, 5, 3);
1035 let mut out = Vec::new();
1036 process_input_event(
1037 mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
1038 &mut ui,
1039 &c,
1040 &mut out,
1041 );
1042 assert!(matches!(out.as_slice(), [Action::CatchGreenCoin]));
1043 }
1044
1045 #[test]
1046 fn g_with_both_powerups_picks_lower_life_ticks() {
1047 let mut s = state_with_golden();
1051 if let Some(g) = s.goldens[GoldenVariant::Lucky as usize].as_mut() {
1053 g.life_ticks = 50;
1054 }
1055 s.green_coin = Some(crate::game::green_coin::GreenCoin {
1056 frac_x: 0.5,
1057 frac_y: 0.5,
1058 life_ticks: 200,
1059 });
1060 let c = empty_ctx(&s);
1061 let mut ui = UiState::new();
1062 let mut out = Vec::new();
1063 process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1064 assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1066
1067 let mut s = state_with_golden();
1069 if let Some(g) = s.goldens[GoldenVariant::Lucky as usize].as_mut() {
1070 g.life_ticks = 200;
1071 }
1072 s.green_coin = Some(crate::game::green_coin::GreenCoin {
1073 frac_x: 0.5,
1074 frac_y: 0.5,
1075 life_ticks: 50,
1076 });
1077 let c = empty_ctx(&s);
1078 let mut out = Vec::new();
1079 process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1080 assert!(matches!(out.as_slice(), [Action::CatchGreenCoin]));
1081 }
1082
1083 #[test]
1084 fn right_click_on_golden_also_catches() {
1085 let s = state_with_golden();
1087 let mut ui = UiState::new();
1088 let c = ctx(
1089 Rect::default(),
1090 rect(50, 12, 4, 2),
1091 rect(0, 0, 100, 30),
1092 Rect::default(),
1093 &[],
1094 &[],
1095 &[],
1096 false,
1097 &s,
1098 );
1099 let mut out = Vec::new();
1100 process_input_event(
1101 mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
1102 &mut ui,
1103 &c,
1104 &mut out,
1105 );
1106 assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1107 }
1108
1109 #[test]
1110 fn left_click_on_fingerer_row_buys_one() {
1111 let s = GameState::default();
1112 let mut ui = UiState::new();
1113 let rows = [(2_usize, rect(100, 5, 38, 3))];
1114 let c = ctx(
1115 Rect::default(),
1116 Rect::default(),
1117 Rect::default(),
1118 Rect::default(),
1119 &rows,
1120 &[],
1121 &[],
1122 false,
1123 &s,
1124 );
1125 let mut out = Vec::new();
1126 process_input_event(
1127 mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1128 &mut ui,
1129 &c,
1130 &mut out,
1131 );
1132 assert!(matches!(
1133 out.as_slice(),
1134 [Action::BuyFingerer {
1135 idx: 2,
1136 qty: BuyQty::One,
1137 }]
1138 ));
1139 }
1140
1141 #[test]
1142 fn right_click_on_fingerer_row_buys_max() {
1143 let s = GameState::default();
1145 let mut ui = UiState::new();
1146 let rows = [(2_usize, rect(100, 5, 38, 3))];
1147 let c = ctx(
1148 Rect::default(),
1149 Rect::default(),
1150 Rect::default(),
1151 Rect::default(),
1152 &rows,
1153 &[],
1154 &[],
1155 false,
1156 &s,
1157 );
1158 let mut out = Vec::new();
1159 process_input_event(
1160 mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1161 &mut ui,
1162 &c,
1163 &mut out,
1164 );
1165 assert!(matches!(
1166 out.as_slice(),
1167 [Action::BuyFingerer {
1168 qty: BuyQty::Max,
1169 ..
1170 }]
1171 ));
1172 }
1173
1174 #[test]
1175 fn shift_left_click_on_fingerer_row_buys_ten() {
1176 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 Rect::default(),
1182 Rect::default(),
1183 Rect::default(),
1184 &rows,
1185 &[],
1186 &[],
1187 false,
1188 &s,
1189 );
1190 let mut out = Vec::new();
1191 let mods = Modifiers {
1192 shift: true,
1193 ..Modifiers::default()
1194 };
1195 process_input_event(
1196 mouse_down(110, 6, MouseButton::Left, mods),
1197 &mut ui,
1198 &c,
1199 &mut out,
1200 );
1201 assert!(matches!(
1202 out.as_slice(),
1203 [Action::BuyFingerer {
1204 qty: BuyQty::Ten,
1205 ..
1206 }]
1207 ));
1208 }
1209
1210 #[test]
1213 fn dead_zone_left_click_inside_play_area_emits_misclick() {
1214 let s = GameState::default();
1216 let mut ui = UiState::new();
1217 let c = ctx(
1218 rect(40, 10, 20, 10), Rect::default(),
1220 rect(0, 0, 100, 30), Rect::default(),
1222 &[],
1223 &[],
1224 &[],
1225 false,
1226 &s,
1227 );
1228 let mut out = Vec::new();
1229 process_input_event(
1230 mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1231 &mut ui,
1232 &c,
1233 &mut out,
1234 );
1235 assert!(
1236 matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1237 "got {:?}",
1238 out
1239 );
1240 }
1241
1242 #[test]
1243 fn click_outside_play_area_does_not_misclick() {
1244 let s = GameState::default();
1247 let mut ui = UiState::new();
1248 let c = ctx(
1249 rect(40, 10, 20, 10),
1250 Rect::default(),
1251 rect(0, 0, 100, 30), Rect::default(),
1253 &[],
1254 &[],
1255 &[],
1256 false,
1257 &s,
1258 );
1259 let mut out = Vec::new();
1260 process_input_event(
1261 mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1262 &mut ui,
1263 &c,
1264 &mut out,
1265 );
1266 assert!(out.is_empty(), "got {:?}", out);
1267 }
1268
1269 #[test]
1270 fn right_click_in_dead_zone_is_silent() {
1271 let s = GameState::default();
1273 let mut ui = UiState::new();
1274 let c = ctx(
1275 rect(40, 10, 20, 10),
1276 Rect::default(),
1277 rect(0, 0, 100, 30),
1278 Rect::default(),
1279 &[],
1280 &[],
1281 &[],
1282 false,
1283 &s,
1284 );
1285 let mut out = Vec::new();
1286 process_input_event(
1287 mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1288 &mut ui,
1289 &c,
1290 &mut out,
1291 );
1292 assert!(out.is_empty());
1293 }
1294
1295 #[test]
1298 fn click_quit_hint_flips_running() {
1299 let s = GameState::default();
1300 let mut ui = UiState::new();
1301 let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1302 let c = ctx(
1303 Rect::default(),
1304 Rect::default(),
1305 rect(0, 0, 100, 30),
1306 Rect::default(),
1307 &[],
1308 &[],
1309 &hits,
1310 false,
1311 &s,
1312 );
1313 let mut out = Vec::new();
1314 process_input_event(
1315 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1316 &mut ui,
1317 &c,
1318 &mut out,
1319 );
1320 assert!(!ui.running);
1321 assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1322 }
1323
1324 #[test]
1325 fn click_open_mode_hint_toggles_mode() {
1326 let s = GameState::default();
1327 let mut ui = UiState::new();
1328 let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1329 let c = ctx(
1330 Rect::default(),
1331 Rect::default(),
1332 rect(0, 0, 100, 30),
1333 Rect::default(),
1334 &[],
1335 &[],
1336 &hits,
1337 false,
1338 &s,
1339 );
1340 let mut out = Vec::new();
1341 process_input_event(
1342 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1343 &mut ui,
1344 &c,
1345 &mut out,
1346 );
1347 assert_eq!(ui.mode, Mode::Stats);
1348 }
1349
1350 #[test]
1353 fn prestige_reset_rect_unavailable_does_not_reset() {
1354 let s = GameState::default(); let mut ui = UiState::new();
1360 let c = ctx(
1361 Rect::default(),
1362 Rect::default(),
1363 rect(0, 0, 100, 30),
1364 rect(40, 15, 30, 1), &[],
1366 &[],
1367 &[],
1368 false,
1369 &s,
1370 );
1371 let mut out = Vec::new();
1372 process_input_event(
1373 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1374 &mut ui,
1375 &c,
1376 &mut out,
1377 );
1378 assert!(
1379 !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1380 "no PrestigeReset when unavailable; got {:?}",
1381 out
1382 );
1383 assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1384 }
1385
1386 #[test]
1387 fn prestige_reset_rect_available_emits_action() {
1388 let s = state_with_prestige();
1389 let mut ui = UiState::new();
1390 ui.mode = Mode::Prestige;
1391 let c = ctx(
1392 Rect::default(),
1393 Rect::default(),
1394 rect(0, 0, 100, 30),
1395 rect(40, 15, 30, 1),
1396 &[],
1397 &[],
1398 &[],
1399 false,
1400 &s,
1401 );
1402 let mut out = Vec::new();
1403 process_input_event(
1404 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1405 &mut ui,
1406 &c,
1407 &mut out,
1408 );
1409 assert_eq!(out.len(), 1);
1410 assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1411 assert_eq!(
1412 ui.mode,
1413 Mode::Game,
1414 "panel auto-closes after prestige confirm",
1415 );
1416 }
1417
1418 #[test]
1421 fn wheel_down_inside_play_area_increases_zoom_idx() {
1422 let s = GameState::default();
1423 let mut ui = UiState::new();
1424 let c = ctx(
1425 Rect::default(),
1426 Rect::default(),
1427 rect(0, 0, 100, 30),
1428 Rect::default(),
1429 &[],
1430 &[],
1431 &[],
1432 false,
1433 &s,
1434 );
1435 let mut out = Vec::new();
1436 process_input_event(
1437 InputEvent::Wheel {
1438 col: 50,
1439 row: 15,
1440 delta: WheelDelta::Down,
1441 },
1442 &mut ui,
1443 &c,
1444 &mut out,
1445 );
1446 assert_eq!(ui.zoom_idx, 1);
1447 }
1448
1449 #[test]
1450 fn wheel_outside_play_area_does_not_zoom() {
1451 let s = GameState::default();
1453 let mut ui = UiState::new();
1454 let c = ctx(
1455 Rect::default(),
1456 Rect::default(),
1457 rect(0, 0, 100, 30),
1458 Rect::default(),
1459 &[],
1460 &[],
1461 &[],
1462 false,
1463 &s,
1464 );
1465 let mut out = Vec::new();
1466 process_input_event(
1467 InputEvent::Wheel {
1468 col: 120,
1469 row: 10,
1470 delta: WheelDelta::Down,
1471 },
1472 &mut ui,
1473 &c,
1474 &mut out,
1475 );
1476 assert_eq!(ui.zoom_idx, 0);
1477 }
1478
1479 #[test]
1480 fn wheel_up_saturates_at_zero() {
1481 let s = GameState::default();
1482 let mut ui = UiState::new();
1483 ui.zoom_idx = 0;
1484 let c = ctx(
1485 Rect::default(),
1486 Rect::default(),
1487 rect(0, 0, 100, 30),
1488 Rect::default(),
1489 &[],
1490 &[],
1491 &[],
1492 false,
1493 &s,
1494 );
1495 let mut out = Vec::new();
1496 process_input_event(
1497 InputEvent::Wheel {
1498 col: 50,
1499 row: 15,
1500 delta: WheelDelta::Up,
1501 },
1502 &mut ui,
1503 &c,
1504 &mut out,
1505 );
1506 assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1507 }
1508
1509 #[test]
1510 fn wheel_down_caps_at_last_level() {
1511 let s = GameState::default();
1512 let mut ui = UiState::new();
1513 let last = crate::ui::biscuit::level_count() - 1;
1514 ui.zoom_idx = last;
1515 let c = ctx(
1516 Rect::default(),
1517 Rect::default(),
1518 rect(0, 0, 100, 30),
1519 Rect::default(),
1520 &[],
1521 &[],
1522 &[],
1523 false,
1524 &s,
1525 );
1526 let mut out = Vec::new();
1527 process_input_event(
1528 InputEvent::Wheel {
1529 col: 50,
1530 row: 15,
1531 delta: WheelDelta::Down,
1532 },
1533 &mut ui,
1534 &c,
1535 &mut out,
1536 );
1537 assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1538 }
1539
1540 #[test]
1543 fn mouse_moved_updates_last_position() {
1544 let s = GameState::default();
1545 let mut ui = UiState::new();
1546 let mut out = Vec::new();
1547 process_input_event(
1548 InputEvent::MouseMoved { col: 42, row: 7 },
1549 &mut ui,
1550 &empty_ctx(&s),
1551 &mut out,
1552 );
1553 assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1554 assert!(out.is_empty());
1555 }
1556
1557 #[test]
1558 fn mouse_down_updates_last_position_before_dispatch() {
1559 let s = GameState::default();
1563 let mut ui = UiState::new();
1564 let c = ctx(
1565 rect(40, 10, 20, 10),
1566 Rect::default(),
1567 rect(0, 0, 100, 30),
1568 Rect::default(),
1569 &[],
1570 &[],
1571 &[],
1572 false,
1573 &s,
1574 );
1575 let mut out = Vec::new();
1576 process_input_event(
1577 mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1578 &mut ui,
1579 &c,
1580 &mut out,
1581 );
1582 assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1583 }
1584}