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