Skip to main content

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}