cuqueclicker 1.2.0

A TUI idle clicker where you finger an ASCII ass instead of clicking a cookie.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! Platform-agnostic simulation core.
//!
//! Owns the [`Action`] / [`BuyQty`] types (the input router produces them;
//! [`apply_action`] is the only thing that interprets them) and the per-tick
//! `state.tick()` + ambient spawn helpers.
//!
//! What lives **outside** this module:
//! - the threaded sim loop on native (`app.rs::sim_loop`), which wraps
//!   [`sim_tick`] + [`apply_action`] with `mpsc::recv_timeout`, save
//!   scheduling via the [`Persistence`](crate::platform::Persistence) impl,
//!   and the demo-recorder driver.
//! - the requestAnimationFrame-driven loop on web (added when the wasm
//!   port lands), which calls the same [`sim_tick`] + [`apply_action`]
//!   single-threaded.
//!
//! The split is: this module is cross-platform; threading + I/O scheduling
//! around it isn't. See tracking issue #13 for rationale.

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;

/// Buy quantity for a fingerer purchase action. Modifier-key meaning is
/// translated to this in the input router; sim only consumes the resolved
/// value so the modifier mapping can change without touching tick logic.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BuyQty {
    One,
    Ten,
    Max,
}

/// Commands the input router produces and the sim consumes. The sim is
/// the sole authority on [`GameState`] mutation — input handling translates
/// raw events (key/mouse/wheel) into these and feeds them through.
#[derive(Clone, Debug)]
pub enum Action {
    Click {
        col: u16,
        row: u16,
    },
    ClickCenter,
    /// Catch the on-screen powerup with the given `spawn_id`. The id is
    /// minted at spawn time on `GameState::next_spawn_id`; click hit-test
    /// and the `g` hotkey both reference instances by id, never by Vec
    /// index, so `swap_remove` on catch is safe even with multiple
    /// in-flight events between frames.
    CatchPowerup(u64),
    BuyFingerer {
        idx: usize,
        qty: BuyQty,
    },
    /// Buy the tree node at the given lot. No-op if the lot doesn't have a
    /// node, is already owned, isn't reachable, or the player can't afford it.
    TreeBuy(TreeCoord),
    /// Refund the tree node at the given lot. No-op if not owned, would
    /// orphan another owned node, or the node doesn't exist.
    TreeRefund(TreeCoord),
    /// Move the tree cursor to `lot` (no purchase). Persists into
    /// `state.tree.cursor` so reopening the modal lands here.
    TreeFocus(TreeCoord),
    PrestigeReset,
    /// Latest render-computed biscuit geometry, so the sim can place
    /// powerups and auto-particles inside the current layout. Powerup
    /// rects live on the input/render side (only the click handler reads
    /// them). `powerups_paused` is set while a full-screen modal (the
    /// upgrade tree) is open — auto-FPS keeps accruing but the powerup
    /// engine freezes (no new spawns, existing on-screen powerups stop
    /// counting down their lifetime).
    UpdateGeometry {
        biscuit: Rect,
        powerups_paused: bool,
    },
    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
    /// the sim trusts whatever arrives.
    DevAddCuques(f64),
    /// Force-spawn a powerup of the given kind. Pushes a fresh entry onto
    /// `state.powerups` — pressing the same F-key twice now produces two
    /// of the same kind on screen.
    DevForcePowerup(PowerupKind),
    /// J10: a click that didn't hit anything actionable. Sim spawns a
    /// short-lived "·" misclick particle at the screen point so dead-zone
    /// clicks visibly register.
    Misclick {
        col: u16,
        row: u16,
    },
}

/// Geometry the sim needs to interpret screen-space events. Updated on
/// every render via [`Action::UpdateGeometry`].
#[derive(Clone, Copy, Default)]
pub struct SimGeometry {
    pub biscuit: Rect,
    /// True while a full-screen modal (the upgrade tree) is open. Pauses
    /// the powerup spawn / tick engine; the rest of the tick keeps
    /// running so auto-FPS continues to accrue underneath.
    pub powerups_paused: bool,
}

