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