use ratatui::layout::Rect;
use crate::game::state::GameState;
use crate::sim::{Action, BuyQty};
use crate::ui::{HelpAction, Mode};
#[derive(Clone, Debug)]
pub enum InputEvent {
KeyPress { code: KeyCode, mods: Modifiers },
MouseDown {
col: u16,
row: u16,
button: MouseButton,
mods: Modifiers,
},
MouseMoved { col: u16, row: u16 },
Wheel {
col: u16,
row: u16,
delta: WheelDelta,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyCode {
Char(char),
Esc,
F(u8),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
Left,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct Modifiers {
pub shift: bool,
pub alt: bool,
pub ctrl: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WheelDelta {
Up,
Down,
}
pub struct UiState {
pub mode: Mode,
pub zoom_idx: usize,
pub running: bool,
pub last_mouse_pos: Option<(u16, u16)>,
}
impl UiState {
pub fn new() -> Self {
Self {
mode: Mode::Game,
zoom_idx: 0,
running: true,
last_mouse_pos: None,
}
}
}
impl Default for UiState {
fn default() -> Self {
Self::new()
}
}
pub struct InputContext<'a> {
pub fingerer_rows: &'a [(usize, Rect)],
pub upgrade_rows: &'a [(usize, Rect)],
pub help_hits: &'a [(HelpAction, Rect)],
pub biscuit_rect: Rect,
pub biscuit_focal: (u16, u16),
pub powerup_rects: &'a [(u64, Rect)],
pub play_area: Rect,
pub prestige_reset_rect: Rect,
pub debug: bool,
pub current: &'a GameState,
}
impl<'a> InputContext<'a> {
pub fn from_layout(
layout: &'a crate::ui::DrawOutput,
current: &'a GameState,
debug: bool,
) -> Self {
InputContext {
fingerer_rows: &layout.fingerer_rows,
upgrade_rows: &layout.upgrade_rows,
help_hits: &layout.help_hits,
biscuit_rect: layout.biscuit_rect,
biscuit_focal: layout.biscuit_focal,
powerup_rects: &layout.powerup_rects,
play_area: layout.play_area,
prestige_reset_rect: layout.prestige_reset_rect,
debug,
current,
}
}
}
pub fn process_input_event(
ev: InputEvent,
ui: &mut UiState,
ctx: &InputContext,
out: &mut Vec<Action>,
) {
match ev {
InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
InputEvent::MouseDown {
col,
row,
button,
mods,
} => {
ui.last_mouse_pos = Some((col, row));
if try_help_click(col, row, ui, ctx, out) {
return;
}
handle_click(col, row, button, mods, ui, ctx, out);
}
InputEvent::MouseMoved { col, row } => {
ui.last_mouse_pos = Some((col, row));
}
InputEvent::Wheel { col, row, delta } => {
if !in_play_area(col, row, ctx.play_area) {
return;
}
match delta {
WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
WheelDelta::Down => {
ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
}
}
}
}
}
fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
if play_area.width == 0 || play_area.height == 0 {
return true;
}
col >= play_area.x
&& col < play_area.x + play_area.width
&& row >= play_area.y
&& row < play_area.y + play_area.height
}
fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
rect.width > 0
&& rect.height > 0
&& col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
if let Some(p) = ctx.current.powerups.iter().min_by_key(|p| p.life_ticks) {
out.push(Action::CatchPowerup(p.spawn_id));
}
}
fn click_buy_qty(mods: Modifiers) -> BuyQty {
if mods.alt || mods.ctrl {
BuyQty::Max
} else if mods.shift {
BuyQty::Ten
} else {
BuyQty::One
}
}
fn try_help_click(
col: u16,
row: u16,
ui: &mut UiState,
ctx: &InputContext,
out: &mut Vec<Action>,
) -> bool {
if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
out.push(Action::PrestigeReset);
ui.mode = Mode::Game;
return true;
}
for &(action, rect) in ctx.help_hits {
if !rect_contains(rect, col, row) {
continue;
}
match action {
HelpAction::OpenMode(target) => {
ui.mode = if ui.mode == target {
Mode::Game
} else {
target
};
}
HelpAction::GrabGolden => {
push_grab_most_urgent(ctx, out);
}
HelpAction::PrestigeReset => {
if ctx.current.prestige_available() > 0 {
out.push(Action::PrestigeReset);
ui.mode = Mode::Game;
}
}
HelpAction::Quit => {
ui.running = false;
}
}
return true;
}
false
}
fn handle_click(
col: u16,
row: u16,
button: MouseButton,
mods: Modifiers,
ui: &UiState,
ctx: &InputContext,
out: &mut Vec<Action>,
) {
for &(id, rect) in ctx.powerup_rects {
if rect_contains(rect, col, row) {
out.push(Action::CatchPowerup(id));
return;
}
}
if rect_contains(ctx.biscuit_rect, col, row) {
if button == MouseButton::Left {
out.push(Action::Click { col, row });
}
return;
}
if ui.mode == Mode::Game {
for &(idx, r) in ctx.fingerer_rows {
if rect_contains(r, col, row) {
let qty = if button == MouseButton::Right {
BuyQty::Max
} else {
click_buy_qty(mods)
};
out.push(Action::BuyFingerer { idx, qty });
return;
}
}
}
if ui.mode == Mode::Upgrades {
for &(idx, r) in ctx.upgrade_rows {
if rect_contains(r, col, row) {
out.push(Action::BuyUpgrade(idx));
return;
}
}
}
if button != MouseButton::Left {
return;
}
if !rect_contains(ctx.play_area, col, row) {
return;
}
if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
return;
}
out.push(Action::Misclick { col, row });
}
fn handle_key(
code: KeyCode,
mods: Modifiers,
ui: &mut UiState,
ctx: &InputContext,
out: &mut Vec<Action>,
) {
match code {
KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
KeyCode::Esc => match ui.mode {
Mode::Game => {}
_ => ui.mode = Mode::Game,
},
KeyCode::Char('s') | KeyCode::Char('S') => {
ui.mode = if matches!(ui.mode, Mode::Stats) {
Mode::Game
} else {
Mode::Stats
};
}
KeyCode::Char('a') | KeyCode::Char('A') => {
ui.mode = if matches!(ui.mode, Mode::Achievements) {
Mode::Game
} else {
Mode::Achievements
};
}
KeyCode::Char('u') | KeyCode::Char('U') => {
ui.mode = if matches!(ui.mode, Mode::Upgrades) {
Mode::Game
} else {
Mode::Upgrades
};
}
KeyCode::Char('g') | KeyCode::Char('G') => {
push_grab_most_urgent(ctx, out);
}
KeyCode::F(8) if ctx.debug => {
out.push(Action::DevForcePowerup(
crate::game::powerup::PowerupKind::Lucky,
));
}
KeyCode::F(2) if ctx.debug => {
out.push(Action::DevForcePowerup(
crate::game::powerup::PowerupKind::Frenzy,
));
}
KeyCode::F(3) if ctx.debug => {
out.push(Action::DevForcePowerup(
crate::game::powerup::PowerupKind::Buff,
));
}
KeyCode::F(4) if ctx.debug => {
out.push(Action::DevAddCuques(1_000_000.0));
}
KeyCode::F(5) if ctx.debug => {
out.push(Action::DevForcePowerup(
crate::game::powerup::PowerupKind::GreenCoin,
));
}
KeyCode::Char('p') | KeyCode::Char('P') => {
ui.mode = if matches!(ui.mode, Mode::Prestige) {
Mode::Game
} else {
Mode::Prestige
};
}
KeyCode::Char('r') | KeyCode::Char('R')
if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
{
out.push(Action::PrestigeReset);
ui.mode = Mode::Game;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
}
KeyCode::Char('-') | KeyCode::Char('_') => {
ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
}
KeyCode::Char(' ') => {
out.push(Action::ClickCenter);
}
KeyCode::Char(c) => {
if let Some((slot, shifted_sym)) = digit_slot(c) {
let buy_10 = shifted_sym || mods.shift;
let buy_max = mods.alt || mods.ctrl;
match ui.mode {
Mode::Game => {
if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
let qty = if buy_max {
BuyQty::Max
} else if buy_10 {
BuyQty::Ten
} else {
BuyQty::One
};
out.push(Action::BuyFingerer { idx: fid, qty });
}
}
Mode::Upgrades => {
if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
out.push(Action::BuyUpgrade(u_idx));
}
}
_ => {}
}
}
}
_ => {}
}
}
fn digit_slot(c: char) -> Option<(usize, bool)> {
match c {
'1' => Some((0, false)),
'2' => Some((1, false)),
'3' => Some((2, false)),
'4' => Some((3, false)),
'5' => Some((4, false)),
'6' => Some((5, false)),
'7' => Some((6, false)),
'8' => Some((7, false)),
'9' => Some((8, false)),
'0' => Some((9, false)),
'!' => Some((0, true)),
'@' => Some((1, true)),
'#' => Some((2, true)),
'$' => Some((3, true)),
'%' => Some((4, true)),
'^' => Some((5, true)),
'&' => Some((6, true)),
'*' => Some((7, true)),
'(' => Some((8, true)),
')' => Some((9, true)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::powerup::{Powerup, PowerupKind};
use crate::sim::{Action, BuyQty};
use ratatui::layout::Rect;
use std::mem::discriminant;
fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
Rect::new(x, y, w, h)
}
#[allow(clippy::too_many_arguments)]
fn ctx<'a>(
biscuit: Rect,
powerup_rects: &'a [(u64, Rect)],
play_area: Rect,
prestige_reset_rect: Rect,
fingerer_rows: &'a [(usize, Rect)],
upgrade_rows: &'a [(usize, Rect)],
help_hits: &'a [(HelpAction, Rect)],
debug: bool,
current: &'a GameState,
) -> InputContext<'a> {
InputContext {
fingerer_rows,
upgrade_rows,
help_hits,
biscuit_rect: biscuit,
biscuit_focal: (0, 0),
powerup_rects,
play_area,
prestige_reset_rect,
debug,
current,
}
}
fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
&[],
&[],
&[],
false,
state,
)
}
fn state_with_lucky() -> (GameState, u64) {
let mut s = GameState::default();
let id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::Lucky,
spawn_id: id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: PowerupKind::Lucky.lifetime_ticks(),
});
(s, id)
}
fn state_with_prestige() -> GameState {
GameState {
lifetime_cuques: 4_000_000_000.0,
..GameState::default()
}
}
fn key(code: KeyCode) -> InputEvent {
InputEvent::KeyPress {
code,
mods: Modifiers::default(),
}
}
fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
InputEvent::KeyPress {
code,
mods: Modifiers { shift, alt, ctrl },
}
}
fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
InputEvent::MouseDown {
col,
row,
button,
mods,
}
}
#[test]
fn q_key_flips_running_off() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
assert!(!ui.running);
assert!(out.is_empty());
}
#[test]
fn esc_from_game_is_noop() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
assert!(ui.running);
assert_eq!(ui.mode, Mode::Game);
assert!(out.is_empty());
}
#[test]
fn esc_from_stats_returns_to_game() {
let s = GameState::default();
let mut ui = UiState::new();
ui.mode = Mode::Stats;
let mut out = Vec::new();
process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
assert_eq!(ui.mode, Mode::Game);
assert!(out.is_empty());
}
#[test]
fn s_key_toggles_stats() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
assert_eq!(ui.mode, Mode::Stats);
process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
assert_eq!(ui.mode, Mode::Game);
}
#[test]
fn space_emits_click_center() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
assert_eq!(out.len(), 1);
assert!(matches!(out[0], Action::ClickCenter));
}
#[test]
fn g_with_no_powerup_is_silent() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
assert!(out.is_empty());
}
#[test]
fn g_with_powerup_emits_catch() {
let (s, id) = state_with_lucky();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
"expected CatchPowerup({id}), got {out:?}"
);
}
#[test]
fn fkeys_gated_by_debug() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
let c = empty_ctx(&s);
process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
assert!(out.is_empty(), "F-keys must be silent when debug=false");
}
#[test]
fn fkeys_active_when_debug() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
&[],
&[],
&[],
true, &s,
);
let mut out = Vec::new();
process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
assert!(matches!(
out[0],
Action::DevForcePowerup(PowerupKind::Lucky)
));
assert!(matches!(out[1], Action::DevAddCuques(_)));
assert!(matches!(
out[2],
Action::DevForcePowerup(PowerupKind::GreenCoin)
));
}
fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
rows,
&[],
&[],
false,
state,
)
}
#[test]
fn digit_1_buys_one() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(0_usize, rect(0, 0, 1, 1))];
let mut out = Vec::new();
process_input_event(
key(KeyCode::Char('1')),
&mut ui,
&fingerer_row_ctx(&s, &rows),
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
idx: 0,
qty: BuyQty::One,
}]
));
}
#[test]
fn shifted_digit_symbol_buys_ten() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(0_usize, rect(0, 0, 1, 1))];
let mut out = Vec::new();
process_input_event(
key(KeyCode::Char('!')),
&mut ui,
&fingerer_row_ctx(&s, &rows),
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
idx: 0,
qty: BuyQty::Ten,
}]
));
}
#[test]
fn shift_modifier_on_digit_buys_ten() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(0_usize, rect(0, 0, 1, 1))];
let mut out = Vec::new();
process_input_event(
key_with(KeyCode::Char('1'), true, false, false),
&mut ui,
&fingerer_row_ctx(&s, &rows),
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
qty: BuyQty::Ten,
..
}]
));
}
#[test]
fn alt_or_ctrl_modifier_on_digit_buys_max() {
let s = GameState::default();
let rows = [(0_usize, rect(0, 0, 1, 1))];
for (alt, ctrl) in [(true, false), (false, true)] {
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(
key_with(KeyCode::Char('1'), false, alt, ctrl),
&mut ui,
&fingerer_row_ctx(&s, &rows),
&mut out,
);
assert!(
matches!(
out.as_slice(),
[Action::BuyFingerer {
qty: BuyQty::Max,
..
}]
),
"alt={alt} ctrl={ctrl} should buy max",
);
}
}
#[test]
fn digit_with_no_visible_row_is_silent() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(
key(KeyCode::Char('1')),
&mut ui,
&fingerer_row_ctx(&s, &[]),
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn left_click_on_biscuit_emits_click() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(10, 5, 30, 20),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
"got {:?}",
out
);
}
#[test]
fn right_click_on_biscuit_is_noop() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(10, 5, 30, 20),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(out.is_empty(), "got {:?}", out);
}
#[test]
fn left_click_on_powerup_emits_catch() {
let (s, id) = state_with_lucky();
let mut ui = UiState::new();
let powerup_rects = [(id, rect(50, 12, 4, 2))];
let c = ctx(
Rect::default(),
&powerup_rects,
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
"got {out:?}"
);
}
#[test]
fn left_click_on_powerup_only_catches_one_under_cursor() {
let mut s = GameState::default();
let lucky_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::Lucky,
spawn_id: lucky_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 100,
});
let green_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::GreenCoin,
spawn_id: green_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 100,
});
let mut ui = UiState::new();
let powerup_rects = [
(lucky_id, rect(50, 12, 5, 3)),
(green_id, rect(70, 12, 5, 3)),
];
let c = ctx(
Rect::default(),
&powerup_rects,
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
"got {out:?}"
);
}
#[test]
fn g_with_two_kinds_picks_lower_life_ticks() {
let mut s = GameState::default();
let lucky_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::Lucky,
spawn_id: lucky_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 50,
});
let green_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::GreenCoin,
spawn_id: green_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 200,
});
let c = empty_ctx(&s);
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == lucky_id),
"lower-life Lucky should win, got {out:?}"
);
let mut s = GameState::default();
let lucky_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::Lucky,
spawn_id: lucky_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 200,
});
let green_id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::GreenCoin,
spawn_id: green_id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 50,
});
let c = empty_ctx(&s);
let mut out = Vec::new();
process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
"lower-life GreenCoin should win, got {out:?}"
);
}
#[test]
fn right_click_on_powerup_also_catches() {
let (s, id) = state_with_lucky();
let mut ui = UiState::new();
let powerup_rects = [(id, rect(50, 12, 4, 2))];
let c = ctx(
Rect::default(),
&powerup_rects,
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
"got {out:?}"
);
}
#[test]
fn left_click_on_fingerer_row_buys_one() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(2_usize, rect(100, 5, 38, 3))];
let c = ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
&rows,
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
idx: 2,
qty: BuyQty::One,
}]
));
}
#[test]
fn right_click_on_fingerer_row_buys_max() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(2_usize, rect(100, 5, 38, 3))];
let c = ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
&rows,
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
qty: BuyQty::Max,
..
}]
));
}
#[test]
fn shift_left_click_on_fingerer_row_buys_ten() {
let s = GameState::default();
let mut ui = UiState::new();
let rows = [(2_usize, rect(100, 5, 38, 3))];
let c = ctx(
Rect::default(),
&[],
Rect::default(),
Rect::default(),
&rows,
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
let mods = Modifiers {
shift: true,
..Modifiers::default()
};
process_input_event(
mouse_down(110, 6, MouseButton::Left, mods),
&mut ui,
&c,
&mut out,
);
assert!(matches!(
out.as_slice(),
[Action::BuyFingerer {
qty: BuyQty::Ten,
..
}]
));
}
#[test]
fn dead_zone_left_click_inside_play_area_emits_misclick() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(40, 10, 20, 10), &[],
rect(0, 0, 100, 30), Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
"got {:?}",
out
);
}
#[test]
fn click_outside_play_area_does_not_misclick() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(40, 10, 20, 10),
&[],
rect(0, 0, 100, 30), Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(out.is_empty(), "got {:?}", out);
}
#[test]
fn right_click_in_dead_zone_is_silent() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(40, 10, 20, 10),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn click_quit_hint_flips_running() {
let s = GameState::default();
let mut ui = UiState::new();
let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&hits,
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(!ui.running);
assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
}
#[test]
fn click_open_mode_hint_toggles_mode() {
let s = GameState::default();
let mut ui = UiState::new();
let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&hits,
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.mode, Mode::Stats);
}
#[test]
fn prestige_reset_rect_unavailable_does_not_reset() {
let s = GameState::default(); let mut ui = UiState::new();
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
rect(40, 15, 30, 1), &[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert!(
!out.iter().any(|a| matches!(a, Action::PrestigeReset)),
"no PrestigeReset when unavailable; got {:?}",
out
);
assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
}
#[test]
fn prestige_reset_rect_available_emits_action() {
let s = state_with_prestige();
let mut ui = UiState::new();
ui.mode = Mode::Prestige;
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
rect(40, 15, 30, 1),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert_eq!(out.len(), 1);
assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
assert_eq!(
ui.mode,
Mode::Game,
"panel auto-closes after prestige confirm",
);
}
#[test]
fn wheel_down_inside_play_area_increases_zoom_idx() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
InputEvent::Wheel {
col: 50,
row: 15,
delta: WheelDelta::Down,
},
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.zoom_idx, 1);
}
#[test]
fn wheel_outside_play_area_does_not_zoom() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
InputEvent::Wheel {
col: 120,
row: 10,
delta: WheelDelta::Down,
},
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.zoom_idx, 0);
}
#[test]
fn wheel_up_saturates_at_zero() {
let s = GameState::default();
let mut ui = UiState::new();
ui.zoom_idx = 0;
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
InputEvent::Wheel {
col: 50,
row: 15,
delta: WheelDelta::Up,
},
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
}
#[test]
fn wheel_down_caps_at_last_level() {
let s = GameState::default();
let mut ui = UiState::new();
let last = crate::ui::biscuit::level_count() - 1;
ui.zoom_idx = last;
let c = ctx(
Rect::default(),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
InputEvent::Wheel {
col: 50,
row: 15,
delta: WheelDelta::Down,
},
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.zoom_idx, last, "min() cap at last level");
}
#[test]
fn mouse_moved_updates_last_position() {
let s = GameState::default();
let mut ui = UiState::new();
let mut out = Vec::new();
process_input_event(
InputEvent::MouseMoved { col: 42, row: 7 },
&mut ui,
&empty_ctx(&s),
&mut out,
);
assert_eq!(ui.last_mouse_pos, Some((42, 7)));
assert!(out.is_empty());
}
#[test]
fn mouse_down_updates_last_position_before_dispatch() {
let s = GameState::default();
let mut ui = UiState::new();
let c = ctx(
rect(40, 10, 20, 10),
&[],
rect(0, 0, 100, 30),
Rect::default(),
&[],
&[],
&[],
false,
&s,
);
let mut out = Vec::new();
process_input_event(
mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
&mut ui,
&c,
&mut out,
);
assert_eq!(ui.last_mouse_pos, Some((7, 7)));
}
}