/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
/// no time, no threading. Called from both the native sim thread (on
/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
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);
            }
            // Mark this tick as "saw a spacebar press." `tick()` reads the
            // flag, advances the held-streak counter, and clears it. A
            // single tap → 1 tick of streak → resets immediately. A held
            // key (terminal repeat) → streak climbs over time.
            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);
        }
    }
}

/// Run the platform-agnostic body of one sim tick: state updates + ambient
/// spawn helpers. Save scheduling and demo-driver autopilot are the
/// **caller's** concern (they live in `app.rs::sim_loop` on native).
///
/// When `geom.powerups_paused` is set (a full-screen modal is open), the
/// powerup engine is skipped entirely — no spawns, no lifetime ticks, no
/// cooldown advancement. The base tick still runs so auto-FPS, modifiers,
/// achievements, and HUD count-ups keep flowing.
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;
    }
    // ~1 per 45s average at 20Hz
    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;
    }
    // `target_rate` paces the particle spawner; once FPS is in the
    // "many cuques per second" range it caps at 8/s and we don't care
    // about precise sqrt math. Compute via f64 (saturating) and clamp.
    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;
    }
    // Random anchor within the biscuit, with a small inset so the "+N" text
    // doesn't clip into the border.
    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);
}

/// Insets pull the spawn lottery away from the biscuit edges so the 5×3
/// marker has room to render without clipping into the border. Match the
/// pre-refactor inset values exactly — they were tuned against the same
/// marker geometry.
const SPAWN_INSET_X: f32 = 0.08;
const SPAWN_INSET_Y: f32 = 0.10;
/// Minimum cell-space distance between two on-screen powerup centers,
/// measured in biscuit-cell units (NOT fractional units). The 5×3 marker
/// is 5 cells wide and 3 tall, so a 4-cell minimum keeps two markers
/// from sharing any of their interior cells while still allowing tight
/// neighbors that read as distinct.
const POWERUP_MIN_CELL_DIST: f32 = 4.0;
/// Approximate biscuit cell aspect ratio (width / height of a terminal
/// cell). Most monospace fonts render cells ~2× taller than wide; the
/// FULL biscuit's bounding box is ~60×30 (cell ratio 2:1), MEDIUM is
/// 40×18 (~2.2:1), TINY is 16×8 (2:1). Using 2.0 here keeps the
/// dispersion check working in cell space, so the same fractional gap
/// in `frac_y` covers more visual cells than in `frac_x` — without this
/// correction, two markers separated only vertically would read as
/// overlapping while passing the dispersion filter.
const BISCUIT_CELL_ASPECT: f32 = 2.0;
/// Best-effort retry budget for dispersion. Eight tries is plenty when the
/// Vec is short (the expected ~0.2 concurrent per kind average); on a
/// pile-up the fall-through to plain-random keeps the spawn happening
/// rather than skipping it.
const POWERUP_DISPERSION_TRIES: u32 = 8;

/// Pick a fractional position inside the biscuit, dispersed away from any
/// existing powerup in `existing`. Best-effort: up to
/// `POWERUP_DISPERSION_TRIES` retries, then accept a plain-random position
/// (acceptable to the issue spec — exact overlap is rare in practice).
///
/// `biscuit_cells` is `(width, height)` of the live biscuit rect. The
/// dispersion check works in CELL SPACE — `dx_cells² + dy_cells² ≥
/// POWERUP_MIN_CELL_DIST²` — because the biscuit is roughly 2:1 in cell
/// aspect (terminal cells are ~2× tall as wide), and a pure-fractional
/// distance would over-allow vertical overlap.
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| {
            // Convert fractional deltas to cell-space deltas. Y is
            // multiplied by BISCUIT_CELL_ASPECT to compensate for the
            // tall terminal cell — one row visually equals ~2 cols.
            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);
    // Each kind runs on its own clock. Cooldown is reset to a fresh
    // exponential sample on every spawn (regardless of how many of the
    // same kind are already on screen — the parallelism is the whole
    // point of this refactor). `tick_powerups` already decremented the
    // cooldown this tick, so a `> 0` test here is correct.
    for kind in PowerupKind::ALL {
        let i = kind as usize;
        if state.powerup_cooldowns[i] > 0 {
            continue;
        }
        spawn_powerup(state, kind, cells);
        // Tree contribution: SpawnRateMul is a true spawn-rate
        // multiplier — >1.0 means more frequent spawns, so the
        // cooldown scales by its inverse.
        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;
    }
}

