Skip to main content

cuqueclicker_lib/game/
powerup.rs

1//! Unified powerup model — replaces the old per-variant slots
2//! (`GoldenCuque[3]` + `GreenCoin`) with a single `Vec<Powerup>` keyed by
3//! `PowerupKind`.
4//!
5//! Adding a new kind is a single-file change: extend `PowerupKind`, give it
6//! `lifetime_ticks` / `mean_cooldown_ticks`, add a render branch, and add a
7//! catch-effect arm in `GameState::catch_powerup`. Every other system —
8//! input routing, tick loop, persistence, achievements — inherits the new
9//! kind without further plumbing.
10//!
11//! Spawn timing is **truncated exponential per kind**, sampled inter-arrival
12//! (one RNG draw per spawn, not a per-tick roll). Per-kind cooldown clocks
13//! tick independently and don't freeze when the kind already has on-screen
14//! instances — the Vec is unbounded and pile-ups self-resolve via the
15//! short lifetime.
16
17use rand::RngExt;
18
19use crate::game::state::TICK_HZ;
20
21/// Stable discriminator for every kind of powerup. Order also defines the
22/// indexing into `GameState::powerup_cooldowns: [u32; N_KINDS]`, so don't
23/// reorder once shipped — a save written under one ordering would index a
24/// different kind's cooldown after a swap. (Cooldowns are `#[serde(skip)]`
25/// today, so the actual blast radius is "fresh-load reseeds them anyway,"
26/// but treat the order as stable to keep test fixtures honest.)
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum PowerupKind {
29    /// Instant flat reward: `max(10, fps * 60s)` cuques. No buff, no
30    /// duration. Frequent, small.
31    Lucky,
32    /// 13-second `ClickFrenzy` buff: each manual click during the buff
33    /// adds an FPS-scaled bonus on top of normal click power. Less
34    /// common, disruptive while it lasts; the FPS scaling means a
35    /// late-game Frenzy is much more rewarding than an early-game one
36    /// (and the early-game version stays bounded against the cost
37    /// ladder).
38    Frenzy,
39    /// 60-second per-fingerer modifier on a random *owned* fingerer:
40    /// that fingerer's passive FPS is multiplied x7. Implemented as a
41    /// `ModifierSource::PurpleCoin` `MulFactor(7.0)` with `Ticks(1200)`.
42    Buff,
43    /// Permanent +10% AddPercent modifier on a random *visible* fingerer
44    /// (sidebar-visible — including unowned tiers above the visibility
45    /// threshold; Index Finger is always eligible). Rare; sourced as
46    /// `ModifierSource::GreenCoin`.
47    GreenCoin,
48}
49
50/// Number of distinct powerup kinds — must match `PowerupKind::ALL.len()`.
51/// Used to size `GameState::powerup_cooldowns`.
52pub const N_KINDS: usize = 4;
53
54/// Compile-time guard against `N_KINDS` drifting out of sync with the
55/// `PowerupKind::ALL` array. If a new variant is added to `PowerupKind`
56/// and `ALL` is updated but `N_KINDS` isn't (or vice versa), the build
57/// fails here instead of producing silent index-out-of-bounds at runtime
58/// against `GameState::powerup_cooldowns: [u32; N_KINDS]`.
59const _: () = assert!(PowerupKind::ALL.len() == N_KINDS);
60
61impl PowerupKind {
62    /// Stable iteration order. Mirrors discriminant order so
63    /// `kind as usize` indexes into anything sized to `N_KINDS`.
64    pub const ALL: [Self; N_KINDS] = [Self::Lucky, Self::Frenzy, Self::Buff, Self::GreenCoin];
65
66    /// On-screen lifetime for this kind. ~11s at 20Hz across all kinds for
67    /// now — long enough to see and react, short enough to be a meaningful
68    /// catch. Keep on `PowerupKind` so tuning is one number per kind.
69    pub fn lifetime_ticks(self) -> u32 {
70        match self {
71            Self::Lucky | Self::Frenzy | Self::Buff | Self::GreenCoin => 220,
72        }
73    }
74
75    /// Mean inter-arrival time (in ticks) for this kind. Single tuning
76    /// knob — raise to make the kind rarer, lower to see it more often.
77    pub fn mean_cooldown_ticks(self) -> u32 {
78        match self {
79            Self::Lucky => 60 * TICK_HZ,      // ~60s avg — frequent, small
80            Self::Frenzy => 120 * TICK_HZ,    // ~120s avg — fps-scaled click bonus
81            Self::Buff => 120 * TICK_HZ,      // ~120s avg — x7 fingerer mult
82            Self::GreenCoin => 240 * TICK_HZ, // ~240s avg — rarest, permanent +10%
83        }
84    }
85
86    /// Safety floor on the truncated exponential. Prevents pathologically
87    /// rapid spawns from the left tail (two of the same kind appearing in
88    /// the same second by chance and visually overlapping).
89    pub fn min_cooldown_ticks(self) -> u32 {
90        5 * TICK_HZ
91    }
92
93    /// Safety ceiling on the truncated exponential. Catches the bottom ~2%
94    /// of the right tail; everything below 4× the mean passes through
95    /// unchanged. Prevents the player from feeling like a kind has been
96    /// "removed" during a particularly long drought.
97    pub fn max_cooldown_ticks(self) -> u32 {
98        4 * self.mean_cooldown_ticks()
99    }
100}
101
102/// One on-screen powerup. Position is biscuit-fractional ([0, 1] on each
103/// axis), same convention as `Particle` and the old `GoldenCuque` —
104/// the marker stays anchored to its spot when the terminal resizes or the
105/// user zooms.
106///
107/// `spawn_id` is a stable, monotonic identifier minted from
108/// `GameState::next_spawn_id` at spawn time. Click hit-testing and the `g`
109/// hotkey reference instances by id, never by Vec index — Vec indices shift
110/// on `swap_remove`, and the input router holds layout rects across multiple
111/// events, so a stable id is the only safe way to disambiguate "this
112/// specific Frenzy among the three on screen."
113#[derive(Clone, Debug)]
114pub struct Powerup {
115    pub kind: PowerupKind,
116    pub spawn_id: u64,
117    pub frac_x: f32,
118    pub frac_y: f32,
119    pub life_ticks: u32,
120}
121
122/// Sample the next inter-arrival cooldown for this kind. Truncated
123/// exponential: `Exp(1/mean)` clamped to `[min, max]` safety rails.
124///
125/// Memoryless / bursty / "truly random" arrivals — the genre's natural
126/// distribution. Per-spawn cost: one RNG draw + one `ln` + one `round` +
127/// one `clamp` (~25ns). Called once every couple of seconds across all
128/// kinds combined; effectively free.
129pub fn next_cooldown(kind: PowerupKind) -> u32 {
130    let mean = kind.mean_cooldown_ticks() as f64;
131    let min = kind.min_cooldown_ticks();
132    let max = kind.max_cooldown_ticks();
133    // Inverse-CDF of `Exp(1/mean)`: `-ln(1 - u) * mean` for `u ∈ [0, 1)`.
134    // Sampling `1.0 - u` keeps the argument in `(0, 1]` so `ln()` never
135    // hits `-∞` when `random()` returns 0.0.
136    let u: f64 = rand::rng().random();
137    let raw = -(1.0 - u).ln() * mean;
138    raw.round().clamp(min as f64, max as f64) as u32
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn next_cooldown_in_truncated_exponential_range() {
147        // Statistical sanity — every sample lands in [min, max], and the
148        // empirical mean is in the right ballpark. Not a tight test (we
149        // don't fix the RNG), just enough to catch a "forgot to multiply
150        // by mean" or "swapped min/max" regression.
151        for kind in PowerupKind::ALL {
152            let min = kind.min_cooldown_ticks();
153            let max = kind.max_cooldown_ticks();
154            let mean = kind.mean_cooldown_ticks() as f64;
155            let n = 10_000;
156            let mut sum: f64 = 0.0;
157            for _ in 0..n {
158                let v = next_cooldown(kind);
159                assert!(
160                    v >= min && v <= max,
161                    "next_cooldown({kind:?}) = {v} not in [{min}, {max}]"
162                );
163                sum += v as f64;
164            }
165            let empirical_mean = sum / n as f64;
166            // Truncation pulls the empirical mean a bit below the
167            // exponential's nominal mean (right-tail clipped to 4×mean).
168            // ±25% is generous enough that flakes are extremely rare.
169            assert!(
170                empirical_mean > mean * 0.55 && empirical_mean < mean * 1.25,
171                "empirical mean {empirical_mean} for {kind:?} too far from {mean}"
172            );
173        }
174    }
175}