Skip to main content

cuqueclicker_lib/
sim.rs

1//! Platform-agnostic simulation core.
2//!
3//! Owns the [`Action`] / [`BuyQty`] types (the input router produces them;
4//! [`apply_action`] is the only thing that interprets them) and the per-tick
5//! `state.tick()` + ambient spawn helpers.
6//!
7//! What lives **outside** this module:
8//! - the threaded sim loop on native (`app.rs::sim_loop`), which wraps
9//!   [`sim_tick`] + [`apply_action`] with `mpsc::recv_timeout`, save
10//!   scheduling via the [`Persistence`](crate::platform::Persistence) impl,
11//!   and the demo-recorder driver.
12//! - the requestAnimationFrame-driven loop on web (added when the wasm
13//!   port lands), which calls the same [`sim_tick`] + [`apply_action`]
14//!   single-threaded.
15//!
16//! The split is: this module is cross-platform; threading + I/O scheduling
17//! around it isn't. See tracking issue #13 for rationale.
18
19use rand::RngExt;
20use ratatui::layout::Rect;
21
22use crate::game::powerup::{self, Powerup, PowerupKind};
23use crate::game::state::{GameState, TICK_DT};
24
25/// Buy quantity for a fingerer purchase action. Modifier-key meaning is
26/// translated to this in the input router; sim only consumes the resolved
27/// value so the modifier mapping can change without touching tick logic.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum BuyQty {
30    One,
31    Ten,
32    Max,
33}
34
35/// Commands the input router produces and the sim consumes. The sim is
36/// the sole authority on [`GameState`] mutation — input handling translates
37/// raw events (key/mouse/wheel) into these and feeds them through.
38#[derive(Clone, Debug)]
39pub enum Action {
40    Click {
41        col: u16,
42        row: u16,
43    },
44    ClickCenter,
45    /// Catch the on-screen powerup with the given `spawn_id`. The id is
46    /// minted at spawn time on `GameState::next_spawn_id`; click hit-test
47    /// and the `g` hotkey both reference instances by id, never by Vec
48    /// index, so `swap_remove` on catch is safe even with multiple
49    /// in-flight events between frames.
50    CatchPowerup(u64),
51    BuyFingerer {
52        idx: usize,
53        qty: BuyQty,
54    },
55    BuyUpgrade(usize),
56    PrestigeReset,
57    /// Latest render-computed biscuit geometry, so the sim can place
58    /// powerups and auto-particles inside the current layout. Powerup
59    /// rects live on the input/render side (only the click handler reads
60    /// them).
61    UpdateGeometry {
62        biscuit: Rect,
63    },
64    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
65    /// the sim trusts whatever arrives.
66    DevAddCuques(f64),
67    /// Force-spawn a powerup of the given kind. Pushes a fresh entry onto
68    /// `state.powerups` — pressing the same F-key twice now produces two
69    /// of the same kind on screen.
70    DevForcePowerup(PowerupKind),
71    /// J10: a click that didn't hit anything actionable. Sim spawns a
72    /// short-lived "·" misclick particle at the screen point so dead-zone
73    /// clicks visibly register.
74    Misclick {
75        col: u16,
76        row: u16,
77    },
78}
79
80/// Geometry the sim needs to interpret screen-space events. Updated on
81/// every render via [`Action::UpdateGeometry`].
82#[derive(Clone, Copy, Default)]
83pub struct SimGeometry {
84    pub biscuit: Rect,
85}
86
87/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
88/// no time, no threading. Called from both the native sim thread (on
89/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
90pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
91    match action {
92        Action::Click { col, row } => {
93            let r = geom.biscuit;
94            if r.width > 0
95                && col >= r.x
96                && col < r.x + r.width
97                && row >= r.y
98                && row < r.y + r.height
99            {
100                state.click((col, row), r);
101            }
102        }
103        Action::ClickCenter => {
104            let r = geom.biscuit;
105            if r.width > 0 && r.height > 0 {
106                state.click((r.x + r.width / 2, r.y + r.height / 2), r);
107            }
108            // Mark this tick as "saw a spacebar press." `tick()` reads the
109            // flag, advances the held-streak counter, and clears it. A
110            // single tap → 1 tick of streak → resets immediately. A held
111            // key (terminal repeat) → streak climbs over time.
112            state.space_pressed_this_tick = true;
113        }
114        Action::CatchPowerup(id) => {
115            state.catch_powerup(id);
116        }
117        Action::BuyFingerer { idx, qty } => match qty {
118            BuyQty::One => {
119                state.buy(idx);
120            }
121            BuyQty::Ten => {
122                state.buy_n(idx, 10);
123            }
124            BuyQty::Max => {
125                state.buy_max(idx);
126            }
127        },
128        Action::BuyUpgrade(idx) => {
129            state.buy_upgrade(idx);
130        }
131        Action::PrestigeReset => {
132            state.prestige_reset();
133        }
134        Action::UpdateGeometry { biscuit } => {
135            *geom = SimGeometry { biscuit };
136        }
137        Action::DevAddCuques(n) => {
138            state.dev_add_cuques(n);
139        }
140        Action::DevForcePowerup(kind) => {
141            force_spawn_powerup(state, geom, kind);
142        }
143        Action::Misclick { col, row } => {
144            state.spawn_misclick(col, row);
145        }
146    }
147}
148
149/// Run the platform-agnostic body of one sim tick: state updates + ambient
150/// spawn helpers. Save scheduling and demo-driver autopilot are the
151/// **caller's** concern (they live in `app.rs::sim_loop` on native).
152pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
153    state.tick();
154    state.tick_powerups();
155    maybe_spawn_powerups(state, geom);
156    maybe_spawn_auto_particle(state, geom);
157    maybe_idle_clench(state);
158}
159
160fn maybe_idle_clench(state: &mut GameState) {
161    if state.clench_ticks > 0 {
162        return;
163    }
164    // ~1 per 45s average at 20Hz
165    if rand::rng().random::<f64>() < 1.0 / 900.0 {
166        state.trigger_clench();
167    }
168}
169
170fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
171    let fps = state.fps();
172    if fps <= 0.0 || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
173        return;
174    }
175    let target_rate = fps.sqrt().clamp(0.5, 8.0);
176    let prob = target_rate * TICK_DT;
177    let mut rng = rand::rng();
178    if rng.random::<f64>() >= prob {
179        return;
180    }
181    // Random anchor within the biscuit, with a small inset so the "+N" text
182    // doesn't clip into the border.
183    let frac_x = rng.random_range(0.05_f32..=0.95);
184    let frac_y = rng.random_range(0.10_f32..=0.95);
185    state.spawn_auto_particle(frac_x, frac_y);
186}
187
188/// Insets pull the spawn lottery away from the biscuit edges so the 5×3
189/// marker has room to render without clipping into the border. Match the
190/// pre-refactor inset values exactly — they were tuned against the same
191/// marker geometry.
192const SPAWN_INSET_X: f32 = 0.08;
193const SPAWN_INSET_Y: f32 = 0.10;
194/// Minimum cell-space distance between two on-screen powerup centers,
195/// measured in biscuit-cell units (NOT fractional units). The 5×3 marker
196/// is 5 cells wide and 3 tall, so a 4-cell minimum keeps two markers
197/// from sharing any of their interior cells while still allowing tight
198/// neighbors that read as distinct.
199const POWERUP_MIN_CELL_DIST: f32 = 4.0;
200/// Approximate biscuit cell aspect ratio (width / height of a terminal
201/// cell). Most monospace fonts render cells ~2× taller than wide; the
202/// FULL biscuit's bounding box is ~60×30 (cell ratio 2:1), MEDIUM is
203/// 40×18 (~2.2:1), TINY is 16×8 (2:1). Using 2.0 here keeps the
204/// dispersion check working in cell space, so the same fractional gap
205/// in `frac_y` covers more visual cells than in `frac_x` — without this
206/// correction, two markers separated only vertically would read as
207/// overlapping while passing the dispersion filter.
208const BISCUIT_CELL_ASPECT: f32 = 2.0;
209/// Best-effort retry budget for dispersion. Eight tries is plenty when the
210/// Vec is short (the expected ~0.2 concurrent per kind average); on a
211/// pile-up the fall-through to plain-random keeps the spawn happening
212/// rather than skipping it.
213const POWERUP_DISPERSION_TRIES: u32 = 8;
214
215/// Pick a fractional position inside the biscuit, dispersed away from any
216/// existing powerup in `existing`. Best-effort: up to
217/// `POWERUP_DISPERSION_TRIES` retries, then accept a plain-random position
218/// (acceptable to the issue spec — exact overlap is rare in practice).
219///
220/// `biscuit_cells` is `(width, height)` of the live biscuit rect. The
221/// dispersion check works in CELL SPACE — `dx_cells² + dy_cells² ≥
222/// POWERUP_MIN_CELL_DIST²` — because the biscuit is roughly 2:1 in cell
223/// aspect (terminal cells are ~2× tall as wide), and a pure-fractional
224/// distance would over-allow vertical overlap.
225fn pick_dispersed_frac(existing: &[Powerup], biscuit_cells: (u16, u16)) -> (f32, f32) {
226    let (bw, bh) = biscuit_cells;
227    let bw = bw.max(1) as f32;
228    let bh = bh.max(1) as f32;
229    let min_sq = POWERUP_MIN_CELL_DIST * POWERUP_MIN_CELL_DIST;
230    let mut rng = rand::rng();
231    for _ in 0..POWERUP_DISPERSION_TRIES {
232        let fx = rng.random_range(SPAWN_INSET_X..=(1.0 - SPAWN_INSET_X));
233        let fy = rng.random_range(SPAWN_INSET_Y..=(1.0 - SPAWN_INSET_Y));
234        let too_close = existing.iter().any(|p| {
235            // Convert fractional deltas to cell-space deltas. Y is
236            // multiplied by BISCUIT_CELL_ASPECT to compensate for the
237            // tall terminal cell — one row visually equals ~2 cols.
238            let dx_cells = (p.frac_x - fx) * bw;
239            let dy_cells = (p.frac_y - fy) * bh * BISCUIT_CELL_ASPECT;
240            dx_cells * dx_cells + dy_cells * dy_cells < min_sq
241        });
242        if !too_close {
243            return (fx, fy);
244        }
245    }
246    let fx = rng.random_range(SPAWN_INSET_X..=(1.0 - SPAWN_INSET_X));
247    let fy = rng.random_range(SPAWN_INSET_Y..=(1.0 - SPAWN_INSET_Y));
248    (fx, fy)
249}
250
251fn maybe_spawn_powerups(state: &mut GameState, geom: &SimGeometry) {
252    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
253        return;
254    }
255    let cells = (geom.biscuit.width, geom.biscuit.height);
256    // Each kind runs on its own clock. Cooldown is reset to a fresh
257    // exponential sample on every spawn (regardless of how many of the
258    // same kind are already on screen — the parallelism is the whole
259    // point of this refactor). `tick_powerups` already decremented the
260    // cooldown this tick, so a `> 0` test here is correct.
261    for kind in PowerupKind::ALL {
262        let i = kind as usize;
263        if state.powerup_cooldowns[i] > 0 {
264            continue;
265        }
266        spawn_powerup(state, kind, cells);
267        state.powerup_cooldowns[i] = powerup::next_cooldown(kind);
268    }
269}
270
271/// Push a fresh powerup of `kind` onto `state.powerups`. Position is
272/// picked with the dispersion helper so back-to-back spawns don't land in
273/// the exact same cell. Cooldown management is the caller's responsibility
274/// (`maybe_spawn_powerups` resets the kind's clock; the dev cheats don't —
275/// pressing F8 twice in quick succession really does push two Lucky's, AND
276/// the natural Lucky cooldown keeps ticking down independently, so a dev
277/// spawn followed shortly by a natural spawn is expected and intentional).
278fn spawn_powerup(state: &mut GameState, kind: PowerupKind, biscuit_cells: (u16, u16)) {
279    // Defensive: every spawn site uses the kind's full lifetime. If a
280    // future caller passes a Powerup with `life_ticks: 0` directly,
281    // `tick_powerups` would still drop it on the next tick — but the
282    // marker would briefly render at near-zero life, hitting the
283    // alarm-mode shimmer immediately. Catch that misuse here.
284    let life_ticks = kind.lifetime_ticks();
285    debug_assert!(life_ticks > 0, "PowerupKind::lifetime_ticks must be > 0");
286    let (frac_x, frac_y) = pick_dispersed_frac(&state.powerups, biscuit_cells);
287    let spawn_id = state.mint_spawn_id();
288    state.powerups.push(Powerup {
289        kind,
290        spawn_id,
291        frac_x,
292        frac_y,
293        life_ticks,
294    });
295}
296
297/// Dev cheat: force-spawn a powerup of `kind`. Unlike `maybe_spawn_powerups`
298/// this does NOT reset the cooldown, so it doesn't disturb the natural
299/// rhythm — and it does NOT gate on slot occupancy (that's the whole
300/// point: pressing F8 twice produces two Lucky's). The biscuit-size
301/// guard mirrors the natural-spawn path so a tiny terminal can't drop a
302/// marker into a 0-width rect.
303fn force_spawn_powerup(state: &mut GameState, geom: &SimGeometry, kind: PowerupKind) {
304    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
305        return;
306    }
307    spawn_powerup(state, kind, (geom.biscuit.width, geom.biscuit.height));
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::game::state::GameState;
314    use ratatui::layout::Rect;
315
316    fn geom_with_biscuit() -> SimGeometry {
317        SimGeometry {
318            biscuit: Rect::new(0, 0, 40, 20),
319        }
320    }
321
322    #[test]
323    fn force_spawn_pushes_to_vec_uncapped() {
324        // Pressing the same F-key twice in a row produces two on-screen
325        // powerups of that kind — no per-kind cap, no slot-occupancy
326        // displacement. This is the headline feature of the refactor.
327        let mut state = GameState::default();
328        let geom = geom_with_biscuit();
329        force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
330        force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
331        let lucky_count = state
332            .powerups
333            .iter()
334            .filter(|p| p.kind == PowerupKind::Lucky)
335            .count();
336        assert_eq!(lucky_count, 2);
337        // Distinct spawn ids — id reuse would defeat the per-instance
338        // hit-test.
339        let ids: Vec<u64> = state.powerups.iter().map(|p| p.spawn_id).collect();
340        assert_ne!(ids[0], ids[1]);
341    }
342
343    #[test]
344    fn force_spawn_mixes_kinds_freely() {
345        // All four kinds can coexist; no slot ever forces a one-per-kind cap.
346        let mut state = GameState::default();
347        let geom = geom_with_biscuit();
348        for kind in PowerupKind::ALL {
349            force_spawn_powerup(&mut state, &geom, kind);
350        }
351        assert_eq!(state.powerups.len(), 4);
352        for kind in PowerupKind::ALL {
353            assert!(state.powerups.iter().any(|p| p.kind == kind));
354        }
355    }
356
357    #[test]
358    fn spawn_dispersion_avoids_exact_overlap() {
359        // Two consecutive force-spawns on a fresh state must produce two
360        // distinct positions. Dispersion is best-effort; we assert the
361        // weaker but tractable property "distance between them is at
362        // least the dispersion threshold" most of the time. With only one
363        // existing entry the retry loop almost always finds a clean spot.
364        let mut state = GameState::default();
365        let geom = geom_with_biscuit();
366        force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
367        force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
368        let a = &state.powerups[0];
369        let b = &state.powerups[1];
370        let dx = a.frac_x - b.frac_x;
371        let dy = a.frac_y - b.frac_y;
372        let dist = (dx * dx + dy * dy).sqrt();
373        // Allow a generous floor: dispersion fall-through can produce a
374        // single near-overlap, but not zero.
375        assert!(dist > 0.0, "two spawns landed at the exact same point");
376    }
377
378    #[test]
379    fn spawn_dispersion_keeps_cell_distance_in_typical_layout() {
380        // Statistical: across 1000 fresh-state pair spawns on a normal
381        // 60×30 biscuit, the cell-space distance between the two
382        // markers should clear `POWERUP_MIN_CELL_DIST` the vast
383        // majority of the time (only the fall-through path violates,
384        // and that fires once per ~8 retries × dense neighborhood,
385        // which is rare for a single existing point on a 50-cell-wide
386        // free area). Asserting a 90%+ pass rate is generous.
387        let mut clear = 0;
388        let trials = 1000;
389        let geom = SimGeometry {
390            biscuit: Rect::new(0, 0, 60, 30),
391        };
392        for _ in 0..trials {
393            let mut state = GameState::default();
394            force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
395            force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
396            let a = &state.powerups[0];
397            let b = &state.powerups[1];
398            let dx_cells = (a.frac_x - b.frac_x) * geom.biscuit.width as f32;
399            let dy_cells = (a.frac_y - b.frac_y) * geom.biscuit.height as f32 * BISCUIT_CELL_ASPECT;
400            let cell_dist = (dx_cells * dx_cells + dy_cells * dy_cells).sqrt();
401            if cell_dist >= POWERUP_MIN_CELL_DIST {
402                clear += 1;
403            }
404        }
405        let ratio = clear as f32 / trials as f32;
406        assert!(
407            ratio > 0.90,
408            "expected ≥90% of pair spawns to clear cell distance; got {clear}/{trials} = {ratio}"
409        );
410    }
411
412    #[test]
413    fn spawn_dispersion_handles_tiny_biscuit_without_panic() {
414        // Edge case: at TINY zoom (16×8) the biscuit is barely large
415        // enough for the marker. The dispersion helper must not divide
416        // by zero or panic, even when the size guard in
417        // `maybe_spawn_powerups` would normally reject.
418        let mut state = GameState::default();
419        // Just above the size guard so force_spawn_powerup goes through.
420        let geom = SimGeometry {
421            biscuit: Rect::new(0, 0, 16, 8),
422        };
423        force_spawn_powerup(&mut state, &geom, PowerupKind::Lucky);
424        force_spawn_powerup(&mut state, &geom, PowerupKind::Frenzy);
425        assert_eq!(state.powerups.len(), 2);
426    }
427}