/// Push a fresh powerup of `kind` onto `state.powerups`. Position is
/// picked with the dispersion helper so back-to-back spawns don't land in
/// the exact same cell. Cooldown management is the caller's responsibility
/// (`maybe_spawn_powerups` resets the kind's clock; the dev cheats don't —
/// pressing F8 twice in quick succession really does push two Lucky's, AND
/// the natural Lucky cooldown keeps ticking down independently, so a dev
/// spawn followed shortly by a natural spawn is expected and intentional).
fn spawn_powerup(state: &mut GameState, kind: PowerupKind, biscuit_cells: (u16, u16)) {
    // Defensive: every spawn site uses the kind's full lifetime. If a
    // future caller passes a Powerup with `life_ticks: 0` directly,
    // `tick_powerups` would still drop it on the next tick — but the
    // marker would briefly render at near-zero life, hitting the
    // alarm-mode shimmer immediately. Catch that misuse here.
    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,
    });
}

/// Dev cheat: force-spawn a powerup of `kind`. Unlike `maybe_spawn_powerups`
/// this does NOT reset the cooldown, so it doesn't disturb the natural
/// rhythm — and it does NOT gate on slot occupancy (that's the whole
/// point: pressing F8 twice produces two Lucky's). The biscuit-size
/// guard mirrors the natural-spawn path so a tiny terminal can't drop a
/// marker into a 0-width rect.
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() {
        // Pressing the same F-key twice in a row produces two on-screen
        // powerups of that kind — no per-kind cap, no slot-occupancy
        // displacement. This is the headline feature of the refactor.
        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);
        // Distinct spawn ids — id reuse would defeat the per-instance
        // hit-test.
        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() {
        // All four kinds can coexist; no slot ever forces a one-per-kind cap.
        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() {
        // Two consecutive force-spawns on a fresh state must produce two
        // distinct positions. Dispersion is best-effort; we assert the
        // weaker but tractable property "distance between them is at
        // least the dispersion threshold" most of the time. With only one
        // existing entry the retry loop almost always finds a clean spot.
        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();
        // Allow a generous floor: dispersion fall-through can produce a
        // single near-overlap, but not zero.
        assert!(dist > 0.0, "two spawns landed at the exact same point");
    }

    #[test]
    fn spawn_dispersion_keeps_cell_distance_in_typical_layout() {
        // Statistical: across 1000 fresh-state pair spawns on a normal
        // 60×30 biscuit, the cell-space distance between the two
        // markers should clear `POWERUP_MIN_CELL_DIST` the vast
        // majority of the time (only the fall-through path violates,
        // and that fires once per ~8 retries × dense neighborhood,
        // which is rare for a single existing point on a 50-cell-wide
        // free area). Asserting a 90%+ pass rate is generous.
        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() {
        // Edge case: at TINY zoom (16×8) the biscuit is barely large
        // enough for the marker. The dispersion helper must not divide
        // by zero or panic, even when the size guard in
        // `maybe_spawn_powerups` would normally reject.
        let mut state = GameState::default();
        // Just above the size guard so force_spawn_powerup goes through.
        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);
    }
}