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