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}