use rand::RngExt;
use ratatui::layout::Rect;
use crate::game::powerup::{self, Powerup, PowerupKind};
use crate::game::state::{GameState, TICK_DT};
use crate::game::tree::coord::TreeCoord;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BuyQty {
One,
Ten,
Max,
}
#[derive(Clone, Debug)]
pub enum Action {
Click {
col: u16,
row: u16,
},
ClickCenter,
CatchPowerup(u64),
BuyFingerer {
idx: usize,
qty: BuyQty,
},
TreeBuy(TreeCoord),
TreeRefund(TreeCoord),
TreeFocus(TreeCoord),
PrestigeReset,
UpdateGeometry {
biscuit: Rect,
powerups_paused: bool,
},
DevAddCuques(f64),
DevForcePowerup(PowerupKind),
Misclick {
col: u16,
row: u16,
},
}
#[derive(Clone, Copy, Default)]
pub struct SimGeometry {
pub biscuit: Rect,
pub powerups_paused: bool,
}
pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
match action {
Action::Click { col, row } => {
let r = geom.biscuit;
if r.width > 0
&& col >= r.x
&& col < r.x + r.width
&& row >= r.y
&& row < r.y + r.height
{
state.click((col, row), r);
}
}
Action::ClickCenter => {
let r = geom.biscuit;
if r.width > 0 && r.height > 0 {
state.click((r.x + r.width / 2, r.y + r.height / 2), r);
}
state.space_pressed_this_tick = true;
}
Action::CatchPowerup(id) => {
state.catch_powerup(id);
}
Action::BuyFingerer { idx, qty } => match qty {
BuyQty::One => {
state.buy(idx);
}
BuyQty::Ten => {
state.buy_n(idx, 10);
}
BuyQty::Max => {
state.buy_max(idx);
}
},
Action::TreeBuy(lot) => {
state.buy_tree_node(lot);
}
Action::TreeRefund(lot) => {
let _ = state.refund_tree_node(lot);
}
Action::TreeFocus(lot) => {
state.tree.cursor = lot;
}
Action::PrestigeReset => {
state.prestige_reset();
}
Action::UpdateGeometry {
biscuit,
powerups_paused,
} => {
*geom = SimGeometry {
biscuit,
powerups_paused,
};
}
Action::DevAddCuques(n) => {
state.dev_add_cuques(n);
}
Action::DevForcePowerup(kind) => {
force_spawn_powerup(state, geom, kind);
}
Action::Misclick { col, row } => {
state.spawn_misclick(col, row);
}
}
}
pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
state.tick();
if !geom.powerups_paused {
state.tick_powerups();
maybe_spawn_powerups(state, geom);
}
maybe_spawn_auto_particle(state, geom);
maybe_idle_clench(state);
}
fn maybe_idle_clench(state: &mut GameState) {
if state.clench_ticks > 0 {
return;
}
if rand::rng().random::<f64>() < 1.0 / 900.0 {
state.trigger_clench();
}
}
fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
let fps = state.fps();
if fps.is_zero() || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
return;
}
let fps_f = fps.to_f64();
let target_rate = fps_f.sqrt().clamp(0.5, 8.0);
let prob = target_rate * TICK_DT;
let mut rng = rand::rng();
if rng.random::<f64>() >= prob {
return;
}
let frac_x = rng.random_range(0.05_f32..=0.95);
let frac_y = rng.random_range(0.10_f32..=0.95);
state.spawn_auto_particle(frac_x, frac_y);
}
const SPAWN_INSET_X: f32 = 0.08;
const SPAWN_INSET_Y: f32 = 0.10;
const POWERUP_MIN_CELL_DIST: f32 = 4.0;
const BISCUIT_CELL_ASPECT: f32 = 2.0;
const POWERUP_DISPERSION_TRIES: u32 = 8;
fn pick_dispersed_frac(existing: &[Powerup], biscuit_cells: (u16, u16)) -> (f32, f32) {
let (bw, bh) = biscuit_cells;
let bw = bw.max(1) as f32;
let bh = bh.max(1) as f32;
let min_sq = POWERUP_MIN_CELL_DIST * POWERUP_MIN_CELL_DIST;
let mut rng = rand::rng();
for _ in 0..POWERUP_DISPERSION_TRIES {
let fx = rng.random_range(SPAWN_INSET_X..=(1.0 - SPAWN_INSET_X));
let fy = rng.random_range(SPAWN_INSET_Y..=(1.0 - SPAWN_INSET_Y));
let too_close = existing.iter().any(|p| {
let dx_cells = (p.frac_x - fx) * bw;
let dy_cells = (p.frac_y - fy) * bh * BISCUIT_CELL_ASPECT;
dx_cells * dx_cells + dy_cells * dy_cells < min_sq
});
if !too_close {
return (fx, fy);
}
}
let fx = rng.random_range(SPAWN_INSET_X..=(1.0 - SPAWN_INSET_X));
let fy = rng.random_range(SPAWN_INSET_Y..=(1.0 - SPAWN_INSET_Y));
(fx, fy)
}
fn maybe_spawn_powerups(state: &mut GameState, geom: &SimGeometry) {
if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
return;
}
let cells = (geom.biscuit.width, geom.biscuit.height);
for kind in PowerupKind::ALL {
let i = kind as usize;
if state.powerup_cooldowns[i] > 0 {
continue;
}
spawn_powerup(state, kind, cells);
let mul = state
.tree_aggregate
.powerup_spawn_mul
.get(i)
.copied()
.unwrap_or(1.0);
let base = powerup::next_cooldown(kind) as f64;
let cooldown = if mul > 0.0 { base / mul } else { base };
state.powerup_cooldowns[i] = cooldown.max(1.0) as u32;
}
}
fn spawn_powerup(state: &mut GameState, kind: PowerupKind, biscuit_cells: (u16, u16)) {
let life_ticks = kind.lifetime_ticks();
debug_assert!(life_ticks > 0, "PowerupKind::lifetime_ticks must be > 0");
let (frac_x, frac_y) = pick_dispersed_frac(&state.powerups, biscuit_cells);
let spawn_id = state.mint_spawn_id();
state.powerups.push(Powerup {
kind,
spawn_id,
frac_x,
frac_y,
life_ticks,
});
}
fn force_spawn_powerup(state: &mut GameState, geom: &SimGeometry, kind: PowerupKind) {
if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
return;
}
spawn_powerup(state, kind, (geom.biscuit.width, geom.biscuit.height));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::state::GameState;
use ratatui::layout::Rect;
fn geom_with_biscuit() -> SimGeometry {
SimGeometry {
biscuit: Rect::new(0, 0, 40, 20),
powerups_paused: false,
}
}
#[test]
fn force_spawn_pushes_to_vec_uncapped() {
let mut state = GameState::default();
let geom = geom_with_biscuit();
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
let lucky_count = state
.powerups
.iter()
.filter(|p| p.kind == PowerupKind::Lucky)
.count();
assert_eq!(lucky_count, 2);
let ids: Vec<u64> = state.powerups.iter().map(|p| p.spawn_id).collect();
assert_ne!(ids[0], ids[1]);
}
#[test]
fn force_spawn_mixes_kinds_freely() {
let mut state = GameState::default();
let geom = geom_with_biscuit();
for kind in PowerupKind::ALL {
force_spawn_powerup(&mut state, &geom, kind);
}
assert_eq!(state.powerups.len(), 4);
for kind in PowerupKind::ALL {
assert!(state.powerups.iter().any(|p| p.kind == kind));
}
}
#[test]
fn spawn_dispersion_avoids_exact_overlap() {
let mut state = GameState::default();
let geom = geom_with_biscuit();
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
let a = &state.powerups[0];
let b = &state.powerups[1];
let dx = a.frac_x - b.frac_x;
let dy = a.frac_y - b.frac_y;
let dist = (dx * dx + dy * dy).sqrt();
assert!(dist > 0.0, "two spawns landed at the exact same point");
}
#[test]
fn spawn_dispersion_keeps_cell_distance_in_typical_layout() {
let mut clear = 0;
let trials = 1000;
let geom = SimGeometry {
biscuit: Rect::new(0, 0, 60, 30),
powerups_paused: false,
};
for _ in 0..trials {
let mut state = GameState::default();
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
let a = &state.powerups[0];
let b = &state.powerups[1];
let dx_cells = (a.frac_x - b.frac_x) * geom.biscuit.width as f32;
let dy_cells = (a.frac_y - b.frac_y) * geom.biscuit.height as f32 * BISCUIT_CELL_ASPECT;
let cell_dist = (dx_cells * dx_cells + dy_cells * dy_cells).sqrt();
if cell_dist >= POWERUP_MIN_CELL_DIST {
clear += 1;
}
}
let ratio = clear as f32 / trials as f32;
assert!(
ratio > 0.90,
"expected ≥90% of pair spawns to clear cell distance; got {clear}/{trials} = {ratio}"
);
}
#[test]
fn spawn_dispersion_handles_tiny_biscuit_without_panic() {
let mut state = GameState::default();
let geom = SimGeometry {
biscuit: Rect::new(0, 0, 16, 8),
powerups_paused: false,
};
force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
force_spawn_powerup(&mut state, &geom, PowerupKind::Frenzy);
assert_eq!(state.powerups.len(), 2);
}
}