Skip to main content

cuqueclicker_lib/game/
state.rs

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