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