cuqueclicker_lib/game/state.rs
1use std::collections::{HashMap, HashSet};
2
3use rand::RngExt;
4use ratatui::layout::Rect;
5use serde::{Deserialize, Serialize};
6
7use crate::game::achievement::ACHIEVEMENTS;
8use crate::game::fingerer::{self, FINGERERS};
9use crate::game::modifier::{
10 FingererAggregate, Modifier, ModifierDuration, ModifierEffect, ModifierSource,
11};
12use crate::game::powerup::{self, N_KINDS, Powerup, PowerupKind};
13use crate::game::upgrade::{UPGRADES, UpgradeEffect};
14
15pub const TICK_HZ: u32 = 20;
16pub const TICK_DT: f64 = 1.0 / TICK_HZ as f64;
17/// How long the biscuit stays "clenched" (eye→`*`, color shifts pink, art
18/// vertically squashes by one row). Bumped from 3 to 6 so a single click is
19/// actually visible — at 20Hz, 3 ticks (~150ms) was hard to perceive.
20pub const CLENCH_TICKS: u32 = 6;
21/// First `CLENCH_SQUASH_TICKS` of a clench draw the biscuit one row shorter
22/// (top blank dropped, art shifted) so each finger reads as a real squish
23/// before springing back. Strict subset of CLENCH_TICKS.
24pub const CLENCH_SQUASH_TICKS: u32 = 2;
25const PARTICLE_LIFE: u32 = 20;
26/// Misclick "·" lifetime — short, just enough to acknowledge the attempt.
27pub const MISCLICK_LIFE: u32 = 8;
28/// Achievement-unlock toast: how long the popup stays on screen.
29pub const TOAST_TICKS: u32 = TICK_HZ * 4;
30/// HUD digit "I just got bigger" green flash duration.
31pub const HUD_FLASH_TICKS: u32 = TICK_HZ; // 1s
32/// Achievement-unlock border channel duration (gold pulse like Lucky but
33/// shorter — celebratory, not lingering).
34pub const ACHIEVEMENT_FLASH_TICKS: u32 = TICK_HZ * 2;
35/// "You can afford this now!" row flash — fires the moment a fingerer or
36/// upgrade transitions from unaffordable to affordable. Brief on purpose:
37/// short enough that it's clearly an "announcement," not the longer
38/// purchase flash that fires on actual buy.
39pub const UNLOCK_FLASH_TICKS: u32 = TICK_HZ / 2; // 0.5s
40/// Per-tick upward drift for a particle, expressed as a fraction of the
41/// biscuit's height. Calibrated to match the original feel before the
42/// switch to fractional anchors: the old code rose 0.18 cells/tick on
43/// any biscuit size; on a typical ~30-row biscuit that's 0.006 of height
44/// per tick — slow enough that a "+1" only travels ~10-12% of the biscuit
45/// across its 1-second life, instead of streaking across half of it.
46const PARTICLE_FRAC_RISE: f32 = 0.006;
47const GOLDEN_REWARD_SECONDS: f64 = 60.0;
48const GOLDEN_REWARD_FLAT: f64 = 10.0;
49/// Per-click Frenzy bonus: each click during a `Buff::ClickFrenzy` adds
50/// `max(FRENZY_FLAT_PER_CLICK, fps * FRENZY_FPS_SECONDS_PER_CLICK)` cuques
51/// on top of the regular click power. The FPS-scaled term is what makes
52/// late-game Frenzy still feel huge; the flat floor is what keeps an
53/// early-game Frenzy from trivializing the cost ladder. Same shape as
54/// Lucky's reward formula, but per-click instead of per-catch.
55///
56/// 5 seconds of FPS per click × ~30 clicks in the 13s buff = ~150 seconds
57/// of FPS in 13 seconds = ~12× normal income rate during the buff. Real
58/// boost without breaking the early game (where FPS ≈ 0 and the floor
59/// caps each click at 10 cuques regardless of how fast you spam).
60const FRENZY_FPS_SECONDS_PER_CLICK: f64 = 5.0;
61const FRENZY_FLAT_PER_CLICK: f64 = 10.0;
62
63/// Visual flavor for a particle. Drives color/weight in the renderer; the
64/// motion model (rise + horizontal drift) is identical across kinds.
65#[derive(Clone, Copy, PartialEq, Eq)]
66pub enum ParticleKind {
67 /// Default `+1` from a normal click — white→red fade.
68 Click,
69 /// High-power click (Frenzy active, big upgrade mults). Bold +
70 /// warm-yellow accent so it stands out from a swarm of `+1`s.
71 ClickBig,
72 /// Auto-fingerer income particle.
73 Auto,
74 /// Golden-catch label ("FRENZY!", "+1.2k", etc). Longer life,
75 /// brighter palette.
76 Golden,
77 /// Bulk-buy confetti pop. Coloured glyphs, shorter than a click.
78 Confetti,
79}
80
81/// Position is stored as a fraction of the biscuit rect ([0.0, 1.0] on each
82/// axis), matching `Powerup`. The renderer resolves these fractions
83/// against the *current* biscuit rect every frame, so particles travel with
84/// the biscuit when the terminal resizes or the user zooms.
85#[derive(Clone)]
86pub struct Particle {
87 pub frac_x: f32,
88 pub frac_y: f32,
89 pub life: u32,
90 pub text: String,
91 pub kind: ParticleKind,
92 /// Per-tick horizontal drift in fraction-of-biscuit units. Set at spawn
93 /// from a small uniform so co-spawned particles separate as they rise
94 /// instead of stacking into garbage like `++1++++1`.
95 pub drift_x: f32,
96}
97
98/// Screen-anchored particle (raw col/row, not biscuit-fractional). Used for
99/// misclick acknowledgement: a small grey "·" at the exact dead-zone click
100/// point so the player knows the click registered but missed every target.
101#[derive(Clone)]
102pub struct MisclickParticle {
103 pub col: u16,
104 pub row: u16,
105 pub life: u32,
106}
107
108/// Convert an absolute `(col, row)` screen point into biscuit-fractional
109/// coordinates, clamped to [0.0, 1.0]. Used at click/spawn sites that come
110/// from screen-space input (mouse clicks, RNG within the biscuit rect).
111pub fn screen_to_biscuit_frac(col: u16, row: u16, biscuit: Rect) -> (f32, f32) {
112 if biscuit.width == 0 || biscuit.height == 0 {
113 return (0.5, 0.5);
114 }
115 let fx = ((col as i32 - biscuit.x as i32) as f32) / biscuit.width as f32;
116 let fy = ((row as i32 - biscuit.y as i32) as f32) / biscuit.height as f32;
117 (fx.clamp(0.0, 1.0), fy.clamp(0.0, 1.0))
118}
119
120/// Convert biscuit-fractional coordinates back to an absolute screen point.
121pub fn biscuit_frac_to_screen(frac_x: f32, frac_y: f32, biscuit: Rect) -> (u16, u16) {
122 let col = biscuit.x as f32 + frac_x.clamp(0.0, 1.0) * biscuit.width as f32;
123 let row = biscuit.y as f32 + frac_y.clamp(0.0, 1.0) * biscuit.height as f32;
124 (
125 col.round().clamp(0.0, u16::MAX as f32) as u16,
126 row.round().clamp(0.0, u16::MAX as f32) as u16,
127 )
128}
129
130/// Global, click-side buffs. Per-fingerer multipliers (the old
131/// `Buff::FingererBoost`) live on the modifier system in
132/// `crate::game::modifier`; only buffs that affect global click power
133/// belong here.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub enum Buff {
136 ClickFrenzy {
137 ticks_remaining: u32,
138 initial_ticks: u32,
139 /// Legacy field, retained for V2/V3 save compatibility but no
140 /// longer read by `click_power()`. The per-click Frenzy bonus
141 /// is FPS-scaled (see `FRENZY_FPS_SECONDS_PER_CLICK` /
142 /// `FRENZY_FLAT_PER_CLICK` in this module). A future V4
143 /// migration can drop this field outright; today it just
144 /// serializes as 777.0 and gets ignored.
145 mult: f64,
146 },
147}
148
149impl Buff {
150 pub fn ticks_remaining(&self) -> u32 {
151 match self {
152 Buff::ClickFrenzy {
153 ticks_remaining, ..
154 } => *ticks_remaining,
155 }
156 }
157
158 /// Plateau-at-1.0 until the last `BUFF_FADE_TICKS` of the buff, then
159 /// smoothstep-decay to 0. Gives a "stays on, then swift but smooth fade"
160 /// feel rather than a constantly-shrinking linear ramp.
161 pub fn strength(&self) -> f32 {
162 const FADE_TICKS: f32 = 30.0; // ~1.5s at 20Hz
163 let remaining = self.ticks_remaining() as f32;
164 if remaining >= FADE_TICKS {
165 1.0
166 } else {
167 let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
168 t * t * (3.0 - 2.0 * t)
169 }
170 }
171
172 fn tick(&mut self) {
173 match self {
174 Buff::ClickFrenzy {
175 ticks_remaining, ..
176 } => {
177 *ticks_remaining = ticks_remaining.saturating_sub(1);
178 }
179 }
180 }
181}
182
183/// Per-fingerer persistent state.
184///
185/// `count` is the number of units the player owns. `modifiers` is the list
186/// of [`Modifier`]s attached to this fingerer (Green Coin permanents,
187/// Purple Coin temp boosts, future buffs/debuffs); see
188/// [`crate::game::modifier`] for the stacking rules. `aggregate` is a
189/// derived cache rebuilt from `modifiers` on add/remove/expire and on
190/// save load — it's `#[serde(skip)]` because it's pure-derived data, and
191/// the live state is always reconstructable from `modifiers`.
192#[derive(Clone, Debug, Default, Serialize, Deserialize)]
193pub struct FingererState {
194 #[serde(default)]
195 pub count: u32,
196 #[serde(default)]
197 pub modifiers: Vec<Modifier>,
198 /// Pre-computed aggregate of every effect across every modifier.
199 /// Rebuilt by `attach_modifier` / per-tick expiry / `migrate_runtime`.
200 /// FPS reads MUST consult this, not the `Vec`.
201 #[serde(skip)]
202 pub aggregate: FingererAggregate,
203}
204
205/// Persistent game state. Catalog-addressed state (`fingerers_state`,
206/// `upgrades_earned`, `achievements_earned`) is keyed by STABLE STRING IDS,
207/// not positional indices, so reordering / inserting / removing entries in
208/// `FINGERERS`, `UPGRADES`, or `ACHIEVEMENTS` never corrupts an old save.
209/// Unknown ids in a save are ignored (forward-compat); missing ids default
210/// to zero / absent (backward-compat).
211#[derive(Clone, Serialize, Deserialize)]
212pub struct GameState {
213 /// Save schema version. The on-disk migration chain (`crate::save`)
214 /// reads this via `peek_version` *before* deserializing into the right
215 /// `GameStateVN` struct. A live in-memory state always equals
216 /// `crate::save::CURRENT_VERSION` — the chain stamps it on conversion
217 /// and `Default` initializes it that way. Pre-versioned saves on disk
218 /// have no `version` key, which `peek_version` treats as V1.
219 #[serde(default = "default_save_version")]
220 pub version: u32,
221 #[serde(default)]
222 pub cuques: f64,
223 #[serde(default)]
224 pub total_clicks: u64,
225 #[serde(default)]
226 pub lifetime_cuques: f64,
227 #[serde(default)]
228 pub best_fps: f64,
229 /// Lifetime grand total of every powerup caught (Lucky, Frenzy, Buff,
230 /// Green Coin). Stays a strict rollup so existing achievements that
231 /// gate on it continue to work, and pre-V3 saves whose breakdown was
232 /// never recorded keep an honest total. The four per-variant counters
233 /// below were added in V3; they only count post-V3 catches.
234 #[serde(default)]
235 pub golden_caught: u64,
236 #[serde(default)]
237 pub lucky_caught: u64,
238 #[serde(default)]
239 pub frenzy_caught: u64,
240 #[serde(default)]
241 pub buff_caught: u64,
242 #[serde(default)]
243 pub green_coin_caught: u64,
244
245 /// Fingerer id → owned count + attached modifiers + aggregate cache.
246 #[serde(default)]
247 pub fingerers_state: HashMap<String, FingererState>,
248 /// Set of earned achievement ids.
249 #[serde(default)]
250 pub achievements_earned: HashSet<String>,
251 /// Set of earned upgrade ids.
252 #[serde(default)]
253 pub upgrades_earned: HashSet<String>,
254
255 #[serde(default)]
256 pub prestige: u64,
257 #[serde(default)]
258 pub total_play_ticks: u64,
259 #[serde(default)]
260 pub buffs: Vec<Buff>,
261
262 #[serde(skip)]
263 pub clench_ticks: u32,
264 #[serde(skip)]
265 pub particles: Vec<Particle>,
266 /// Screen-anchored "misclick" tap particles — independent buffer because
267 /// they don't follow the biscuit (they're feedback for clicks that
268 /// MISSED the biscuit, including the dead zone at low zoom).
269 #[serde(skip)]
270 pub misclick_particles: Vec<MisclickParticle>,
271 /// All currently on-screen powerups, regardless of kind. Multiple of
272 /// any kind can coexist — the spawn side has no per-kind slot cap.
273 /// Each entry carries a stable `spawn_id`; click hit-test and the `g`
274 /// hotkey reference instances by id, never by Vec index. Not
275 /// persisted: closing and reopening the game shouldn't preserve frozen
276 /// powerup frames.
277 #[serde(skip)]
278 pub powerups: Vec<Powerup>,
279 /// Monotonic counter that mints `Powerup::spawn_id`. Session-scoped
280 /// (re-seeded to 0 on every load) — ids only need to be stable within
281 /// a single live state, not across restarts.
282 #[serde(skip)]
283 pub next_spawn_id: u64,
284 /// Per-kind inter-arrival cooldown clocks, indexed by `kind as usize`.
285 /// Each ticks down independently and rolls fresh from
286 /// `powerup::next_cooldown(kind)` on every spawn (including
287 /// force-spawns from the dev cheats). Doesn't freeze when the kind
288 /// already has on-screen instances — pile-ups self-resolve via the
289 /// short lifetime, so the only cost is "another marker on screen for
290 /// 11s".
291 #[serde(skip)]
292 pub powerup_cooldowns: [u32; N_KINDS],
293 #[serde(skip)]
294 pub session_ticks: u64,
295 /// Queue of achievement ids that unlocked but haven't yet been shown as a
296 /// toast. Drained one-at-a-time by `tick()` into `active_unlock_id`.
297 #[serde(skip)]
298 pub newly_unlocked: Vec<String>,
299 /// Currently-on-screen achievement toast (id) and its remaining life in
300 /// ticks. `None` means no toast right now; `tick()` pops the next pending
301 /// id off `newly_unlocked` when this clears.
302 #[serde(skip)]
303 pub active_unlock_id: Option<String>,
304 #[serde(skip)]
305 pub active_unlock_ticks: u32,
306 #[serde(skip)]
307 pub visual_debt: f64,
308 #[serde(skip)]
309 pub lucky_flash_ticks: u32,
310 #[serde(skip)]
311 pub achievement_flash_ticks: u32,
312 /// Brief green border channel pulse fired on a Green Coin catch.
313 /// Behaves like `lucky_flash_ticks` (plateau-fade); coexists with
314 /// other channels so a Green Coin caught during a Frenzy or Lucky
315 /// adds a green moiré rather than overwriting them.
316 #[serde(skip)]
317 pub green_coin_flash_ticks: u32,
318 /// HUD title border phase clock. Advances by `border_speed()` each
319 /// tick, so the title border visibly speeds up under Frenzy / Lucky /
320 /// purchase events. INTENTIONALLY NOT shared with secondary shimmers
321 /// (panel borders, sidebar / upgrade rows) — they need a constant-rate
322 /// clock so a global speed-up on the HUD doesn't drag them along.
323 #[serde(skip)]
324 pub border_phase: u32,
325 /// Constant-rate phase clock for secondary shimmers — sidebar row,
326 /// upgrade row, and panel-border flashes. Advances by exactly 1 per
327 /// tick regardless of game state, so e.g. an Achievement / Frenzy
328 /// event accelerating `border_phase` doesn't accelerate the
329 /// "can't-buy" shimmer that happens to be running on a fingerer
330 /// row at the same time.
331 #[serde(skip)]
332 pub steady_phase: u32,
333 #[serde(skip)]
334 pub purchase_flash_ticks: u32,
335 /// Strength multiplier (1.0..=3.0) for the most recent purchase flash,
336 /// scaled by bulk-buy quantity. The border + panel borders read this so
337 /// a max-buy lands harder than a single click.
338 #[serde(skip)]
339 pub purchase_flash_strength: f32,
340 /// One slot per visible sidebar row; indexed by catalog position because
341 /// it's purely a render-time flash and doesn't need to survive reorders.
342 #[serde(skip)]
343 pub fingerer_flash_ticks: Vec<u32>,
344 /// Mirror of `fingerer_flash_ticks` for the Upgrades panel. Sized to
345 /// UPGRADES.len() lazily by `migrate()`.
346 #[serde(skip)]
347 pub upgrade_flash_ticks: Vec<u32>,
348 /// Negative-feedback flash: red row pulse when a click hit a row but
349 /// `cuques < cost`. One slot per fingerer / upgrade index.
350 #[serde(skip)]
351 pub fingerer_unaffordable_flash: Vec<u32>,
352 #[serde(skip)]
353 pub upgrade_unaffordable_flash: Vec<u32>,
354 /// "Just became affordable" flash: a brief one-shot green shimmer
355 /// fired the tick a row's affordability flips false → true. Distinct
356 /// from `*_flash_ticks` (purchase) — shorter duration, no panel
357 /// border bleed — so the player can tell "now buyable" apart from
358 /// "you just bought."
359 #[serde(skip)]
360 pub fingerer_unlock_flash: Vec<u32>,
361 #[serde(skip)]
362 pub upgrade_unlock_flash: Vec<u32>,
363 /// Brief gold shimmer on a fingerer row when a Green Coin catch
364 /// targeted it. Closes the visual loop with the floating
365 /// `+10% {fingerer}` particle and the green-tinted title-border
366 /// pulse — the gold here matches the catch particle, so the player
367 /// can see at a glance which row in the sidebar just took the boost.
368 #[serde(skip)]
369 pub fingerer_green_coin_flash: Vec<u32>,
370 /// Previous-tick affordability per row, used to detect the
371 /// false→true edge that triggers `*_unlock_flash`. Sized to catalog
372 /// length by `migrate()` and seeded at init from the live state, so a
373 /// freshly-loaded save with rows already affordable doesn't fire a
374 /// fake unlock flash on tick 1.
375 #[serde(skip)]
376 pub prev_fingerer_affordable: Vec<bool>,
377 #[serde(skip)]
378 pub prev_upgrade_affordable: Vec<bool>,
379 /// Held-spacebar tracking.
380 ///
381 /// `space_pressed_this_tick` is set whenever `Action::ClickCenter`
382 /// arrives (terminal key-repeat fires Press events at ~30Hz, easily
383 /// hitting every 50ms tick when a key is genuinely held).
384 /// `ticks_since_last_press` is a small countdown that allows up to 3
385 /// missed ticks (~150ms) before declaring the key released — handles
386 /// real keyboard-repeat jitter so a 1-tick gap doesn't kill the
387 /// streak. `space_hold_ticks` is the consecutive "active" tick streak;
388 /// `space_held()` is true once it crosses 1 second.
389 ///
390 /// Net result: spamming spacebar at human speed (≥150ms between
391 /// presses) never triggers held; actually holding the key climbs the
392 /// streak past 20 ticks within ~1s.
393 #[serde(skip)]
394 pub space_pressed_this_tick: bool,
395 #[serde(skip)]
396 pub ticks_since_last_press: u32,
397 #[serde(skip)]
398 pub space_hold_ticks: u32,
399 /// HUD count-up tween: rendered numbers smoothly chase the real ones.
400 /// Initialized to the live values on load so the first frame doesn't
401 /// look like a count-up from zero.
402 #[serde(skip)]
403 pub displayed_cuques: f64,
404 #[serde(skip)]
405 pub displayed_fps: f64,
406 /// Brief green flash on the HUD digits when cuques jump UP — golden
407 /// catch, frenzy click, F4 dev cheat, etc. ("money coming in")
408 #[serde(skip)]
409 pub cuques_flash_ticks: u32,
410 /// Brief red flash on the HUD digits when cuques drop — successful
411 /// purchase, prestige reset (the big -all event). Mirrors
412 /// `cuques_flash_ticks` and competes with it: whichever channel is
413 /// stronger this frame drives the HUD color sweep, so a buy that
414 /// happens during a still-decaying gain pulse correctly flips the
415 /// digits red instead of staying green.
416 #[serde(skip)]
417 pub cuques_spend_flash_ticks: u32,
418}
419
420pub const LUCKY_FLASH_TICKS: u32 = 70; // 3.5s at 20Hz
421pub const PURCHASE_FLASH_TICKS: u32 = 20; // 1s at 20Hz
422/// Green Coin catch pulse — slightly shorter than Lucky's so the celebratory
423/// blip lands without lingering for so long it competes with whatever might
424/// be running on top (Frenzy, Buff, Lucky).
425pub const GREEN_COIN_FLASH_TICKS: u32 = 50; // 2.5s at 20Hz
426/// Per-row gold shimmer on the targeted fingerer's sidebar row when a
427/// Green Coin catch lands on it. ~2 seconds — long enough for the eye
428/// to track from the floating `+10% {fingerer}` particle over to the
429/// row, short enough that it doesn't outlive the catch event.
430pub const GREEN_COIN_ROW_FLASH_TICKS: u32 = TICK_HZ * 2; // 2.0s at 20Hz
431/// Permanent AddPercent the Green Coin attaches on catch. Tunable; bumping
432/// it changes the long-term power curve significantly so treat with care.
433pub const GREEN_COIN_ADD_PERCENT: f64 = 0.10;
434
435/// Serde default for `GameState::version`. A direct deserialize of the live
436/// `GameState` from a pre-versioned save (one without the field) still
437/// produces a sensibly-stamped state — though production loads always go
438/// through the migration chain in `crate::save`.
439fn default_save_version() -> u32 {
440 crate::save::CURRENT_VERSION
441}
442
443impl Default for GameState {
444 fn default() -> Self {
445 Self {
446 version: crate::save::CURRENT_VERSION,
447 cuques: 0.0,
448 total_clicks: 0,
449 lifetime_cuques: 0.0,
450 best_fps: 0.0,
451 golden_caught: 0,
452 lucky_caught: 0,
453 frenzy_caught: 0,
454 buff_caught: 0,
455 green_coin_caught: 0,
456 fingerers_state: HashMap::new(),
457 achievements_earned: HashSet::new(),
458 upgrades_earned: HashSet::new(),
459 prestige: 0,
460 total_play_ticks: 0,
461 buffs: Vec::new(),
462 clench_ticks: 0,
463 particles: Vec::new(),
464 misclick_particles: Vec::new(),
465 powerups: Vec::new(),
466 next_spawn_id: 0,
467 powerup_cooldowns: {
468 let mut cds = [0u32; N_KINDS];
469 for kind in PowerupKind::ALL {
470 cds[kind as usize] = powerup::next_cooldown(kind);
471 }
472 cds
473 },
474 session_ticks: 0,
475 newly_unlocked: Vec::new(),
476 active_unlock_id: None,
477 active_unlock_ticks: 0,
478 visual_debt: 0.0,
479 lucky_flash_ticks: 0,
480 achievement_flash_ticks: 0,
481 green_coin_flash_ticks: 0,
482 border_phase: 0,
483 steady_phase: 0,
484 purchase_flash_ticks: 0,
485 purchase_flash_strength: 1.0,
486 fingerer_flash_ticks: vec![0; fingerer::count()],
487 upgrade_flash_ticks: vec![0; UPGRADES.len()],
488 fingerer_unaffordable_flash: vec![0; fingerer::count()],
489 upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
490 fingerer_unlock_flash: vec![0; fingerer::count()],
491 upgrade_unlock_flash: vec![0; UPGRADES.len()],
492 fingerer_green_coin_flash: vec![0; fingerer::count()],
493 prev_fingerer_affordable: vec![false; fingerer::count()],
494 prev_upgrade_affordable: vec![false; UPGRADES.len()],
495 space_pressed_this_tick: false,
496 ticks_since_last_press: u32::MAX,
497 space_hold_ticks: 0,
498 displayed_cuques: 0.0,
499 displayed_fps: 0.0,
500 cuques_flash_ticks: 0,
501 cuques_spend_flash_ticks: 0,
502 }
503 }
504}
505
506impl GameState {
507 /// Initialize ephemeral runtime state that `#[serde(skip)]` left empty
508 /// after deserialization, and normalize any fields that need live values
509 /// rather than the serde default.
510 ///
511 /// **Runtime-only.** Persisted-shape migrations live in
512 /// `crate::save::versions::vN.rs` (see CLAUDE.md "Save versioning").
513 /// This method runs *after* the migration chain has produced a live
514 /// `GameState`; it must not assume any particular pre-state and must
515 /// be safe to call multiple times.
516 pub fn migrate_runtime(mut self) -> Self {
517 // `aggregate` is `#[serde(skip)]` — rebuild from the persisted
518 // `modifiers` list before any code reads `fps()`.
519 for st in self.fingerers_state.values_mut() {
520 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
521 }
522 // Per-catalog flash slots are runtime-only — re-size if the catalog
523 // grew/shrank since this save was written.
524 if self.fingerer_flash_ticks.len() != fingerer::count() {
525 self.fingerer_flash_ticks = vec![0; fingerer::count()];
526 }
527 if self.upgrade_flash_ticks.len() != UPGRADES.len() {
528 self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
529 }
530 if self.fingerer_unaffordable_flash.len() != fingerer::count() {
531 self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
532 }
533 if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
534 self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
535 }
536 if self.fingerer_unlock_flash.len() != fingerer::count() {
537 self.fingerer_unlock_flash = vec![0; fingerer::count()];
538 }
539 if self.upgrade_unlock_flash.len() != UPGRADES.len() {
540 self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
541 }
542 if self.fingerer_green_coin_flash.len() != fingerer::count() {
543 self.fingerer_green_coin_flash = vec![0; fingerer::count()];
544 }
545 // Seed `prev_affordable` from the LIVE state so a freshly-loaded
546 // save with rows already affordable doesn't fire spurious unlock
547 // flashes on tick 1. Resize if catalog grew/shrank.
548 if self.prev_fingerer_affordable.len() != fingerer::count() {
549 self.prev_fingerer_affordable =
550 (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
551 }
552 if self.prev_upgrade_affordable.len() != UPGRADES.len() {
553 self.prev_upgrade_affordable = (0..UPGRADES.len())
554 .map(|i| {
555 let u = &UPGRADES[i];
556 !self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
557 })
558 .collect();
559 }
560 // Re-seed any per-kind cooldown left at 0 (the array is
561 // `#[serde(skip)]` so it's already at default after deserialize;
562 // this is a defensive guard against saves that walked through an
563 // older shape that had per-cooldown layout drift).
564 for kind in PowerupKind::ALL {
565 let i = kind as usize;
566 if self.powerup_cooldowns[i] == 0 {
567 self.powerup_cooldowns[i] = powerup::next_cooldown(kind);
568 }
569 }
570 // Seed the count-up tween at the live values so a freshly-loaded save
571 // doesn't animate the HUD "from 0" up to whatever the player had.
572 self.displayed_cuques = self.cuques;
573 self.displayed_fps = 0.0; // recomputed on first tick
574 if self.purchase_flash_strength <= 0.0 {
575 self.purchase_flash_strength = 1.0;
576 }
577 self
578 }
579
580 // -- Catalog lookups (stable-id keyed) ---------------------------------
581
582 pub fn fingerer_count(&self, id: &str) -> u32 {
583 self.fingerers_state.get(id).map(|st| st.count).unwrap_or(0)
584 }
585
586 pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
587 FINGERERS
588 .get(idx)
589 .map(|f| self.fingerer_count(f.id))
590 .unwrap_or(0)
591 }
592
593 pub fn fingerers_owned_total(&self) -> u32 {
594 self.fingerers_state.values().map(|st| st.count).sum()
595 }
596
597 /// Return the cached modifier aggregate for `id`, or the identity
598 /// (`Default`) if the fingerer has no entry. Hot-path read for `fps()`
599 /// and the sidebar — never iterates the underlying `Vec<Modifier>`.
600 pub fn fingerer_aggregate(&self, id: &str) -> FingererAggregate {
601 self.fingerers_state
602 .get(id)
603 .map(|st| st.aggregate)
604 .unwrap_or_default()
605 }
606
607 /// Attach a modifier to the given fingerer id. Creates the
608 /// `FingererState` entry on the fly if absent (count stays 0). Rebuilds
609 /// the aggregate cache. Use this from goldens, debug cheats, future
610 /// events.
611 pub fn attach_modifier(&mut self, fingerer_id: &str, m: Modifier) {
612 let st = self
613 .fingerers_state
614 .entry(fingerer_id.to_string())
615 .or_default();
616 st.modifiers.push(m);
617 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
618 }
619
620 /// Pick a random fingerer with `count > 0` and attach `m` to it. Returns
621 /// the chosen id, or `None` if no fingerer is owned. Used by the Buff
622 /// Golden (Purple Coin), where targeting an un-owned tier is pointless
623 /// — a temporary x7 multiplier on a count of zero produces zero output.
624 pub fn attach_modifier_random_owned(&mut self, m: Modifier) -> Option<String> {
625 let owned: Vec<String> = self
626 .fingerers_state
627 .iter()
628 .filter(|(_, st)| st.count > 0)
629 .map(|(id, _)| id.clone())
630 .collect();
631 if owned.is_empty() {
632 return None;
633 }
634 let pick = owned[rand::rng().random_range(0..owned.len())].clone();
635 self.attach_modifier(&pick, m);
636 Some(pick)
637 }
638
639 /// Pick a random fingerer that is currently *visible in the sidebar*
640 /// — by the same `fingerer::visible` rule the UI uses (`idx == 0` ||
641 /// `owned > 0` || `lifetime_cuques >= base_cost * 0.5`) — and attach
642 /// `m` to it.
643 ///
644 /// Used by the Green Coin: a *permanent* +10% boost is still useful on
645 /// a tier the player can see but hasn't bought yet; when they finally
646 /// buy it the boost is already in place. Index Finger is always visible
647 /// (`idx == 0`), so as long as `FINGERERS` is non-empty this picks
648 /// something. Returns `None` only on an empty catalog (never in
649 /// practice).
650 pub fn attach_modifier_random_visible(&mut self, m: Modifier) -> Option<String> {
651 let visible: Vec<String> = FINGERERS
652 .iter()
653 .enumerate()
654 .filter(|(idx, f)| {
655 let owned = self.fingerer_count(f.id);
656 fingerer::visible(*idx, owned, self.lifetime_cuques)
657 })
658 .map(|(_, f)| f.id.to_string())
659 .collect();
660 if visible.is_empty() {
661 return None;
662 }
663 let pick = visible[rand::rng().random_range(0..visible.len())].clone();
664 self.attach_modifier(&pick, m);
665 Some(pick)
666 }
667
668 pub fn has_upgrade(&self, id: &str) -> bool {
669 self.upgrades_earned.contains(id)
670 }
671
672 pub fn has_achievement(&self, id: &str) -> bool {
673 self.achievements_earned.contains(id)
674 }
675
676 pub fn has_achievement_idx(&self, idx: usize) -> bool {
677 ACHIEVEMENTS
678 .get(idx)
679 .is_some_and(|a| self.has_achievement(a.id))
680 }
681
682 // -- Click / tick -------------------------------------------------------
683
684 pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
685 let power = self.click_power();
686 self.add_cuques(power);
687 self.total_clicks += 1;
688 self.clench_ticks = CLENCH_TICKS;
689 // Click that meaningfully grows the counter also flashes the HUD
690 // digits — a single +1 doesn't deserve the green tint, but a
691 // Frenzy click (FPS-scaled bonus, often hundreds-to-millions) or
692 // any bulk jump does.
693 if power >= 50.0 {
694 self.cuques_flash_ticks = HUD_FLASH_TICKS;
695 }
696 let mut rng = rand::rng();
697 // Wider random horizontal jitter (proportional to biscuit width) plus
698 // a small Y jitter so co-spawned particles don't overlap into "+1+1+1"
699 // mush at the same row. Per-particle drift_x continues the spread
700 // over the particle's life.
701 let jitter_x_range = (biscuit.width as i32 / 8).max(3);
702 let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
703 let jitter_y = rng.random_range(-1..=1);
704 let col = (origin.0 as i32 + jitter_x).max(0) as u16;
705 let row = origin
706 .1
707 .saturating_sub(1)
708 .saturating_add_signed(jitter_y as i16);
709 let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
710 let drift_x = rng.random_range(-0.012_f32..=0.012);
711 let frenzy_active = self
712 .buffs
713 .iter()
714 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
715 // Small numbers stay subtle; big ones (Frenzy, Cosmic mults) get a
716 // bold ClickBig style so they read as "this matters" against the
717 // chatter of auto-particles.
718 let kind = if power >= 50.0 || frenzy_active {
719 ParticleKind::ClickBig
720 } else {
721 ParticleKind::Click
722 };
723 self.particles.push(Particle {
724 frac_x,
725 frac_y,
726 life: PARTICLE_LIFE,
727 text: format!("+{}", crate::format::big(power)),
728 kind,
729 drift_x,
730 });
731 // Frenzy clicks also spawn a halo of `*` confetti to make every tap
732 // feel chaotic without altering game behavior.
733 if frenzy_active {
734 for _ in 0..2 {
735 let halo_x = rng.random_range(-0.05_f32..=0.05);
736 let halo_y = rng.random_range(-0.04_f32..=0.04);
737 let (hfx, hfy) =
738 screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
739 self.particles.push(Particle {
740 frac_x: (hfx + halo_x).clamp(0.0, 1.0),
741 frac_y: (hfy + halo_y).clamp(0.0, 1.0),
742 life: PARTICLE_LIFE / 2,
743 text: "*".into(),
744 kind: ParticleKind::Confetti,
745 drift_x: rng.random_range(-0.02_f32..=0.02),
746 });
747 }
748 }
749 }
750
751 /// Spawn a screen-anchored "·" particle at a click point that hit nothing
752 /// (biscuit dead zone, blank panel area, etc). Acknowledges that the
753 /// click registered without altering any game state.
754 pub fn spawn_misclick(&mut self, col: u16, row: u16) {
755 // Cap to avoid unbounded buildup if a player rage-clicks empty space.
756 if self.misclick_particles.len() >= 16 {
757 self.misclick_particles.remove(0);
758 }
759 self.misclick_particles.push(MisclickParticle {
760 col,
761 row,
762 life: MISCLICK_LIFE,
763 });
764 }
765
766 /// Spawn `n` confetti particles scattered over the biscuit. Used for
767 /// bulk-buy juice — a max-buy of a fingerer pops a small burst.
768 pub fn spawn_confetti(&mut self, n: u32) {
769 if n == 0 {
770 return;
771 }
772 let mut rng = rand::rng();
773 let glyphs = ['*', '+', '~', '.', 'o'];
774 for _ in 0..n.min(8) {
775 let glyph = glyphs[rng.random_range(0..glyphs.len())];
776 self.particles.push(Particle {
777 frac_x: rng.random_range(0.10_f32..=0.90),
778 frac_y: rng.random_range(0.20_f32..=0.85),
779 life: PARTICLE_LIFE,
780 text: glyph.to_string(),
781 kind: ParticleKind::Confetti,
782 drift_x: rng.random_range(-0.02_f32..=0.02),
783 });
784 }
785 }
786
787 pub fn click_power(&self) -> f64 {
788 let mut m = 1.0;
789 for u in UPGRADES.iter() {
790 if self.has_upgrade(u.id)
791 && let UpgradeEffect::ClickMult(f) = u.effect
792 {
793 m *= f;
794 }
795 }
796 // Per-click Frenzy bonus, FPS-scaled. The legacy `mult: 777.0`
797 // on `Buff::ClickFrenzy` is now ignored at click-time (kept on
798 // the struct only for V2/V3 save compatibility); each click
799 // during Frenzy adds a flat-or-fps-scaled bonus instead. This
800 // is what keeps an early-game Frenzy (FPS ≈ 0) capped at the
801 // FRENZY_FLAT floor (10 cuques/click) instead of trivializing
802 // the cost ladder via ×777, while a late-game Frenzy still
803 // delivers a ~12× income-rate boost during the buff via the
804 // FRENZY_FPS_SECONDS_PER_CLICK term.
805 let frenzy_active = self
806 .buffs
807 .iter()
808 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
809 if frenzy_active {
810 let bonus = (self.fps() * FRENZY_FPS_SECONDS_PER_CLICK).max(FRENZY_FLAT_PER_CLICK);
811 m + bonus
812 } else {
813 m
814 }
815 }
816
817 pub fn fingerer_mult(&self, idx: usize) -> f64 {
818 let Some(target) = FINGERERS.get(idx) else {
819 return 1.0;
820 };
821 let mut m = 1.0;
822 for u in UPGRADES.iter() {
823 if !self.has_upgrade(u.id) {
824 continue;
825 }
826 match u.effect {
827 UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
828 UpgradeEffect::AllFingerersMult(f) => m *= f,
829 _ => {}
830 }
831 }
832 // Per-fingerer Buff golden contributions used to live here as
833 // `Buff::FingererBoost`. They now flow through the modifier
834 // aggregate (see `fingerer_aggregate`), keeping `fingerer_mult`
835 // strictly about upgrades.
836 m
837 }
838
839 fn add_cuques(&mut self, amount: f64) {
840 self.cuques += amount;
841 self.lifetime_cuques += amount;
842 }
843
844 /// Dev-build cheat. Bypasses normal flow; not reachable in release builds
845 /// because the F-key that triggers it is gated behind `App::debug`.
846 pub fn dev_add_cuques(&mut self, amount: f64) {
847 self.add_cuques(amount);
848 self.cuques_flash_ticks = HUD_FLASH_TICKS;
849 }
850
851 /// Mint a fresh, monotonic spawn id. Session-scoped — wraps around at
852 /// `u64::MAX` (would take ~5 trillion years at one spawn per nanosecond,
853 /// so wrap collision isn't a real concern).
854 pub fn mint_spawn_id(&mut self) -> u64 {
855 let id = self.next_spawn_id;
856 self.next_spawn_id = self.next_spawn_id.wrapping_add(1);
857 id
858 }
859
860 /// Catch the powerup with the given `spawn_id`, if it's still on
861 /// screen. Applies the kind-specific effect, increments
862 /// `golden_caught` (lifetime grand total — keeps the existing
863 /// achievements working) plus the per-kind counter, and returns the
864 /// flat reward (only Lucky is non-zero).
865 ///
866 /// The Vec is unbounded; multiple powerups of the same kind can
867 /// coexist. Catching one never disturbs the others — `swap_remove`
868 /// is fine because input routes by `spawn_id`, not by Vec index.
869 ///
870 /// Per-kind cooldown is NOT touched here — it ticks independently
871 /// from spawns; a catch just removes the on-screen instance.
872 pub fn catch_powerup(&mut self, spawn_id: u64) -> f64 {
873 let Some(idx) = self.powerups.iter().position(|p| p.spawn_id == spawn_id) else {
874 return 0.0;
875 };
876 let p = self.powerups.swap_remove(idx);
877 self.golden_caught += 1;
878 let (reward, label) = match p.kind {
879 PowerupKind::Lucky => {
880 self.lucky_caught += 1;
881 let fps = self.fps();
882 let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
883 self.add_cuques(r);
884 self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
885 self.cuques_flash_ticks = HUD_FLASH_TICKS;
886 (r, format!("+{}", crate::format::big(r)))
887 }
888 PowerupKind::Frenzy => {
889 self.frenzy_caught += 1;
890 let dur = TICK_HZ * 13;
891 // `mult: 777.0` is a legacy field — `click_power()` no
892 // longer reads it. The per-click Frenzy bonus is FPS-
893 // scaled via `FRENZY_FPS_SECONDS_PER_CLICK` /
894 // `FRENZY_FLAT_PER_CLICK`. Kept on the struct so V2/V3
895 // saves with this field deserialize cleanly.
896 self.buffs.push(Buff::ClickFrenzy {
897 ticks_remaining: dur,
898 initial_ticks: dur,
899 mult: 777.0,
900 });
901 (0.0, "FRENZY!".into())
902 }
903 PowerupKind::Buff => {
904 self.buff_caught += 1;
905 let dur = TICK_HZ * 60;
906 let m = Modifier {
907 source: ModifierSource::PurpleCoin,
908 effects: vec![ModifierEffect::MulFactor(7.0)],
909 duration: ModifierDuration::Ticks(dur),
910 created_at_tick: self.total_play_ticks,
911 };
912 // Fall back to the first catalog tier if the player owns
913 // nothing yet — same defensive behavior the legacy buff
914 // had: a x7 mul on count=0 is wasted, so just attach
915 // somewhere visible to keep the modifier system honest.
916 if self.attach_modifier_random_owned(m.clone()).is_none() {
917 let pick = FINGERERS[0].id;
918 self.attach_modifier(pick, m);
919 }
920 (0.0, "BOOSTED x7!".into())
921 }
922 PowerupKind::GreenCoin => {
923 self.green_coin_caught += 1;
924 self.green_coin_flash_ticks = GREEN_COIN_FLASH_TICKS;
925 let m = Modifier {
926 source: ModifierSource::GreenCoin,
927 effects: vec![ModifierEffect::AddPercent(GREEN_COIN_ADD_PERCENT)],
928 duration: ModifierDuration::Permanent,
929 created_at_tick: self.total_play_ticks,
930 };
931 // Visible-set targeting: a permanent +10% can land on a
932 // sidebar-visible tier the player hasn't bought yet, so
933 // they get a head start when they finally afford it.
934 let chosen = self.attach_modifier_random_visible(m);
935 // `attach_modifier_random_visible` is only `None` if the
936 // visible set is empty, which requires both an empty
937 // `FINGERERS` catalog AND `lifetime_cuques < 0.5 * cost`
938 // for every tier. Index Finger is always visible
939 // (`fingerer::visible` short-circuits on `idx == 0`), so
940 // as long as `FINGERERS` is non-empty (currently 10
941 // entries, asserted as a project invariant in CLAUDE.md)
942 // this branch can't fire. Fail loud in dev so we notice
943 // if that invariant ever shifts.
944 debug_assert!(
945 chosen.is_some(),
946 "Green Coin catch found no visible fingerer — Index Finger should always be visible"
947 );
948 let label = match &chosen {
949 Some(id) => {
950 let idx = FINGERERS.iter().position(|f| f.id == id);
951 if let Some(i) = idx
952 && let Some(slot) = self.fingerer_green_coin_flash.get_mut(i)
953 {
954 *slot = GREEN_COIN_ROW_FLASH_TICKS;
955 }
956 let name = idx
957 .and_then(|i| crate::i18n::t().fingerer_names.get(i).copied())
958 .unwrap_or("?");
959 format!("+10% {}", name)
960 }
961 // Defensive fallback for release builds — render a
962 // neutral marker rather than panic if the invariant
963 // ever breaks. The debug_assert above catches it in
964 // dev.
965 None => "+10% ???".to_string(),
966 };
967 (0.0, label)
968 }
969 };
970 self.particles.push(Particle {
971 frac_x: p.frac_x,
972 frac_y: p.frac_y,
973 life: PARTICLE_LIFE * 2,
974 text: label,
975 kind: ParticleKind::Golden,
976 drift_x: 0.0,
977 });
978 reward
979 }
980
981 pub fn fps(&self) -> f64 {
982 // Per-fingerer formula:
983 // pre = (base * count + flat_fps) * upgrades_mult
984 // post = pre * (1 + add_percent) * mul_factor
985 // Reads use the cached aggregate — never iterate the modifiers Vec.
986 let base: f64 = FINGERERS
987 .iter()
988 .enumerate()
989 .map(|(i, k)| {
990 let count = self.fingerer_count(k.id) as f64;
991 let upgrades_mult = self.fingerer_mult(i);
992 let agg = self.fingerer_aggregate(k.id);
993 let pre = (k.fps_per_unit * count + agg.flat_fps) * upgrades_mult;
994 pre * (1.0 + agg.add_percent) * agg.mul_factor
995 })
996 .sum();
997 base * self.prestige_mult()
998 }
999
1000 pub fn border_speed(&self) -> u32 {
1001 let mut s: u32 = 1;
1002 for b in &self.buffs {
1003 match b {
1004 Buff::ClickFrenzy { .. } => s = s.max(3),
1005 }
1006 }
1007 // Active timed per-fingerer modifiers (PurpleCoin and friends)
1008 // bump the border one notch — same baseline the old
1009 // `Buff::FingererBoost` arm produced.
1010 if self.fingerers_state.values().any(|st| {
1011 st.modifiers
1012 .iter()
1013 .any(|m| matches!(m.duration, ModifierDuration::Ticks(_)))
1014 }) {
1015 s = s.max(2);
1016 }
1017 if self.lucky_flash_ticks > 0 {
1018 s = s.max(4);
1019 }
1020 if self.achievement_flash_ticks > 0 {
1021 s = s.max(3);
1022 }
1023 if self.purchase_flash_ticks > 0 {
1024 s += 2;
1025 }
1026 s
1027 }
1028
1029 /// Trigger the green purchase flash on the global border + the panel
1030 /// border. `strength` scales how loud the flash is (1.0 = single buy,
1031 /// up to 3.0 = bulk max-buy) so a max-buy lands harder than a +1.
1032 pub fn trigger_purchase_flash(&mut self, strength: f32) {
1033 self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
1034 // Take the louder of the in-flight strength and the new event so
1035 // back-to-back small buys don't squash a still-decaying loud one.
1036 self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
1037 }
1038
1039 pub fn prestige_mult(&self) -> f64 {
1040 1.0 + 0.01 * self.prestige as f64
1041 }
1042
1043 pub fn prestige_earned_total(&self) -> u64 {
1044 (self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
1045 }
1046
1047 pub fn prestige_available(&self) -> u64 {
1048 self.prestige_earned_total().saturating_sub(self.prestige)
1049 }
1050
1051 pub fn prestige_reset(&mut self) -> bool {
1052 let available = self.prestige_available();
1053 if available == 0 {
1054 return false;
1055 }
1056 self.prestige = self.prestige_earned_total();
1057 self.cuques = 0.0;
1058 // Don't snap `displayed_cuques` to 0 — let it tween down from
1059 // its pre-reset value over the next ~1s for a "draining"
1060 // feel. Same for FPS. The red spend-flash is fired below to
1061 // color the falling counter.
1062 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1063 // Wipe count AND modifiers — prestige resets the run, which is the
1064 // whole point. Permanent Green Coin boosts do not survive a prestige.
1065 self.fingerers_state.clear();
1066 self.upgrades_earned.clear();
1067 self.buffs.clear();
1068 self.visual_debt = 0.0;
1069 self.particles.clear();
1070 self.misclick_particles.clear();
1071 self.powerups.clear();
1072 self.next_spawn_id = 0;
1073 self.clench_ticks = 0;
1074 // Fresh per-kind cooldowns so the new run has its own independent
1075 // rhythm from tick 1.
1076 for kind in PowerupKind::ALL {
1077 self.powerup_cooldowns[kind as usize] = powerup::next_cooldown(kind);
1078 }
1079 true
1080 }
1081
1082 pub fn tick(&mut self) {
1083 // Per-fingerer modifier walk: decrement timed durations, drop expired
1084 // ones, rebuild the aggregate of any fingerer that lost a modifier.
1085 // Permanent modifiers are walked over but untouched. The walk runs
1086 // before the `buffs` walk so a coin caught this same tick already
1087 // ages by 1 — same convention as Buff::tick.
1088 for st in self.fingerers_state.values_mut() {
1089 let before = st.modifiers.len();
1090 st.modifiers.retain_mut(|m| match &mut m.duration {
1091 ModifierDuration::Permanent => true,
1092 ModifierDuration::Ticks(0) => false,
1093 ModifierDuration::Ticks(n) => {
1094 *n -= 1;
1095 true
1096 }
1097 });
1098 if before != st.modifiers.len() {
1099 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
1100 }
1101 }
1102
1103 for b in self.buffs.iter_mut() {
1104 b.tick();
1105 }
1106 self.buffs.retain(|b| b.ticks_remaining() > 0);
1107
1108 self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
1109 self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
1110 self.green_coin_flash_ticks = self.green_coin_flash_ticks.saturating_sub(1);
1111 self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
1112 if self.purchase_flash_ticks == 0 {
1113 self.purchase_flash_strength = 1.0;
1114 }
1115 self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
1116 self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
1117 for t in self.fingerer_flash_ticks.iter_mut() {
1118 *t = t.saturating_sub(1);
1119 }
1120 for t in self.upgrade_flash_ticks.iter_mut() {
1121 *t = t.saturating_sub(1);
1122 }
1123 for t in self.fingerer_unaffordable_flash.iter_mut() {
1124 *t = t.saturating_sub(1);
1125 }
1126 for t in self.upgrade_unaffordable_flash.iter_mut() {
1127 *t = t.saturating_sub(1);
1128 }
1129 for t in self.fingerer_unlock_flash.iter_mut() {
1130 *t = t.saturating_sub(1);
1131 }
1132 for t in self.upgrade_unlock_flash.iter_mut() {
1133 *t = t.saturating_sub(1);
1134 }
1135 for t in self.fingerer_green_coin_flash.iter_mut() {
1136 *t = t.saturating_sub(1);
1137 }
1138 // Held-spacebar streak with a small grace window. Real key-repeat
1139 // is bursty (~30Hz nominal but with OS-level jitter), so a strict
1140 // "every tick must see a press" test breaks on a single missed
1141 // tick. Instead: a press resets `ticks_since_last_press` to 0;
1142 // each tick increments it; the streak counts ticks that arrived
1143 // within the last ~150ms (3 ticks). Spamming with ≥150ms gaps
1144 // (human tap speed) never builds a streak. Genuine holding (key
1145 // repeat) keeps `ticks_since_last_press ≤ 1` and the streak
1146 // climbs by 1 every tick.
1147 if self.space_pressed_this_tick {
1148 self.ticks_since_last_press = 0;
1149 } else {
1150 self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
1151 }
1152 self.space_pressed_this_tick = false;
1153 const HOLD_GRACE_TICKS: u32 = 3; // ~150ms at 20Hz
1154 if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
1155 self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
1156 } else {
1157 self.space_hold_ticks = 0;
1158 }
1159 let speed = self.border_speed();
1160 self.border_phase = self.border_phase.wrapping_add(speed);
1161 self.steady_phase = self.steady_phase.wrapping_add(1);
1162
1163 let fps = self.fps();
1164 if fps > self.best_fps {
1165 self.best_fps = fps;
1166 }
1167 let gained = fps * TICK_DT;
1168 self.add_cuques(gained);
1169 self.visual_debt += gained;
1170 self.clench_ticks = self.clench_ticks.saturating_sub(1);
1171 for p in self.particles.iter_mut() {
1172 p.life = p.life.saturating_sub(1);
1173 p.frac_y -= PARTICLE_FRAC_RISE;
1174 // Per-particle horizontal drift so co-spawned particles spread
1175 // out over their lifetime instead of overlapping into garbage.
1176 p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
1177 }
1178 self.particles.retain(|p| p.life > 0);
1179 for m in self.misclick_particles.iter_mut() {
1180 m.life = m.life.saturating_sub(1);
1181 }
1182 self.misclick_particles.retain(|m| m.life > 0);
1183
1184 // K7: edge-detect false→true affordability flips and fire a brief
1185 // unlock flash on the row. Detection runs AFTER `add_cuques(gained)`
1186 // so an income-driven crossover lights up immediately. Two-pass to
1187 // keep the immutable reads (`can_buy`, `req.met`, etc.) cleanly
1188 // separated from the mutable writes to the flash + prev vecs.
1189 let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
1190 let upgrade_now: Vec<bool> = UPGRADES
1191 .iter()
1192 .map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
1193 .collect();
1194 for (i, &now) in fingerer_now.iter().enumerate() {
1195 let was = self
1196 .prev_fingerer_affordable
1197 .get(i)
1198 .copied()
1199 .unwrap_or(false);
1200 if now
1201 && !was
1202 && let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
1203 {
1204 *slot = UNLOCK_FLASH_TICKS;
1205 }
1206 if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
1207 *slot = now;
1208 }
1209 }
1210 for (i, &now) in upgrade_now.iter().enumerate() {
1211 let was = self
1212 .prev_upgrade_affordable
1213 .get(i)
1214 .copied()
1215 .unwrap_or(false);
1216 if now
1217 && !was
1218 && let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
1219 {
1220 *slot = UNLOCK_FLASH_TICKS;
1221 }
1222 if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
1223 *slot = now;
1224 }
1225 }
1226
1227 // Count-up tween: rendered numbers chase the real ones with
1228 // ease-out for BIG jumps (golden, F4, max-buy) so the eye can
1229 // track the rise. Small deltas snap — a single +1 manual click
1230 // would otherwise take ~30 ticks (1.5s) to finish tweening, AND
1231 // `format::big` floors the in-flight value, so the HUD shows "0"
1232 // for most of the climb. Counter-productive juice. The threshold
1233 // (`SNAP_BELOW`) is in absolute cuques: any change smaller than
1234 // ~5 cuques snaps instantly; bigger ones tween. The same
1235 // threshold applies to FPS for symmetry — small FPS deltas come
1236 // from buying a single fingerer, not worth a tween.
1237 const SNAP_BELOW: f64 = 5.0;
1238 let tween = 0.18_f64;
1239 let dc = self.cuques - self.displayed_cuques;
1240 if dc.abs() < SNAP_BELOW {
1241 self.displayed_cuques = self.cuques;
1242 } else {
1243 self.displayed_cuques += dc * tween;
1244 }
1245 let df = fps - self.displayed_fps;
1246 if df.abs() < SNAP_BELOW {
1247 self.displayed_fps = fps;
1248 } else {
1249 self.displayed_fps += df * tween;
1250 }
1251
1252 self.session_ticks += 1;
1253 self.total_play_ticks += 1;
1254 // Run the achievement check *before* the toast popper so an unlock
1255 // detected this tick can become the on-screen toast on the same
1256 // tick. Otherwise we'd waste the first tick of the toast's life
1257 // moving the unlock from the queue to active_unlock_id.
1258 self.tick_achievements();
1259
1260 // Toast queue: when no toast is on screen, pop the next pending
1261 // unlock id and schedule it for TOAST_TICKS. Every other tick
1262 // the active toast just decays.
1263 self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
1264 if self.active_unlock_ticks == 0 {
1265 self.active_unlock_id = None;
1266 if !self.newly_unlocked.is_empty() {
1267 self.active_unlock_id = Some(self.newly_unlocked.remove(0));
1268 self.active_unlock_ticks = TOAST_TICKS;
1269 self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
1270 }
1271 }
1272 }
1273
1274 pub fn tick_achievements(&mut self) {
1275 for a in ACHIEVEMENTS.iter() {
1276 if !self.has_achievement(a.id) && (a.unlocked)(self) {
1277 self.achievements_earned.insert(a.id.to_string());
1278 self.newly_unlocked.push(a.id.to_string());
1279 }
1280 }
1281 }
1282
1283 /// Tick every on-screen powerup down by one frame and decrement every
1284 /// per-kind cooldown. Expired entries (those that just hit 0) are
1285 /// dropped in place via `retain_mut`. Cooldowns tick **independently**
1286 /// of on-screen instances — multiple of the same kind can coexist, so
1287 /// freezing the clock while occupied would block the parallelism this
1288 /// refactor exists to enable.
1289 pub fn tick_powerups(&mut self) {
1290 self.powerups.retain_mut(|p| {
1291 if p.life_ticks == 0 {
1292 false
1293 } else {
1294 p.life_ticks -= 1;
1295 true
1296 }
1297 });
1298 for cd in self.powerup_cooldowns.iter_mut() {
1299 *cd = cd.saturating_sub(1);
1300 }
1301 }
1302
1303 pub fn trigger_clench(&mut self) {
1304 self.clench_ticks = CLENCH_TICKS;
1305 }
1306
1307 /// True when the spacebar has been held continuously for ≥ 1 second.
1308 /// Driven by `space_hold_ticks` (a streak counter that increments on
1309 /// every tick where at least one ClickCenter arrived, resets the
1310 /// instant a tick passes without one). Switches the biscuit's clench
1311 /// animation from a burning `*` to the spin frames `\ | / -`.
1312 pub fn space_held(&self) -> bool {
1313 self.space_hold_ticks >= TICK_HZ
1314 }
1315
1316 /// Spawn a "+N" particle representing cuques earned since the last
1317 /// auto-particle. Silently skips if there isn't a whole cuque of accrued
1318 /// income to show — at low FPS the caller is a rate-based timer that
1319 /// fires faster than cuques arrive, and spawning a "+1" in that window
1320 /// used to lie (particle flying up while the HUD counter didn't move).
1321 /// The shown amount is always real cuques that just accrued into
1322 /// `visual_debt`.
1323 pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
1324 let amount = self.visual_debt.floor() as u64;
1325 if amount == 0 {
1326 return;
1327 }
1328 self.visual_debt -= amount as f64;
1329 let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
1330 self.particles.push(Particle {
1331 frac_x,
1332 frac_y,
1333 life: PARTICLE_LIFE,
1334 text: format!("+{}", crate::format::big(amount as f64)),
1335 kind: ParticleKind::Auto,
1336 drift_x,
1337 });
1338 }
1339
1340 pub fn cost(&self, idx: usize) -> f64 {
1341 let k = &FINGERERS[idx];
1342 // Floor the result so the cost ALWAYS equals what `format::big`
1343 // shows the player. The price formula scales by 1.15× per owned
1344 // unit and produces fractional cuques (e.g. 15 × 1.15⁶ = 34.69).
1345 // Without flooring, the HUD says "Cuques: 34, cost 34" but the
1346 // affordability check `cuques >= 34.69` rejects — the player sees
1347 // a lie. Floor here keeps display, gate, and spend consistent at
1348 // the integer grain the player actually sees.
1349 let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
1350 raw.floor()
1351 }
1352
1353 /// Cuques the player can ACTUALLY spend right now: the lesser of real
1354 /// `cuques` and the displayed counter. Both bounds matter:
1355 ///
1356 /// - Gating ONLY on `cuques` (real) lets the row turn green and a
1357 /// click succeed before the counter visibly catches up — the
1358 /// "I have 8 but the row says I can buy a 17" lie.
1359 /// - Gating ONLY on `displayed_cuques.floor()` lets a click DRAIN
1360 /// real cuques NEGATIVE during a spend's tween-down: real already
1361 /// dropped, displayed hasn't caught down yet, gate sees the high
1362 /// displayed value and lets the buy through against the depleted
1363 /// real. Once `cuques` goes negative, the HUD floor() shows "0"
1364 /// for a long time while the slow income climbs back.
1365 ///
1366 /// Taking `min(real, displayed.floor())` makes both conditions
1367 /// equally binding: row turns green only when the visible counter
1368 /// AND the underlying balance both reach the cost; click succeeds
1369 /// only when both still hold. No overspend, no visual lie.
1370 pub fn affordable_cuques(&self) -> f64 {
1371 self.cuques.min(self.displayed_cuques.floor())
1372 }
1373
1374 pub fn can_buy(&self, idx: usize) -> bool {
1375 self.affordable_cuques() >= self.cost(idx)
1376 }
1377
1378 /// Buy a single unit. Bare mutation only — flash side-effects are
1379 /// scaled by quantity in `buy_n` / `buy_max` so a single buy and a
1380 /// bulk buy produce visually distinct feedback.
1381 fn buy_one_quiet(&mut self, idx: usize) -> bool {
1382 let c = self.cost(idx);
1383 // Use the same min(real, displayed) gate as `can_buy` so the
1384 // visible row state and the buy outcome agree, AND we never
1385 // spend more than `cuques` actually has. We do NOT snap
1386 // `displayed_cuques` to post-spend `cuques` — the existing tick
1387 // path tweens it down and the red spend flash colors that fall.
1388 if self.affordable_cuques() >= c
1389 && let Some(f) = FINGERERS.get(idx)
1390 {
1391 self.cuques -= c;
1392 self.fingerers_state
1393 .entry(f.id.to_string())
1394 .or_default()
1395 .count += 1;
1396 true
1397 } else {
1398 false
1399 }
1400 }
1401
1402 /// Apply purchase flash + per-row green flash, then optionally pop
1403 /// confetti. Called once per public buy action with the total bought
1404 /// count, so the loud bulk-buy feedback only fires once.
1405 fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
1406 if bought == 0 {
1407 return;
1408 }
1409 // 1 → 1.0, 10 → 1.7, 50 → 2.5, capped at 3.0. sqrt-style growth so
1410 // a max-buy is dramatic but doesn't blow the eardrums.
1411 let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
1412 self.trigger_purchase_flash(strength);
1413 match slot_table {
1414 PurchaseSlot::Fingerer => {
1415 if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
1416 *slot = PURCHASE_FLASH_TICKS;
1417 }
1418 }
1419 PurchaseSlot::Upgrade => {
1420 if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
1421 *slot = PURCHASE_FLASH_TICKS;
1422 }
1423 }
1424 }
1425 // A buy is a SPEND — it always fires the red HUD flash so the
1426 // counter dropping is visibly acknowledged. Earlier this slot
1427 // mistakenly used `cuques_flash_ticks` (the gain channel),
1428 // making big buys flash green even though cuques went DOWN.
1429 // Bulk buys also pop confetti for celebratory feel.
1430 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1431 if bought >= 5 {
1432 self.spawn_confetti(bought.min(8));
1433 }
1434 }
1435
1436 fn flash_unaffordable_fingerer(&mut self, idx: usize) {
1437 if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
1438 *slot = PURCHASE_FLASH_TICKS / 2;
1439 }
1440 }
1441
1442 fn flash_unaffordable_upgrade(&mut self, idx: usize) {
1443 if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
1444 *slot = PURCHASE_FLASH_TICKS / 2;
1445 }
1446 }
1447
1448 pub fn buy(&mut self, idx: usize) -> bool {
1449 if self.buy_one_quiet(idx) {
1450 self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
1451 true
1452 } else {
1453 self.flash_unaffordable_fingerer(idx);
1454 false
1455 }
1456 }
1457
1458 pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
1459 let mut bought = 0;
1460 for _ in 0..n {
1461 if !self.buy_one_quiet(idx) {
1462 break;
1463 }
1464 bought += 1;
1465 }
1466 if bought == 0 {
1467 self.flash_unaffordable_fingerer(idx);
1468 } else {
1469 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1470 }
1471 bought
1472 }
1473
1474 pub fn buy_max(&mut self, idx: usize) -> u32 {
1475 let mut bought = 0;
1476 while self.buy_one_quiet(idx) {
1477 bought += 1;
1478 }
1479 if bought == 0 {
1480 self.flash_unaffordable_fingerer(idx);
1481 } else {
1482 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1483 }
1484 bought
1485 }
1486
1487 pub fn buy_upgrade(&mut self, idx: usize) -> bool {
1488 let Some(u) = UPGRADES.get(idx) else {
1489 return false;
1490 };
1491 if self.has_upgrade(u.id) {
1492 return false;
1493 }
1494 // Same min(real, displayed) gate as fingerer buys — see
1495 // `affordable_cuques` for why both bounds matter.
1496 if !u.req.met(self) || self.affordable_cuques() < u.cost {
1497 self.flash_unaffordable_upgrade(idx);
1498 return false;
1499 }
1500 self.cuques -= u.cost;
1501 self.upgrades_earned.insert(u.id.to_string());
1502 self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
1503 true
1504 }
1505}
1506
1507#[derive(Clone, Copy)]
1508enum PurchaseSlot {
1509 Fingerer,
1510 Upgrade,
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use super::*;
1516 use crate::game::modifier::{Modifier, ModifierEffect, ModifierSource};
1517
1518 fn fs_with_count(count: u32) -> FingererState {
1519 FingererState {
1520 count,
1521 ..Default::default()
1522 }
1523 }
1524
1525 #[test]
1526 fn migrate_is_idempotent_on_current_shape() {
1527 let state = GameState {
1528 fingerers_state: [("index_finger".to_string(), fs_with_count(9))]
1529 .into_iter()
1530 .collect(),
1531 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1532 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1533 ..GameState::default()
1534 };
1535
1536 let m = state.migrate_runtime();
1537
1538 assert_eq!(m.fingerer_count("index_finger"), 9);
1539 assert!(m.has_upgrade("click_mult_1"));
1540 assert!(m.has_achievement("first_finger"));
1541 }
1542
1543 #[test]
1544 fn unknown_ids_in_save_are_ignored_not_resurrected() {
1545 // Forward-compat: a future version adds `"giga_finger"` to the
1546 // catalog, player plays, saves. User downgrades to current version.
1547 // That unknown id must not crash — it just reads as 0.
1548 let state = GameState {
1549 fingerers_state: [("giga_finger_from_the_future".to_string(), fs_with_count(42))]
1550 .into_iter()
1551 .collect(),
1552 ..GameState::default()
1553 };
1554
1555 let m = state.migrate_runtime();
1556
1557 assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
1558 assert_eq!(m.fingerer_count("index_finger"), 0);
1559 assert!(!m.has_upgrade("click_mult_1"));
1560 }
1561
1562 #[test]
1563 fn save_roundtrip_is_stable_through_json() {
1564 // Serialize → deserialize → get the same state back. Catches any
1565 // accidental rename that would make saves non-idempotent.
1566 let state = GameState {
1567 cuques: 1234.5,
1568 total_clicks: 99,
1569 fingerers_state: [("index_finger".to_string(), fs_with_count(7))]
1570 .into_iter()
1571 .collect(),
1572 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1573 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1574 ..GameState::default()
1575 };
1576
1577 let json = serde_json::to_string(&state).expect("serialize");
1578 let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
1579 let m = roundtripped.migrate_runtime();
1580
1581 assert_eq!(m.cuques, 1234.5);
1582 assert_eq!(m.total_clicks, 99);
1583 assert_eq!(m.fingerer_count("index_finger"), 7);
1584 assert!(m.has_upgrade("click_mult_1"));
1585 assert!(m.has_achievement("first_finger"));
1586 }
1587
1588 fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
1589 Rect {
1590 x,
1591 y,
1592 width: w,
1593 height: h,
1594 }
1595 }
1596
1597 #[test]
1598 fn frac_screen_roundtrip_at_corners() {
1599 let biscuit = r(10, 5, 40, 20);
1600 // top-left corner
1601 let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
1602 assert!(fx <= 0.001 && fy <= 0.001);
1603 let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
1604 assert_eq!((col, row), (10, 5));
1605
1606 // bottom-right (one beyond, clamps)
1607 let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
1608 assert!(fx >= 0.999 && fy >= 0.999);
1609
1610 // exact center
1611 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
1612 assert_eq!(col, 30);
1613 assert_eq!(row, 15);
1614 }
1615
1616 #[test]
1617 fn frac_position_survives_biscuit_move() {
1618 // A point at fraction (0.25, 0.5) of the biscuit must resolve to a
1619 // proportionally-shifted absolute coord when the biscuit moves /
1620 // grows.
1621 let small = r(0, 0, 40, 20);
1622 let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
1623 let large = r(10, 5, 80, 40);
1624 let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
1625 // Same fractional spot, very different screen coords.
1626 assert_ne!((col_a, row_a), (col_b, row_b));
1627 // And the shifted point should still sit at the 25%/50% mark of the
1628 // new rect.
1629 assert_eq!(col_b, 30); // 10 + 0.25 * 80
1630 assert_eq!(row_b, 25); // 5 + 0.5 * 40
1631 }
1632
1633 #[test]
1634 fn zero_size_biscuit_doesnt_panic() {
1635 let zero = r(0, 0, 0, 0);
1636 let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
1637 assert_eq!((fx, fy), (0.5, 0.5));
1638 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
1639 assert_eq!((col, row), (0, 0));
1640 }
1641
1642 // -- Juice-flash invariants ---------------------------------------------
1643
1644 #[test]
1645 fn buy_when_broke_sets_unaffordable_flash() {
1646 // Player clicks an unaffordable fingerer row → buy() returns false
1647 // AND a red row flash is queued so the rejection is visible. This
1648 // is the J11 contract; without it the click looks silent.
1649 // (Default already zeroes `cuques`; no explicit reset needed.)
1650 let mut s = GameState::default();
1651 let bought = s.buy(0);
1652 assert!(!bought);
1653 assert!(
1654 s.fingerer_unaffordable_flash[0] > 0,
1655 "buy(0) on broke state must flash red"
1656 );
1657 assert!(
1658 s.fingerer_flash_ticks[0] == 0,
1659 "no purchase flash on reject"
1660 );
1661 }
1662
1663 #[test]
1664 fn buy_n_when_broke_sets_unaffordable_flash() {
1665 let mut s = GameState::default();
1666 let bought = s.buy_n(0, 10);
1667 assert_eq!(bought, 0);
1668 assert!(s.fingerer_unaffordable_flash[0] > 0);
1669 }
1670
1671 #[test]
1672 fn bulk_buy_scales_purchase_flash_strength() {
1673 // J8: max-buy is louder than a +1. We don't pin exact values (clamp
1674 // boundaries are tuning), only the relative ordering and bounds.
1675 // `displayed_cuques` must mirror `cuques` here because buy()'s
1676 // affordability gate now reads displayed (matches the visible
1677 // counter on the HUD) — a default-constructed test state has
1678 // displayed=0 and would otherwise reject every buy.
1679 let mut s = GameState {
1680 cuques: 1_000_000.0,
1681 displayed_cuques: 1_000_000.0,
1682 ..Default::default()
1683 };
1684 s.buy(0);
1685 let single = s.purchase_flash_strength;
1686 assert!((1.0..=3.0).contains(&single));
1687
1688 let mut s = GameState {
1689 cuques: 1_000_000.0,
1690 displayed_cuques: 1_000_000.0,
1691 ..Default::default()
1692 };
1693 s.buy_n(0, 50);
1694 let bulk = s.purchase_flash_strength;
1695 assert!(
1696 bulk > single,
1697 "bulk strength must exceed single ({bulk} vs {single})"
1698 );
1699 assert!(bulk <= 3.0, "bulk strength capped at 3.0");
1700 }
1701
1702 #[test]
1703 fn buy_upgrade_when_broke_sets_unaffordable_flash() {
1704 let mut s = GameState::default();
1705 // Pick the cheapest upgrade and try to buy with no money.
1706 let cheapest_idx = (0..UPGRADES.len())
1707 .min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
1708 .unwrap();
1709 let bought = s.buy_upgrade(cheapest_idx);
1710 assert!(!bought);
1711 assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
1712 }
1713
1714 #[test]
1715 fn migrate_resizes_per_catalog_flash_vecs() {
1716 // A serialized state from "before this branch shipped" has empty /
1717 // skipped flash vecs after deserialize. migrate() must size them to
1718 // the live catalog so paint paths can index without bounds checks
1719 // in hot loops.
1720 let json = serde_json::to_string(&GameState::default()).unwrap();
1721 let mut s: GameState = serde_json::from_str(&json).unwrap();
1722 // Simulate stale shape: drop the per-catalog vecs.
1723 s.fingerer_flash_ticks.clear();
1724 s.upgrade_flash_ticks.clear();
1725 s.fingerer_unaffordable_flash.clear();
1726 s.upgrade_unaffordable_flash.clear();
1727 let m = s.migrate_runtime();
1728 assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
1729 assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
1730 assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
1731 assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
1732 }
1733
1734 #[test]
1735 fn migrate_seeds_displayed_counters() {
1736 // J5 contract: a freshly-loaded save shows the live counters at full
1737 // value, not "tweening up from zero".
1738 let s = GameState {
1739 cuques: 5_000.0,
1740 ..Default::default()
1741 };
1742 let m = s.migrate_runtime();
1743 assert_eq!(m.displayed_cuques, 5_000.0);
1744 // displayed_fps starts at 0 and converges over the first few ticks
1745 // (otherwise we'd snap-show the FPS before any tick has run).
1746 assert_eq!(m.displayed_fps, 0.0);
1747 }
1748
1749 #[test]
1750 fn unlock_pop_sets_active_toast_and_gold_flash() {
1751 // J1 contract: when an achievement triggers, tick() drains
1752 // newly_unlocked into active_unlock_id and lights the gold border
1753 // channel.
1754 let mut s = GameState::default();
1755 // Force a "First Finger" unlock by simulating one click.
1756 let biscuit = r(0, 0, 40, 20);
1757 s.click((20, 10), biscuit);
1758 s.tick();
1759 // The fresh tick should have moved the queued unlock onto the screen.
1760 assert!(s.active_unlock_id.is_some());
1761 assert!(s.active_unlock_ticks > 0);
1762 assert!(s.achievement_flash_ticks > 0);
1763 }
1764
1765 // -- Modifier system ----------------------------------------------------
1766
1767 fn perm_add_percent(pct: f64) -> Modifier {
1768 Modifier {
1769 source: ModifierSource::GreenCoin,
1770 effects: vec![ModifierEffect::AddPercent(pct)],
1771 duration: ModifierDuration::Permanent,
1772 created_at_tick: 0,
1773 }
1774 }
1775
1776 fn timed_mul(mult: f64, ticks: u32) -> Modifier {
1777 Modifier {
1778 source: ModifierSource::PurpleCoin,
1779 effects: vec![ModifierEffect::MulFactor(mult)],
1780 duration: ModifierDuration::Ticks(ticks),
1781 created_at_tick: 0,
1782 }
1783 }
1784
1785 #[test]
1786 fn attach_modifier_rebuilds_aggregate() {
1787 let mut s = GameState::default();
1788 s.fingerers_state
1789 .insert("index_finger".into(), fs_with_count(1));
1790 s.attach_modifier("index_finger", perm_add_percent(0.10));
1791 let agg = s.fingerer_aggregate("index_finger");
1792 assert!((agg.add_percent - 0.10).abs() < 1e-9);
1793
1794 // Stacking: a second modifier sums into the same aggregate.
1795 s.attach_modifier("index_finger", perm_add_percent(0.10));
1796 let agg = s.fingerer_aggregate("index_finger");
1797 assert!((agg.add_percent - 0.20).abs() < 1e-9);
1798 }
1799
1800 #[test]
1801 fn attach_modifier_creates_state_entry_if_absent() {
1802 // Attaching to a fingerer the player doesn't own creates a zero-count
1803 // entry rather than silently dropping the modifier. (Production code
1804 // pairs `attach_modifier_random_owned` with the count > 0 filter, so
1805 // this only matters when something explicitly targets a tier.)
1806 let mut s = GameState::default();
1807 s.attach_modifier("hand_of_god", perm_add_percent(0.10));
1808 let st = s.fingerers_state.get("hand_of_god").expect("entry exists");
1809 assert_eq!(st.count, 0);
1810 assert_eq!(st.modifiers.len(), 1);
1811 }
1812
1813 #[test]
1814 fn attach_modifier_random_owned_picks_only_owned() {
1815 let mut s = GameState::default();
1816 s.fingerers_state
1817 .insert("index_finger".into(), fs_with_count(5));
1818 // Add an empty entry for an unowned fingerer; the picker must skip it.
1819 s.fingerers_state
1820 .insert("hand_of_god".into(), fs_with_count(0));
1821 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1822 assert_eq!(chosen.as_deref(), Some("index_finger"));
1823 }
1824
1825 #[test]
1826 fn attach_modifier_random_owned_returns_none_when_nothing_owned() {
1827 let mut s = GameState::default();
1828 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1829 assert!(chosen.is_none());
1830 // No entries created — random_owned doesn't have a target to pick.
1831 assert!(s.fingerers_state.is_empty());
1832 }
1833
1834 #[test]
1835 fn tick_decrements_timed_modifiers() {
1836 let mut s = GameState::default();
1837 s.fingerers_state
1838 .insert("index_finger".into(), fs_with_count(1));
1839 s.attach_modifier("index_finger", timed_mul(2.0, 5));
1840 s.tick();
1841 let st = s.fingerers_state.get("index_finger").unwrap();
1842 assert_eq!(st.modifiers.len(), 1);
1843 assert!(matches!(
1844 st.modifiers[0].duration,
1845 ModifierDuration::Ticks(4)
1846 ));
1847 }
1848
1849 #[test]
1850 fn tick_removes_expired_and_rebuilds_aggregate() {
1851 let mut s = GameState::default();
1852 s.fingerers_state
1853 .insert("index_finger".into(), fs_with_count(1));
1854 s.attach_modifier("index_finger", timed_mul(2.0, 1));
1855 // First tick: Ticks(1) → Ticks(0), still present.
1856 s.tick();
1857 assert_eq!(
1858 s.fingerers_state
1859 .get("index_finger")
1860 .unwrap()
1861 .modifiers
1862 .len(),
1863 1
1864 );
1865 // Second tick: Ticks(0) is dropped, aggregate rebuilt to identity.
1866 s.tick();
1867 let st = s.fingerers_state.get("index_finger").unwrap();
1868 assert_eq!(st.modifiers.len(), 0);
1869 assert!((st.aggregate.mul_factor - 1.0).abs() < 1e-9);
1870 }
1871
1872 #[test]
1873 fn permanent_modifier_does_not_decrement() {
1874 let mut s = GameState::default();
1875 s.fingerers_state
1876 .insert("index_finger".into(), fs_with_count(1));
1877 s.attach_modifier("index_finger", perm_add_percent(0.10));
1878 for _ in 0..50 {
1879 s.tick();
1880 }
1881 let st = s.fingerers_state.get("index_finger").unwrap();
1882 assert_eq!(st.modifiers.len(), 1);
1883 assert!(matches!(
1884 st.modifiers[0].duration,
1885 ModifierDuration::Permanent
1886 ));
1887 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1888 }
1889
1890 #[test]
1891 fn prestige_reset_clears_modifiers() {
1892 // Prestige resets the run — permanent Green Coin boosts must not
1893 // survive. Otherwise a prestiged player would carry +N% on tier-1
1894 // forever.
1895 let mut s = GameState {
1896 lifetime_cuques: 1_000_000_000.0,
1897 ..Default::default()
1898 };
1899 s.fingerers_state
1900 .insert("index_finger".into(), fs_with_count(5));
1901 s.attach_modifier("index_finger", perm_add_percent(0.30));
1902 assert!(s.prestige_reset());
1903 assert!(s.fingerers_state.is_empty());
1904 }
1905
1906 #[test]
1907 fn fps_uses_aggregate_add_percent() {
1908 // Same fingerer count, +10% AddPercent modifier → fps 10% higher.
1909 let mut bare = GameState::default();
1910 bare.fingerers_state
1911 .insert("index_finger".into(), fs_with_count(1));
1912 let bare_fps = bare.fps();
1913
1914 let mut boosted = GameState::default();
1915 boosted
1916 .fingerers_state
1917 .insert("index_finger".into(), fs_with_count(1));
1918 boosted.attach_modifier("index_finger", perm_add_percent(0.10));
1919 let boosted_fps = boosted.fps();
1920
1921 assert!(bare_fps > 0.0);
1922 assert!((boosted_fps - bare_fps * 1.10).abs() < 1e-9);
1923 }
1924
1925 #[test]
1926 fn migrate_runtime_rebuilds_aggregate_after_serde_skip() {
1927 // The aggregate field is `#[serde(skip)]`; a state freshly
1928 // deserialized from JSON has it at the identity Default. Running
1929 // migrate_runtime() must reconstitute it from the modifier list.
1930 let mut s = GameState::default();
1931 s.fingerers_state.insert(
1932 "index_finger".into(),
1933 FingererState {
1934 count: 1,
1935 modifiers: vec![perm_add_percent(0.25)],
1936 aggregate: FingererAggregate::default(), // simulate post-deserialize
1937 },
1938 );
1939 let m = s.migrate_runtime();
1940 let agg = m.fingerer_aggregate("index_finger");
1941 assert!((agg.add_percent - 0.25).abs() < 1e-9);
1942 }
1943
1944 // -- Powerups -----------------------------------------------------------
1945
1946 use crate::game::powerup::{Powerup, PowerupKind};
1947
1948 fn fake_powerup(state: &mut GameState, kind: PowerupKind) -> u64 {
1949 let id = state.mint_spawn_id();
1950 state.powerups.push(Powerup {
1951 kind,
1952 spawn_id: id,
1953 frac_x: 0.5,
1954 frac_y: 0.5,
1955 life_ticks: kind.lifetime_ticks(),
1956 });
1957 id
1958 }
1959
1960 #[test]
1961 fn catch_green_coin_increments_grand_total_and_per_variant_counter() {
1962 let mut s = GameState::default();
1963 s.fingerers_state
1964 .insert("index_finger".into(), fs_with_count(1));
1965 let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
1966 s.catch_powerup(id);
1967 assert_eq!(s.golden_caught, 1, "rollup increments");
1968 assert_eq!(s.green_coin_caught, 1, "per-variant increments");
1969 assert_eq!(s.lucky_caught, 0);
1970 assert_eq!(s.frenzy_caught, 0);
1971 assert_eq!(s.buff_caught, 0);
1972 }
1973
1974 #[test]
1975 fn catch_green_coin_attaches_permanent_modifier() {
1976 let mut s = GameState::default();
1977 s.fingerers_state
1978 .insert("index_finger".into(), fs_with_count(3));
1979 let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
1980
1981 s.catch_powerup(id);
1982
1983 assert!(s.powerups.is_empty());
1984 let st = s.fingerers_state.get("index_finger").unwrap();
1985 assert_eq!(st.modifiers.len(), 1);
1986 let m = &st.modifiers[0];
1987 assert!(matches!(m.source, ModifierSource::GreenCoin));
1988 assert!(matches!(m.duration, ModifierDuration::Permanent));
1989 assert!(matches!(
1990 m.effects[0],
1991 ModifierEffect::AddPercent(v) if (v - 0.10).abs() < 1e-9
1992 ));
1993 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1994 }
1995
1996 #[test]
1997 fn catch_green_coin_with_no_owned_lands_on_index_finger() {
1998 // Visible-set targeting means even on a brand-new save a Green Coin
1999 // attaches somewhere — Index Finger is always visible (`idx == 0`
2000 // short-circuits in `fingerer::visible`).
2001 let mut s = GameState::default();
2002 let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
2003
2004 s.catch_powerup(id);
2005
2006 assert!(s.powerups.is_empty());
2007 let st = s
2008 .fingerers_state
2009 .get(FINGERERS[0].id)
2010 .expect("modifier landed on Index Finger");
2011 assert_eq!(st.modifiers.len(), 1);
2012 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
2013 }
2014
2015 #[test]
2016 fn attach_modifier_random_visible_can_pick_unowned_when_lifetime_unlocks_it() {
2017 let mut s = GameState {
2018 lifetime_cuques: 60.0,
2019 ..Default::default()
2020 };
2021 let m = perm_add_percent(0.10);
2022 let chosen = s.attach_modifier_random_visible(m);
2023 let id = chosen.expect("at least one visible fingerer always exists");
2024 let visible_ids: Vec<&str> = FINGERERS
2025 .iter()
2026 .enumerate()
2027 .filter(|(idx, f)| {
2028 fingerer::visible(*idx, 0, s.lifetime_cuques) && (*idx == 0 || f.id == "whole_hand")
2029 })
2030 .map(|(_, f)| f.id)
2031 .collect();
2032 assert!(visible_ids.contains(&id.as_str()));
2033 }
2034
2035 #[test]
2036 fn catch_powerup_returns_zero_when_id_unknown() {
2037 let mut s = GameState::default();
2038 assert_eq!(s.catch_powerup(9_999), 0.0);
2039 }
2040
2041 #[test]
2042 fn tick_powerups_decrements_lifetime_and_drops_at_zero() {
2043 let mut s = GameState::default();
2044 let id = s.mint_spawn_id();
2045 s.powerups.push(Powerup {
2046 kind: PowerupKind::GreenCoin,
2047 spawn_id: id,
2048 frac_x: 0.5,
2049 frac_y: 0.5,
2050 life_ticks: 2,
2051 });
2052 // Mirrors the legacy `tick_golden` cadence: each tick decrements
2053 // by 1; the entry survives the tick that brings life_ticks to 0,
2054 // and the *next* tick drops it. Same convention the renderer
2055 // relied on (one final visible frame before disappearance).
2056 s.tick_powerups();
2057 assert_eq!(s.powerups[0].life_ticks, 1);
2058 s.tick_powerups();
2059 assert_eq!(s.powerups[0].life_ticks, 0);
2060 s.tick_powerups();
2061 assert!(s.powerups.is_empty());
2062 }
2063
2064 #[test]
2065 fn green_coin_stacks_additively_on_repeat_catches() {
2066 // Two Green Coins on the same fingerer = +20%, not +21%.
2067 let mut s = GameState::default();
2068 s.fingerers_state
2069 .insert("index_finger".into(), fs_with_count(1));
2070 for _ in 0..2 {
2071 let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
2072 s.catch_powerup(id);
2073 }
2074 let st = s.fingerers_state.get("index_finger").unwrap();
2075 // RNG randomly picks the only owned fingerer both times.
2076 assert_eq!(st.modifiers.len(), 2);
2077 assert!((st.aggregate.add_percent - 0.20).abs() < 1e-9);
2078 }
2079
2080 #[test]
2081 fn prestige_reset_clears_powerup_state() {
2082 let mut s = GameState {
2083 lifetime_cuques: 1_000_000_000.0,
2084 ..Default::default()
2085 };
2086 s.fingerers_state
2087 .insert("index_finger".into(), fs_with_count(1));
2088 let _ = fake_powerup(&mut s, PowerupKind::Lucky);
2089 let _ = fake_powerup(&mut s, PowerupKind::GreenCoin);
2090 s.prestige_reset();
2091 assert!(s.powerups.is_empty());
2092 assert_eq!(s.next_spawn_id, 0);
2093 }
2094
2095 #[test]
2096 fn catch_powerup_only_removes_targeted_id() {
2097 // Multiple powerups of mixed kinds coexist; catching one by id
2098 // leaves the others untouched (no Vec-index aliasing).
2099 let mut s = GameState::default();
2100 s.fingerers_state
2101 .insert("index_finger".into(), fs_with_count(1));
2102 let lucky_id = fake_powerup(&mut s, PowerupKind::Lucky);
2103 let frenzy_id = fake_powerup(&mut s, PowerupKind::Frenzy);
2104 let buff_id = fake_powerup(&mut s, PowerupKind::Buff);
2105 s.catch_powerup(frenzy_id);
2106 let remaining: Vec<u64> = s.powerups.iter().map(|p| p.spawn_id).collect();
2107 assert_eq!(remaining.len(), 2);
2108 assert!(remaining.contains(&lucky_id));
2109 assert!(remaining.contains(&buff_id));
2110 }
2111
2112 #[test]
2113 fn buff_stacks_multiplicatively_on_same_fingerer() {
2114 // Two Buff catches on one fingerer = MulFactor 7² = 49 for the
2115 // duration. The modifier system already supports this; assert at
2116 // this layer that nothing in the catch path caps it.
2117 let mut s = GameState::default();
2118 s.fingerers_state
2119 .insert("index_finger".into(), fs_with_count(1));
2120 for _ in 0..2 {
2121 let id = fake_powerup(&mut s, PowerupKind::Buff);
2122 s.catch_powerup(id);
2123 }
2124 let st = s.fingerers_state.get("index_finger").unwrap();
2125 assert_eq!(st.modifiers.len(), 2);
2126 assert!((st.aggregate.mul_factor - 49.0).abs() < 1e-9);
2127 }
2128
2129 #[test]
2130 fn mint_spawn_id_is_monotonic() {
2131 let mut s = GameState::default();
2132 let a = s.mint_spawn_id();
2133 let b = s.mint_spawn_id();
2134 let c = s.mint_spawn_id();
2135 assert_eq!(a, 0);
2136 assert_eq!(b, 1);
2137 assert_eq!(c, 2);
2138 }
2139
2140 #[test]
2141 fn green_coin_catch_always_has_a_target_on_fresh_state() {
2142 // Index Finger is always visible by the `idx == 0` short-circuit
2143 // in `fingerer::visible`, so the Green Coin catch path's
2144 // attach-modifier-random-visible branch never returns None. This
2145 // test enforces the invariant: the catch's particle label must
2146 // never be the "+10% ???" fallback for a default game state.
2147 // (The debug_assert in catch_powerup also guards this in dev,
2148 // but a unit test catches it in release builds too.)
2149 let mut s = GameState::default();
2150 let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
2151 s.catch_powerup(id);
2152 // The most recent particle is the catch label.
2153 let label = &s.particles.last().expect("catch spawns a particle").text;
2154 assert!(
2155 !label.contains("???"),
2156 "GreenCoin catch produced unreachable '+10% ???' fallback: {label}"
2157 );
2158 assert!(
2159 label.starts_with("+10% "),
2160 "GreenCoin catch label must start with '+10% ', got {label}"
2161 );
2162 }
2163
2164 #[test]
2165 fn frenzy_click_yield_is_bounded_in_early_game() {
2166 // Balance regression guard. A single Frenzy on a fresh save
2167 // (FPS=0, click_power=1) used to mint ~30k cuques across 13s of
2168 // clicks — enough to unlock 5-6 fingerer tiers, trivializing
2169 // the cost ladder. With the FPS-scaled per-click bonus, the
2170 // floor is `FRENZY_FLAT_PER_CLICK` (10/click). 13s ≈ 39 clicks
2171 // → ~390 cuques cap. That's enough to buy Index Finger and a
2172 // couple of Tier-1s, but Tier 2+ (cost 1100+) stays gated until
2173 // FPS comes online.
2174 let mut s = GameState::default();
2175 let biscuit = r(0, 0, 40, 20);
2176 // Activate Frenzy, then simulate 13s × 20Hz = 260 ticks of
2177 // clicking once per tick (40 clicks at 3 clicks/sec ≈ 39, but
2178 // we click every tick to be conservative — that's actually
2179 // 260 clicks if we let it run a full buff lifetime).
2180 s.buffs.push(Buff::ClickFrenzy {
2181 ticks_remaining: TICK_HZ * 13,
2182 initial_ticks: TICK_HZ * 13,
2183 mult: 777.0,
2184 });
2185 // Simulate human click cadence: one click every 5 ticks (4Hz)
2186 // for the buff's lifetime. That's the fastest human-sustainable
2187 // tap rate.
2188 let mut clicks = 0;
2189 for _ in 0..(TICK_HZ * 13) {
2190 // Sample every fifth tick.
2191 if clicks * 5 < s.total_play_ticks as u32 + 5 {
2192 s.click((20, 10), biscuit);
2193 clicks += 1;
2194 }
2195 s.tick();
2196 }
2197 assert!(
2198 s.cuques < 2_000.0,
2199 "early-game Frenzy must not blow past ~1k cuques; got {}",
2200 s.cuques
2201 );
2202 // Sanity: the buff did boost something (more than zero clicks
2203 // worth of base power = 1).
2204 assert!(
2205 s.cuques > clicks as f64,
2206 "Frenzy should still meaningfully boost clicks; got {} from {} clicks",
2207 s.cuques,
2208 clicks
2209 );
2210 }
2211
2212 #[test]
2213 fn frenzy_click_yield_scales_with_fps_late_game() {
2214 // Late-game contract: at high FPS, each Frenzy click is worth
2215 // roughly `fps * FRENZY_FPS_SECONDS_PER_CLICK` (5s of FPS per
2216 // click). Set up a state where fps() returns ~1000 by giving
2217 // many fingerers, then assert the per-click yield is the
2218 // scaled term, not the flat floor.
2219 let mut s = GameState::default();
2220 // Buy enough mid-tier fingerers to push fps into the thousands.
2221 // Whole Hand (idx 1) = 1.0 fps each; 2000 of them = 2000 fps.
2222 // (This skips visibility/cost gating since we mutate the count
2223 // map directly.)
2224 s.fingerers_state
2225 .insert("whole_hand".into(), fs_with_count(2000));
2226 let fps = s.fps();
2227 assert!(
2228 fps > 100.0,
2229 "test setup expected fps>100, got {fps} — adjust the count if fingerer base changed"
2230 );
2231 // Activate Frenzy and click once.
2232 s.buffs.push(Buff::ClickFrenzy {
2233 ticks_remaining: TICK_HZ * 13,
2234 initial_ticks: TICK_HZ * 13,
2235 mult: 777.0,
2236 });
2237 let cuques_before = s.cuques;
2238 let biscuit = r(0, 0, 40, 20);
2239 s.click((20, 10), biscuit);
2240 let yield_per_click = s.cuques - cuques_before;
2241 // Should be roughly fps * 5.0 + base click_power (no upgrades = 1).
2242 let expected = fps * FRENZY_FPS_SECONDS_PER_CLICK + 1.0;
2243 // Floor of FRENZY_FLAT_PER_CLICK doesn't kick in here because
2244 // fps * 5 > 10.
2245 assert!(
2246 (yield_per_click - expected).abs() < expected * 0.01,
2247 "expected ~{expected}/click at fps={fps}, got {yield_per_click}"
2248 );
2249 }
2250
2251 #[test]
2252 fn no_frenzy_means_no_bonus() {
2253 // Negative test: without a Frenzy buff active, click_power is
2254 // just the base × upgrades — no FPS-scaled bonus added.
2255 let s = GameState::default();
2256 // No upgrades, no Frenzy.
2257 assert_eq!(s.click_power(), 1.0);
2258 }
2259
2260 #[test]
2261 fn catch_powerup_increments_grand_total_for_every_kind() {
2262 // Achievements like "Golden Touch" gate on `golden_caught`
2263 // (lifetime grand total). The catch path must bump it
2264 // regardless of kind, otherwise GreenCoin catches stop counting
2265 // toward the rollup that pre-V3 saves rely on.
2266 for kind in PowerupKind::ALL {
2267 let mut s = GameState::default();
2268 s.fingerers_state
2269 .insert("index_finger".into(), fs_with_count(1));
2270 let id = fake_powerup(&mut s, kind);
2271 let prior = s.golden_caught;
2272 s.catch_powerup(id);
2273 assert_eq!(
2274 s.golden_caught,
2275 prior + 1,
2276 "{kind:?} catch must bump golden_caught"
2277 );
2278 }
2279 }
2280}