Skip to main content

cuqueclicker_lib/game/
state.rs

1use std::collections::{HashMap, HashSet};
2
3use rand::RngExt;
4use ratatui::layout::Rect;
5use serde::{Deserialize, Serialize};
6
7use crate::game::achievement::ACHIEVEMENTS;
8use crate::game::fingerer::{self, FINGERERS};
9use crate::game::golden::GoldenCuque;
10use crate::game::upgrade::{UPGRADES, UpgradeEffect};
11
12pub const TICK_HZ: u32 = 20;
13pub const TICK_DT: f64 = 1.0 / TICK_HZ as f64;
14/// How long the biscuit stays "clenched" (eye→`*`, color shifts pink, art
15/// vertically squashes by one row). Bumped from 3 to 6 so a single click is
16/// actually visible — at 20Hz, 3 ticks (~150ms) was hard to perceive.
17pub const CLENCH_TICKS: u32 = 6;
18/// First `CLENCH_SQUASH_TICKS` of a clench draw the biscuit one row shorter
19/// (top blank dropped, art shifted) so each finger reads as a real squish
20/// before springing back. Strict subset of CLENCH_TICKS.
21pub const CLENCH_SQUASH_TICKS: u32 = 2;
22const PARTICLE_LIFE: u32 = 20;
23/// Misclick "·" lifetime — short, just enough to acknowledge the attempt.
24pub const MISCLICK_LIFE: u32 = 8;
25/// Achievement-unlock toast: how long the popup stays on screen.
26pub const TOAST_TICKS: u32 = TICK_HZ * 4;
27/// HUD digit "I just got bigger" green flash duration.
28pub const HUD_FLASH_TICKS: u32 = TICK_HZ; // 1s
29/// Achievement-unlock border channel duration (gold pulse like Lucky but
30/// shorter — celebratory, not lingering).
31pub const ACHIEVEMENT_FLASH_TICKS: u32 = TICK_HZ * 2;
32/// "You can afford this now!" row flash — fires the moment a fingerer or
33/// upgrade transitions from unaffordable to affordable. Brief on purpose:
34/// short enough that it's clearly an "announcement," not the longer
35/// purchase flash that fires on actual buy.
36pub const UNLOCK_FLASH_TICKS: u32 = TICK_HZ / 2; // 0.5s
37/// Per-tick upward drift for a particle, expressed as a fraction of the
38/// biscuit's height. Calibrated to match the original feel before the
39/// switch to fractional anchors: the old code rose 0.18 cells/tick on
40/// any biscuit size; on a typical ~30-row biscuit that's 0.006 of height
41/// per tick — slow enough that a "+1" only travels ~10-12% of the biscuit
42/// across its 1-second life, instead of streaking across half of it.
43const PARTICLE_FRAC_RISE: f32 = 0.006;
44const GOLDEN_REWARD_SECONDS: f64 = 60.0;
45const GOLDEN_REWARD_FLAT: f64 = 10.0;
46
47/// Visual flavor for a particle. Drives color/weight in the renderer; the
48/// motion model (rise + horizontal drift) is identical across kinds.
49#[derive(Clone, Copy, PartialEq, Eq)]
50pub enum ParticleKind {
51    /// Default `+1` from a normal click — white→red fade.
52    Click,
53    /// High-power click (Frenzy x777, big mults). Bold + warm-yellow accent
54    /// so it stands out from a swarm of `+1`s.
55    ClickBig,
56    /// Auto-fingerer income particle.
57    Auto,
58    /// Golden-catch label ("FRENZY x777!", "+1.2k", etc). Longer life,
59    /// brighter palette.
60    Golden,
61    /// Bulk-buy confetti pop. Coloured glyphs, shorter than a click.
62    Confetti,
63}
64
65/// Position is stored as a fraction of the biscuit rect ([0.0, 1.0] on each
66/// axis), matching `GoldenCuque`. The renderer resolves these fractions
67/// against the *current* biscuit rect every frame, so particles travel with
68/// the biscuit when the terminal resizes or the user zooms.
69#[derive(Clone)]
70pub struct Particle {
71    pub frac_x: f32,
72    pub frac_y: f32,
73    pub life: u32,
74    pub text: String,
75    pub kind: ParticleKind,
76    /// Per-tick horizontal drift in fraction-of-biscuit units. Set at spawn
77    /// from a small uniform so co-spawned particles separate as they rise
78    /// instead of stacking into garbage like `++1++++1`.
79    pub drift_x: f32,
80}
81
82/// Screen-anchored particle (raw col/row, not biscuit-fractional). Used for
83/// misclick acknowledgement: a small grey "·" at the exact dead-zone click
84/// point so the player knows the click registered but missed every target.
85#[derive(Clone)]
86pub struct MisclickParticle {
87    pub col: u16,
88    pub row: u16,
89    pub life: u32,
90}
91
92/// Convert an absolute `(col, row)` screen point into biscuit-fractional
93/// coordinates, clamped to [0.0, 1.0]. Used at click/spawn sites that come
94/// from screen-space input (mouse clicks, RNG within the biscuit rect).
95pub fn screen_to_biscuit_frac(col: u16, row: u16, biscuit: Rect) -> (f32, f32) {
96    if biscuit.width == 0 || biscuit.height == 0 {
97        return (0.5, 0.5);
98    }
99    let fx = ((col as i32 - biscuit.x as i32) as f32) / biscuit.width as f32;
100    let fy = ((row as i32 - biscuit.y as i32) as f32) / biscuit.height as f32;
101    (fx.clamp(0.0, 1.0), fy.clamp(0.0, 1.0))
102}
103
104/// Convert biscuit-fractional coordinates back to an absolute screen point.
105pub fn biscuit_frac_to_screen(frac_x: f32, frac_y: f32, biscuit: Rect) -> (u16, u16) {
106    let col = biscuit.x as f32 + frac_x.clamp(0.0, 1.0) * biscuit.width as f32;
107    let row = biscuit.y as f32 + frac_y.clamp(0.0, 1.0) * biscuit.height as f32;
108    (
109        col.round().clamp(0.0, u16::MAX as f32) as u16,
110        row.round().clamp(0.0, u16::MAX as f32) as u16,
111    )
112}
113
114#[derive(Clone, Debug, Serialize, Deserialize)]
115pub enum Buff {
116    ClickFrenzy {
117        ticks_remaining: u32,
118        initial_ticks: u32,
119        mult: f64,
120    },
121    /// Fingerer mult buff — applied when a "Buff" golden variant is caught.
122    /// Targets a fingerer by stable id so the buff stays on the right tier
123    /// across catalog changes.
124    FingererBoost {
125        ticks_remaining: u32,
126        initial_ticks: u32,
127        fingerer_id: String,
128        mult: f64,
129    },
130}
131
132impl Buff {
133    pub fn ticks_remaining(&self) -> u32 {
134        match self {
135            Buff::ClickFrenzy {
136                ticks_remaining, ..
137            } => *ticks_remaining,
138            Buff::FingererBoost {
139                ticks_remaining, ..
140            } => *ticks_remaining,
141        }
142    }
143
144    /// Plateau-at-1.0 until the last `BUFF_FADE_TICKS` of the buff, then
145    /// smoothstep-decay to 0. Gives a "stays on, then swift but smooth fade"
146    /// feel rather than a constantly-shrinking linear ramp.
147    pub fn strength(&self) -> f32 {
148        const FADE_TICKS: f32 = 30.0; // ~1.5s at 20Hz
149        let remaining = self.ticks_remaining() as f32;
150        if remaining >= FADE_TICKS {
151            1.0
152        } else {
153            let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
154            t * t * (3.0 - 2.0 * t)
155        }
156    }
157
158    fn tick(&mut self) {
159        match self {
160            Buff::ClickFrenzy {
161                ticks_remaining, ..
162            } => {
163                *ticks_remaining = ticks_remaining.saturating_sub(1);
164            }
165            Buff::FingererBoost {
166                ticks_remaining, ..
167            } => {
168                *ticks_remaining = ticks_remaining.saturating_sub(1);
169            }
170        }
171    }
172}
173
174/// Persistent game state. Catalog-addressed state (`fingerers_owned`,
175/// `upgrades_earned`, `achievements_earned`) is keyed by STABLE STRING IDS,
176/// not positional indices, so reordering / inserting / removing entries in
177/// `FINGERERS`, `UPGRADES`, or `ACHIEVEMENTS` never corrupts an old save.
178/// Unknown ids in a save are ignored (forward-compat); missing ids default
179/// to zero / absent (backward-compat).
180#[derive(Clone, Serialize, Deserialize)]
181pub struct GameState {
182    #[serde(default)]
183    pub cuques: f64,
184    #[serde(default)]
185    pub total_clicks: u64,
186    #[serde(default)]
187    pub lifetime_cuques: f64,
188    #[serde(default)]
189    pub best_fps: f64,
190    #[serde(default)]
191    pub golden_caught: u64,
192
193    /// Fingerer id → owned count.
194    #[serde(default)]
195    pub fingerers_owned: HashMap<String, u32>,
196    /// Set of earned achievement ids.
197    #[serde(default)]
198    pub achievements_earned: HashSet<String>,
199    /// Set of earned upgrade ids.
200    #[serde(default)]
201    pub upgrades_earned: HashSet<String>,
202
203    #[serde(default)]
204    pub prestige: u64,
205    #[serde(default)]
206    pub total_play_ticks: u64,
207    #[serde(default)]
208    pub buffs: Vec<Buff>,
209
210    #[serde(skip)]
211    pub clench_ticks: u32,
212    #[serde(skip)]
213    pub particles: Vec<Particle>,
214    /// Screen-anchored "misclick" tap particles — independent buffer because
215    /// they don't follow the biscuit (they're feedback for clicks that
216    /// MISSED the biscuit, including the dead zone at low zoom).
217    #[serde(skip)]
218    pub misclick_particles: Vec<MisclickParticle>,
219    #[serde(skip)]
220    pub golden: Option<GoldenCuque>,
221    #[serde(skip)]
222    pub golden_cooldown: u32,
223    #[serde(skip)]
224    pub session_ticks: u64,
225    /// Queue of achievement ids that unlocked but haven't yet been shown as a
226    /// toast. Drained one-at-a-time by `tick()` into `active_unlock_id`.
227    #[serde(skip)]
228    pub newly_unlocked: Vec<String>,
229    /// Currently-on-screen achievement toast (id) and its remaining life in
230    /// ticks. `None` means no toast right now; `tick()` pops the next pending
231    /// id off `newly_unlocked` when this clears.
232    #[serde(skip)]
233    pub active_unlock_id: Option<String>,
234    #[serde(skip)]
235    pub active_unlock_ticks: u32,
236    #[serde(skip)]
237    pub visual_debt: f64,
238    #[serde(skip)]
239    pub lucky_flash_ticks: u32,
240    #[serde(skip)]
241    pub achievement_flash_ticks: u32,
242    /// HUD title border phase clock. Advances by `border_speed()` each
243    /// tick, so the title border visibly speeds up under Frenzy / Lucky /
244    /// purchase events. INTENTIONALLY NOT shared with secondary shimmers
245    /// (panel borders, sidebar / upgrade rows) — they need a constant-rate
246    /// clock so a global speed-up on the HUD doesn't drag them along.
247    #[serde(skip)]
248    pub border_phase: u32,
249    /// Constant-rate phase clock for secondary shimmers — sidebar row,
250    /// upgrade row, and panel-border flashes. Advances by exactly 1 per
251    /// tick regardless of game state, so e.g. an Achievement / Frenzy
252    /// event accelerating `border_phase` doesn't accelerate the
253    /// "can't-buy" shimmer that happens to be running on a fingerer
254    /// row at the same time.
255    #[serde(skip)]
256    pub steady_phase: u32,
257    #[serde(skip)]
258    pub purchase_flash_ticks: u32,
259    /// Strength multiplier (1.0..=3.0) for the most recent purchase flash,
260    /// scaled by bulk-buy quantity. The border + panel borders read this so
261    /// a max-buy lands harder than a single click.
262    #[serde(skip)]
263    pub purchase_flash_strength: f32,
264    /// One slot per visible sidebar row; indexed by catalog position because
265    /// it's purely a render-time flash and doesn't need to survive reorders.
266    #[serde(skip)]
267    pub fingerer_flash_ticks: Vec<u32>,
268    /// Mirror of `fingerer_flash_ticks` for the Upgrades panel. Sized to
269    /// UPGRADES.len() lazily by `migrate()`.
270    #[serde(skip)]
271    pub upgrade_flash_ticks: Vec<u32>,
272    /// Negative-feedback flash: red row pulse when a click hit a row but
273    /// `cuques < cost`. One slot per fingerer / upgrade index.
274    #[serde(skip)]
275    pub fingerer_unaffordable_flash: Vec<u32>,
276    #[serde(skip)]
277    pub upgrade_unaffordable_flash: Vec<u32>,
278    /// "Just became affordable" flash: a brief one-shot green shimmer
279    /// fired the tick a row's affordability flips false → true. Distinct
280    /// from `*_flash_ticks` (purchase) — shorter duration, no panel
281    /// border bleed — so the player can tell "now buyable" apart from
282    /// "you just bought."
283    #[serde(skip)]
284    pub fingerer_unlock_flash: Vec<u32>,
285    #[serde(skip)]
286    pub upgrade_unlock_flash: Vec<u32>,
287    /// Previous-tick affordability per row, used to detect the
288    /// false→true edge that triggers `*_unlock_flash`. Sized to catalog
289    /// length by `migrate()` and seeded at init from the live state, so a
290    /// freshly-loaded save with rows already affordable doesn't fire a
291    /// fake unlock flash on tick 1.
292    #[serde(skip)]
293    pub prev_fingerer_affordable: Vec<bool>,
294    #[serde(skip)]
295    pub prev_upgrade_affordable: Vec<bool>,
296    /// Held-spacebar tracking.
297    ///
298    /// `space_pressed_this_tick` is set whenever `Action::ClickCenter`
299    /// arrives (terminal key-repeat fires Press events at ~30Hz, easily
300    /// hitting every 50ms tick when a key is genuinely held).
301    /// `ticks_since_last_press` is a small countdown that allows up to 3
302    /// missed ticks (~150ms) before declaring the key released — handles
303    /// real keyboard-repeat jitter so a 1-tick gap doesn't kill the
304    /// streak. `space_hold_ticks` is the consecutive "active" tick streak;
305    /// `space_held()` is true once it crosses 1 second.
306    ///
307    /// Net result: spamming spacebar at human speed (≥150ms between
308    /// presses) never triggers held; actually holding the key climbs the
309    /// streak past 20 ticks within ~1s.
310    #[serde(skip)]
311    pub space_pressed_this_tick: bool,
312    #[serde(skip)]
313    pub ticks_since_last_press: u32,
314    #[serde(skip)]
315    pub space_hold_ticks: u32,
316    /// HUD count-up tween: rendered numbers smoothly chase the real ones.
317    /// Initialized to the live values on load so the first frame doesn't
318    /// look like a count-up from zero.
319    #[serde(skip)]
320    pub displayed_cuques: f64,
321    #[serde(skip)]
322    pub displayed_fps: f64,
323    /// Brief green flash on the HUD digits when cuques jump UP — golden
324    /// catch, frenzy click, F4 dev cheat, etc. ("money coming in")
325    #[serde(skip)]
326    pub cuques_flash_ticks: u32,
327    /// Brief red flash on the HUD digits when cuques drop — successful
328    /// purchase, prestige reset (the big -all event). Mirrors
329    /// `cuques_flash_ticks` and competes with it: whichever channel is
330    /// stronger this frame drives the HUD color sweep, so a buy that
331    /// happens during a still-decaying gain pulse correctly flips the
332    /// digits red instead of staying green.
333    #[serde(skip)]
334    pub cuques_spend_flash_ticks: u32,
335}
336
337pub const LUCKY_FLASH_TICKS: u32 = 70; // 3.5s at 20Hz
338pub const PURCHASE_FLASH_TICKS: u32 = 20; // 1s at 20Hz
339
340impl Default for GameState {
341    fn default() -> Self {
342        Self {
343            cuques: 0.0,
344            total_clicks: 0,
345            lifetime_cuques: 0.0,
346            best_fps: 0.0,
347            golden_caught: 0,
348            fingerers_owned: HashMap::new(),
349            achievements_earned: HashSet::new(),
350            upgrades_earned: HashSet::new(),
351            prestige: 0,
352            total_play_ticks: 0,
353            buffs: Vec::new(),
354            clench_ticks: 0,
355            particles: Vec::new(),
356            misclick_particles: Vec::new(),
357            golden: None,
358            golden_cooldown: crate::game::golden::next_cooldown(),
359            session_ticks: 0,
360            newly_unlocked: Vec::new(),
361            active_unlock_id: None,
362            active_unlock_ticks: 0,
363            visual_debt: 0.0,
364            lucky_flash_ticks: 0,
365            achievement_flash_ticks: 0,
366            border_phase: 0,
367            steady_phase: 0,
368            purchase_flash_ticks: 0,
369            purchase_flash_strength: 1.0,
370            fingerer_flash_ticks: vec![0; fingerer::count()],
371            upgrade_flash_ticks: vec![0; UPGRADES.len()],
372            fingerer_unaffordable_flash: vec![0; fingerer::count()],
373            upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
374            fingerer_unlock_flash: vec![0; fingerer::count()],
375            upgrade_unlock_flash: vec![0; UPGRADES.len()],
376            prev_fingerer_affordable: vec![false; fingerer::count()],
377            prev_upgrade_affordable: vec![false; UPGRADES.len()],
378            space_pressed_this_tick: false,
379            ticks_since_last_press: u32::MAX,
380            space_hold_ticks: 0,
381            displayed_cuques: 0.0,
382            displayed_fps: 0.0,
383            cuques_flash_ticks: 0,
384            cuques_spend_flash_ticks: 0,
385        }
386    }
387}
388
389impl GameState {
390    /// Initialize ephemeral runtime state that `#[serde(skip)]` left empty
391    /// after deserialization, and normalize any fields that need live values
392    /// rather than the serde default. Hook point for future shape
393    /// transforms — see CLAUDE.md on the stable-id save policy.
394    pub fn migrate(mut self) -> Self {
395        // Per-catalog flash slots are runtime-only — re-size if the catalog
396        // grew/shrank since this save was written.
397        if self.fingerer_flash_ticks.len() != fingerer::count() {
398            self.fingerer_flash_ticks = vec![0; fingerer::count()];
399        }
400        if self.upgrade_flash_ticks.len() != UPGRADES.len() {
401            self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
402        }
403        if self.fingerer_unaffordable_flash.len() != fingerer::count() {
404            self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
405        }
406        if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
407            self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
408        }
409        if self.fingerer_unlock_flash.len() != fingerer::count() {
410            self.fingerer_unlock_flash = vec![0; fingerer::count()];
411        }
412        if self.upgrade_unlock_flash.len() != UPGRADES.len() {
413            self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
414        }
415        // Seed `prev_affordable` from the LIVE state so a freshly-loaded
416        // save with rows already affordable doesn't fire spurious unlock
417        // flashes on tick 1. Resize if catalog grew/shrank.
418        if self.prev_fingerer_affordable.len() != fingerer::count() {
419            self.prev_fingerer_affordable =
420                (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
421        }
422        if self.prev_upgrade_affordable.len() != UPGRADES.len() {
423            self.prev_upgrade_affordable = (0..UPGRADES.len())
424                .map(|i| {
425                    let u = &UPGRADES[i];
426                    !self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
427                })
428                .collect();
429        }
430        if self.golden_cooldown == 0 {
431            self.golden_cooldown = crate::game::golden::next_cooldown();
432        }
433        // Seed the count-up tween at the live values so a freshly-loaded save
434        // doesn't animate the HUD "from 0" up to whatever the player had.
435        self.displayed_cuques = self.cuques;
436        self.displayed_fps = 0.0; // recomputed on first tick
437        if self.purchase_flash_strength <= 0.0 {
438            self.purchase_flash_strength = 1.0;
439        }
440        self
441    }
442
443    // -- Catalog lookups (stable-id keyed) ---------------------------------
444
445    pub fn fingerer_count(&self, id: &str) -> u32 {
446        self.fingerers_owned.get(id).copied().unwrap_or(0)
447    }
448
449    pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
450        FINGERERS
451            .get(idx)
452            .map(|f| self.fingerer_count(f.id))
453            .unwrap_or(0)
454    }
455
456    pub fn fingerers_owned_total(&self) -> u32 {
457        self.fingerers_owned.values().sum()
458    }
459
460    pub fn has_upgrade(&self, id: &str) -> bool {
461        self.upgrades_earned.contains(id)
462    }
463
464    pub fn has_achievement(&self, id: &str) -> bool {
465        self.achievements_earned.contains(id)
466    }
467
468    pub fn has_achievement_idx(&self, idx: usize) -> bool {
469        ACHIEVEMENTS
470            .get(idx)
471            .is_some_and(|a| self.has_achievement(a.id))
472    }
473
474    // -- Click / tick -------------------------------------------------------
475
476    pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
477        let power = self.click_power();
478        self.add_cuques(power);
479        self.total_clicks += 1;
480        self.clench_ticks = CLENCH_TICKS;
481        // Click that meaningfully grows the counter also flashes the HUD
482        // digits — a single +1 doesn't deserve the green tint, but a
483        // Frenzy +777 (or any bulk jump) does.
484        if power >= 50.0 {
485            self.cuques_flash_ticks = HUD_FLASH_TICKS;
486        }
487        let mut rng = rand::rng();
488        // Wider random horizontal jitter (proportional to biscuit width) plus
489        // a small Y jitter so co-spawned particles don't overlap into "+1+1+1"
490        // mush at the same row. Per-particle drift_x continues the spread
491        // over the particle's life.
492        let jitter_x_range = (biscuit.width as i32 / 8).max(3);
493        let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
494        let jitter_y = rng.random_range(-1..=1);
495        let col = (origin.0 as i32 + jitter_x).max(0) as u16;
496        let row = origin
497            .1
498            .saturating_sub(1)
499            .saturating_add_signed(jitter_y as i16);
500        let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
501        let drift_x = rng.random_range(-0.012_f32..=0.012);
502        let frenzy_active = self
503            .buffs
504            .iter()
505            .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
506        // Small numbers stay subtle; big ones (Frenzy, Cosmic mults) get a
507        // bold ClickBig style so they read as "this matters" against the
508        // chatter of auto-particles.
509        let kind = if power >= 50.0 || frenzy_active {
510            ParticleKind::ClickBig
511        } else {
512            ParticleKind::Click
513        };
514        self.particles.push(Particle {
515            frac_x,
516            frac_y,
517            life: PARTICLE_LIFE,
518            text: format!("+{}", crate::format::big(power)),
519            kind,
520            drift_x,
521        });
522        // Frenzy clicks also spawn a halo of `*` confetti to make every tap
523        // feel chaotic without altering game behavior.
524        if frenzy_active {
525            for _ in 0..2 {
526                let halo_x = rng.random_range(-0.05_f32..=0.05);
527                let halo_y = rng.random_range(-0.04_f32..=0.04);
528                let (hfx, hfy) =
529                    screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
530                self.particles.push(Particle {
531                    frac_x: (hfx + halo_x).clamp(0.0, 1.0),
532                    frac_y: (hfy + halo_y).clamp(0.0, 1.0),
533                    life: PARTICLE_LIFE / 2,
534                    text: "*".into(),
535                    kind: ParticleKind::Confetti,
536                    drift_x: rng.random_range(-0.02_f32..=0.02),
537                });
538            }
539        }
540    }
541
542    /// Spawn a screen-anchored "·" particle at a click point that hit nothing
543    /// (biscuit dead zone, blank panel area, etc). Acknowledges that the
544    /// click registered without altering any game state.
545    pub fn spawn_misclick(&mut self, col: u16, row: u16) {
546        // Cap to avoid unbounded buildup if a player rage-clicks empty space.
547        if self.misclick_particles.len() >= 16 {
548            self.misclick_particles.remove(0);
549        }
550        self.misclick_particles.push(MisclickParticle {
551            col,
552            row,
553            life: MISCLICK_LIFE,
554        });
555    }
556
557    /// Spawn `n` confetti particles scattered over the biscuit. Used for
558    /// bulk-buy juice — a max-buy of a fingerer pops a small burst.
559    pub fn spawn_confetti(&mut self, n: u32) {
560        if n == 0 {
561            return;
562        }
563        let mut rng = rand::rng();
564        let glyphs = ['*', '+', '~', '.', 'o'];
565        for _ in 0..n.min(8) {
566            let glyph = glyphs[rng.random_range(0..glyphs.len())];
567            self.particles.push(Particle {
568                frac_x: rng.random_range(0.10_f32..=0.90),
569                frac_y: rng.random_range(0.20_f32..=0.85),
570                life: PARTICLE_LIFE,
571                text: glyph.to_string(),
572                kind: ParticleKind::Confetti,
573                drift_x: rng.random_range(-0.02_f32..=0.02),
574            });
575        }
576    }
577
578    pub fn click_power(&self) -> f64 {
579        let mut m = 1.0;
580        for u in UPGRADES.iter() {
581            if self.has_upgrade(u.id)
582                && let UpgradeEffect::ClickMult(f) = u.effect
583            {
584                m *= f;
585            }
586        }
587        for b in &self.buffs {
588            if let Buff::ClickFrenzy { mult, .. } = b {
589                m *= *mult;
590            }
591        }
592        m
593    }
594
595    pub fn fingerer_mult(&self, idx: usize) -> f64 {
596        let Some(target) = FINGERERS.get(idx) else {
597            return 1.0;
598        };
599        let mut m = 1.0;
600        for u in UPGRADES.iter() {
601            if !self.has_upgrade(u.id) {
602                continue;
603            }
604            match u.effect {
605                UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
606                UpgradeEffect::AllFingerersMult(f) => m *= f,
607                _ => {}
608            }
609        }
610        for b in &self.buffs {
611            if let Buff::FingererBoost {
612                fingerer_id, mult, ..
613            } = b
614                && fingerer_id == target.id
615            {
616                m *= *mult;
617            }
618        }
619        m
620    }
621
622    fn add_cuques(&mut self, amount: f64) {
623        self.cuques += amount;
624        self.lifetime_cuques += amount;
625    }
626
627    /// Dev-build cheat. Bypasses normal flow; not reachable in release builds
628    /// because the F-key that triggers it is gated behind `App::debug`.
629    pub fn dev_add_cuques(&mut self, amount: f64) {
630        self.add_cuques(amount);
631        self.cuques_flash_ticks = HUD_FLASH_TICKS;
632    }
633
634    /// Catch whatever Golden Cuque is currently on screen (any variant:
635    /// Lucky, Frenzy, or Buff). Applies the variant-specific effect,
636    /// increments `golden_caught`, re-rolls the next spawn cooldown, and
637    /// returns the flat reward (0.0 for buff variants).
638    pub fn catch_golden(&mut self) -> f64 {
639        use crate::game::golden::GoldenVariant;
640        let Some(golden) = self.golden.take() else {
641            return 0.0;
642        };
643        self.golden_caught += 1;
644        self.golden_cooldown = crate::game::golden::next_cooldown();
645        let (reward, label) = match golden.variant {
646            GoldenVariant::Lucky => {
647                let fps = self.fps();
648                let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
649                self.add_cuques(r);
650                self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
651                self.cuques_flash_ticks = HUD_FLASH_TICKS;
652                (r, format!("+{}", crate::format::big(r)))
653            }
654            GoldenVariant::Frenzy => {
655                let dur = TICK_HZ * 13;
656                self.buffs.push(Buff::ClickFrenzy {
657                    ticks_remaining: dur,
658                    initial_ticks: dur,
659                    mult: 777.0,
660                });
661                (0.0, "FRENZY x777!".into())
662            }
663            GoldenVariant::Buff => {
664                let active_ids: Vec<&'static str> = FINGERERS
665                    .iter()
666                    .filter(|f| self.fingerer_count(f.id) > 0)
667                    .map(|f| f.id)
668                    .collect();
669                let pick = if active_ids.is_empty() {
670                    FINGERERS[0].id
671                } else {
672                    use rand::RngExt;
673                    active_ids[rand::rng().random_range(0..active_ids.len())]
674                };
675                let dur = TICK_HZ * 60;
676                self.buffs.push(Buff::FingererBoost {
677                    ticks_remaining: dur,
678                    initial_ticks: dur,
679                    fingerer_id: pick.to_string(),
680                    mult: 7.0,
681                });
682                (0.0, "BOOSTED x7!".into())
683            }
684        };
685        self.particles.push(Particle {
686            frac_x: golden.frac_x,
687            frac_y: golden.frac_y,
688            life: PARTICLE_LIFE * 2,
689            text: label,
690            kind: ParticleKind::Golden,
691            drift_x: 0.0,
692        });
693        reward
694    }
695
696    pub fn fps(&self) -> f64 {
697        let base: f64 = FINGERERS
698            .iter()
699            .enumerate()
700            .map(|(i, k)| k.fps_per_unit * self.fingerer_count(k.id) as f64 * self.fingerer_mult(i))
701            .sum();
702        base * self.prestige_mult()
703    }
704
705    pub fn border_speed(&self) -> u32 {
706        let mut s: u32 = 1;
707        for b in &self.buffs {
708            match b {
709                Buff::ClickFrenzy { .. } => s = s.max(3),
710                Buff::FingererBoost { .. } => s = s.max(2),
711            }
712        }
713        if self.lucky_flash_ticks > 0 {
714            s = s.max(4);
715        }
716        if self.achievement_flash_ticks > 0 {
717            s = s.max(3);
718        }
719        if self.purchase_flash_ticks > 0 {
720            s += 2;
721        }
722        s
723    }
724
725    /// Trigger the green purchase flash on the global border + the panel
726    /// border. `strength` scales how loud the flash is (1.0 = single buy,
727    /// up to 3.0 = bulk max-buy) so a max-buy lands harder than a +1.
728    pub fn trigger_purchase_flash(&mut self, strength: f32) {
729        self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
730        // Take the louder of the in-flight strength and the new event so
731        // back-to-back small buys don't squash a still-decaying loud one.
732        self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
733    }
734
735    pub fn prestige_mult(&self) -> f64 {
736        1.0 + 0.01 * self.prestige as f64
737    }
738
739    pub fn prestige_earned_total(&self) -> u64 {
740        (self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
741    }
742
743    pub fn prestige_available(&self) -> u64 {
744        self.prestige_earned_total().saturating_sub(self.prestige)
745    }
746
747    pub fn prestige_reset(&mut self) -> bool {
748        let available = self.prestige_available();
749        if available == 0 {
750            return false;
751        }
752        self.prestige = self.prestige_earned_total();
753        self.cuques = 0.0;
754        // Don't snap `displayed_cuques` to 0 — let it tween down from
755        // its pre-reset value over the next ~1s for a "draining"
756        // feel. Same for FPS. The red spend-flash is fired below to
757        // color the falling counter.
758        self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
759        self.fingerers_owned.clear();
760        self.upgrades_earned.clear();
761        self.buffs.clear();
762        self.visual_debt = 0.0;
763        self.particles.clear();
764        self.misclick_particles.clear();
765        self.golden = None;
766        self.clench_ticks = 0;
767        self.golden_cooldown = crate::game::golden::next_cooldown();
768        true
769    }
770
771    pub fn tick(&mut self) {
772        for b in self.buffs.iter_mut() {
773            b.tick();
774        }
775        self.buffs.retain(|b| b.ticks_remaining() > 0);
776
777        self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
778        self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
779        self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
780        if self.purchase_flash_ticks == 0 {
781            self.purchase_flash_strength = 1.0;
782        }
783        self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
784        self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
785        for t in self.fingerer_flash_ticks.iter_mut() {
786            *t = t.saturating_sub(1);
787        }
788        for t in self.upgrade_flash_ticks.iter_mut() {
789            *t = t.saturating_sub(1);
790        }
791        for t in self.fingerer_unaffordable_flash.iter_mut() {
792            *t = t.saturating_sub(1);
793        }
794        for t in self.upgrade_unaffordable_flash.iter_mut() {
795            *t = t.saturating_sub(1);
796        }
797        for t in self.fingerer_unlock_flash.iter_mut() {
798            *t = t.saturating_sub(1);
799        }
800        for t in self.upgrade_unlock_flash.iter_mut() {
801            *t = t.saturating_sub(1);
802        }
803        // Held-spacebar streak with a small grace window. Real key-repeat
804        // is bursty (~30Hz nominal but with OS-level jitter), so a strict
805        // "every tick must see a press" test breaks on a single missed
806        // tick. Instead: a press resets `ticks_since_last_press` to 0;
807        // each tick increments it; the streak counts ticks that arrived
808        // within the last ~150ms (3 ticks). Spamming with ≥150ms gaps
809        // (human tap speed) never builds a streak. Genuine holding (key
810        // repeat) keeps `ticks_since_last_press ≤ 1` and the streak
811        // climbs by 1 every tick.
812        if self.space_pressed_this_tick {
813            self.ticks_since_last_press = 0;
814        } else {
815            self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
816        }
817        self.space_pressed_this_tick = false;
818        const HOLD_GRACE_TICKS: u32 = 3; // ~150ms at 20Hz
819        if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
820            self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
821        } else {
822            self.space_hold_ticks = 0;
823        }
824        let speed = self.border_speed();
825        self.border_phase = self.border_phase.wrapping_add(speed);
826        self.steady_phase = self.steady_phase.wrapping_add(1);
827
828        let fps = self.fps();
829        if fps > self.best_fps {
830            self.best_fps = fps;
831        }
832        let gained = fps * TICK_DT;
833        self.add_cuques(gained);
834        self.visual_debt += gained;
835        self.clench_ticks = self.clench_ticks.saturating_sub(1);
836        for p in self.particles.iter_mut() {
837            p.life = p.life.saturating_sub(1);
838            p.frac_y -= PARTICLE_FRAC_RISE;
839            // Per-particle horizontal drift so co-spawned particles spread
840            // out over their lifetime instead of overlapping into garbage.
841            p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
842        }
843        self.particles.retain(|p| p.life > 0);
844        for m in self.misclick_particles.iter_mut() {
845            m.life = m.life.saturating_sub(1);
846        }
847        self.misclick_particles.retain(|m| m.life > 0);
848
849        // K7: edge-detect false→true affordability flips and fire a brief
850        // unlock flash on the row. Detection runs AFTER `add_cuques(gained)`
851        // so an income-driven crossover lights up immediately. Two-pass to
852        // keep the immutable reads (`can_buy`, `req.met`, etc.) cleanly
853        // separated from the mutable writes to the flash + prev vecs.
854        let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
855        let upgrade_now: Vec<bool> = UPGRADES
856            .iter()
857            .map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
858            .collect();
859        for (i, &now) in fingerer_now.iter().enumerate() {
860            let was = self
861                .prev_fingerer_affordable
862                .get(i)
863                .copied()
864                .unwrap_or(false);
865            if now
866                && !was
867                && let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
868            {
869                *slot = UNLOCK_FLASH_TICKS;
870            }
871            if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
872                *slot = now;
873            }
874        }
875        for (i, &now) in upgrade_now.iter().enumerate() {
876            let was = self
877                .prev_upgrade_affordable
878                .get(i)
879                .copied()
880                .unwrap_or(false);
881            if now
882                && !was
883                && let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
884            {
885                *slot = UNLOCK_FLASH_TICKS;
886            }
887            if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
888                *slot = now;
889            }
890        }
891
892        // Count-up tween: rendered numbers chase the real ones with
893        // ease-out for BIG jumps (golden, F4, max-buy) so the eye can
894        // track the rise. Small deltas snap — a single +1 manual click
895        // would otherwise take ~30 ticks (1.5s) to finish tweening, AND
896        // `format::big` floors the in-flight value, so the HUD shows "0"
897        // for most of the climb. Counter-productive juice. The threshold
898        // (`SNAP_BELOW`) is in absolute cuques: any change smaller than
899        // ~5 cuques snaps instantly; bigger ones tween. The same
900        // threshold applies to FPS for symmetry — small FPS deltas come
901        // from buying a single fingerer, not worth a tween.
902        const SNAP_BELOW: f64 = 5.0;
903        let tween = 0.18_f64;
904        let dc = self.cuques - self.displayed_cuques;
905        if dc.abs() < SNAP_BELOW {
906            self.displayed_cuques = self.cuques;
907        } else {
908            self.displayed_cuques += dc * tween;
909        }
910        let df = fps - self.displayed_fps;
911        if df.abs() < SNAP_BELOW {
912            self.displayed_fps = fps;
913        } else {
914            self.displayed_fps += df * tween;
915        }
916
917        self.session_ticks += 1;
918        self.total_play_ticks += 1;
919        // Run the achievement check *before* the toast popper so an unlock
920        // detected this tick can become the on-screen toast on the same
921        // tick. Otherwise we'd waste the first tick of the toast's life
922        // moving the unlock from the queue to active_unlock_id.
923        self.tick_achievements();
924
925        // Toast queue: when no toast is on screen, pop the next pending
926        // unlock id and schedule it for TOAST_TICKS. Every other tick
927        // the active toast just decays.
928        self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
929        if self.active_unlock_ticks == 0 {
930            self.active_unlock_id = None;
931            if !self.newly_unlocked.is_empty() {
932                self.active_unlock_id = Some(self.newly_unlocked.remove(0));
933                self.active_unlock_ticks = TOAST_TICKS;
934                self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
935            }
936        }
937    }
938
939    pub fn tick_achievements(&mut self) {
940        for a in ACHIEVEMENTS.iter() {
941            if !self.has_achievement(a.id) && (a.unlocked)(self) {
942                self.achievements_earned.insert(a.id.to_string());
943                self.newly_unlocked.push(a.id.to_string());
944            }
945        }
946    }
947
948    pub fn tick_golden(&mut self) {
949        if let Some(g) = self.golden.as_mut() {
950            if g.life_ticks == 0 {
951                self.golden = None;
952                self.golden_cooldown = crate::game::golden::next_cooldown();
953            } else {
954                g.life_ticks -= 1;
955            }
956        } else if self.golden_cooldown > 0 {
957            self.golden_cooldown -= 1;
958        }
959    }
960
961    pub fn trigger_clench(&mut self) {
962        self.clench_ticks = CLENCH_TICKS;
963    }
964
965    /// True when the spacebar has been held continuously for ≥ 1 second.
966    /// Driven by `space_hold_ticks` (a streak counter that increments on
967    /// every tick where at least one ClickCenter arrived, resets the
968    /// instant a tick passes without one). Switches the biscuit's clench
969    /// animation from a burning `*` to the spin frames `\ | / -`.
970    pub fn space_held(&self) -> bool {
971        self.space_hold_ticks >= TICK_HZ
972    }
973
974    /// Spawn a "+N" particle representing cuques earned since the last
975    /// auto-particle. Silently skips if there isn't a whole cuque of accrued
976    /// income to show — at low FPS the caller is a rate-based timer that
977    /// fires faster than cuques arrive, and spawning a "+1" in that window
978    /// used to lie (particle flying up while the HUD counter didn't move).
979    /// The shown amount is always real cuques that just accrued into
980    /// `visual_debt`.
981    pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
982        let amount = self.visual_debt.floor() as u64;
983        if amount == 0 {
984            return;
985        }
986        self.visual_debt -= amount as f64;
987        let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
988        self.particles.push(Particle {
989            frac_x,
990            frac_y,
991            life: PARTICLE_LIFE,
992            text: format!("+{}", crate::format::big(amount as f64)),
993            kind: ParticleKind::Auto,
994            drift_x,
995        });
996    }
997
998    pub fn cost(&self, idx: usize) -> f64 {
999        let k = &FINGERERS[idx];
1000        // Floor the result so the cost ALWAYS equals what `format::big`
1001        // shows the player. The price formula scales by 1.15× per owned
1002        // unit and produces fractional cuques (e.g. 15 × 1.15⁶ = 34.69).
1003        // Without flooring, the HUD says "Cuques: 34, cost 34" but the
1004        // affordability check `cuques >= 34.69` rejects — the player sees
1005        // a lie. Floor here keeps display, gate, and spend consistent at
1006        // the integer grain the player actually sees.
1007        let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
1008        raw.floor()
1009    }
1010
1011    /// Cuques the player can ACTUALLY spend right now: the lesser of real
1012    /// `cuques` and the displayed counter. Both bounds matter:
1013    ///
1014    /// - Gating ONLY on `cuques` (real) lets the row turn green and a
1015    ///   click succeed before the counter visibly catches up — the
1016    ///   "I have 8 but the row says I can buy a 17" lie.
1017    /// - Gating ONLY on `displayed_cuques.floor()` lets a click DRAIN
1018    ///   real cuques NEGATIVE during a spend's tween-down: real already
1019    ///   dropped, displayed hasn't caught down yet, gate sees the high
1020    ///   displayed value and lets the buy through against the depleted
1021    ///   real. Once `cuques` goes negative, the HUD floor() shows "0"
1022    ///   for a long time while the slow income climbs back.
1023    ///
1024    /// Taking `min(real, displayed.floor())` makes both conditions
1025    /// equally binding: row turns green only when the visible counter
1026    /// AND the underlying balance both reach the cost; click succeeds
1027    /// only when both still hold. No overspend, no visual lie.
1028    pub fn affordable_cuques(&self) -> f64 {
1029        self.cuques.min(self.displayed_cuques.floor())
1030    }
1031
1032    pub fn can_buy(&self, idx: usize) -> bool {
1033        self.affordable_cuques() >= self.cost(idx)
1034    }
1035
1036    /// Buy a single unit. Bare mutation only — flash side-effects are
1037    /// scaled by quantity in `buy_n` / `buy_max` so a single buy and a
1038    /// bulk buy produce visually distinct feedback.
1039    fn buy_one_quiet(&mut self, idx: usize) -> bool {
1040        let c = self.cost(idx);
1041        // Use the same min(real, displayed) gate as `can_buy` so the
1042        // visible row state and the buy outcome agree, AND we never
1043        // spend more than `cuques` actually has. We do NOT snap
1044        // `displayed_cuques` to post-spend `cuques` — the existing tick
1045        // path tweens it down and the red spend flash colors that fall.
1046        if self.affordable_cuques() >= c
1047            && let Some(f) = FINGERERS.get(idx)
1048        {
1049            self.cuques -= c;
1050            *self.fingerers_owned.entry(f.id.to_string()).or_insert(0) += 1;
1051            true
1052        } else {
1053            false
1054        }
1055    }
1056
1057    /// Apply purchase flash + per-row green flash, then optionally pop
1058    /// confetti. Called once per public buy action with the total bought
1059    /// count, so the loud bulk-buy feedback only fires once.
1060    fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
1061        if bought == 0 {
1062            return;
1063        }
1064        // 1 → 1.0, 10 → 1.7, 50 → 2.5, capped at 3.0. sqrt-style growth so
1065        // a max-buy is dramatic but doesn't blow the eardrums.
1066        let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
1067        self.trigger_purchase_flash(strength);
1068        match slot_table {
1069            PurchaseSlot::Fingerer => {
1070                if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
1071                    *slot = PURCHASE_FLASH_TICKS;
1072                }
1073            }
1074            PurchaseSlot::Upgrade => {
1075                if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
1076                    *slot = PURCHASE_FLASH_TICKS;
1077                }
1078            }
1079        }
1080        // A buy is a SPEND — it always fires the red HUD flash so the
1081        // counter dropping is visibly acknowledged. Earlier this slot
1082        // mistakenly used `cuques_flash_ticks` (the gain channel),
1083        // making big buys flash green even though cuques went DOWN.
1084        // Bulk buys also pop confetti for celebratory feel.
1085        self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1086        if bought >= 5 {
1087            self.spawn_confetti(bought.min(8));
1088        }
1089    }
1090
1091    fn flash_unaffordable_fingerer(&mut self, idx: usize) {
1092        if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
1093            *slot = PURCHASE_FLASH_TICKS / 2;
1094        }
1095    }
1096
1097    fn flash_unaffordable_upgrade(&mut self, idx: usize) {
1098        if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
1099            *slot = PURCHASE_FLASH_TICKS / 2;
1100        }
1101    }
1102
1103    pub fn buy(&mut self, idx: usize) -> bool {
1104        if self.buy_one_quiet(idx) {
1105            self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
1106            true
1107        } else {
1108            self.flash_unaffordable_fingerer(idx);
1109            false
1110        }
1111    }
1112
1113    pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
1114        let mut bought = 0;
1115        for _ in 0..n {
1116            if !self.buy_one_quiet(idx) {
1117                break;
1118            }
1119            bought += 1;
1120        }
1121        if bought == 0 {
1122            self.flash_unaffordable_fingerer(idx);
1123        } else {
1124            self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1125        }
1126        bought
1127    }
1128
1129    pub fn buy_max(&mut self, idx: usize) -> u32 {
1130        let mut bought = 0;
1131        while self.buy_one_quiet(idx) {
1132            bought += 1;
1133        }
1134        if bought == 0 {
1135            self.flash_unaffordable_fingerer(idx);
1136        } else {
1137            self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1138        }
1139        bought
1140    }
1141
1142    pub fn buy_upgrade(&mut self, idx: usize) -> bool {
1143        let Some(u) = UPGRADES.get(idx) else {
1144            return false;
1145        };
1146        if self.has_upgrade(u.id) {
1147            return false;
1148        }
1149        // Same min(real, displayed) gate as fingerer buys — see
1150        // `affordable_cuques` for why both bounds matter.
1151        if !u.req.met(self) || self.affordable_cuques() < u.cost {
1152            self.flash_unaffordable_upgrade(idx);
1153            return false;
1154        }
1155        self.cuques -= u.cost;
1156        self.upgrades_earned.insert(u.id.to_string());
1157        self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
1158        true
1159    }
1160}
1161
1162#[derive(Clone, Copy)]
1163enum PurchaseSlot {
1164    Fingerer,
1165    Upgrade,
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::*;
1171
1172    #[test]
1173    fn migrate_is_idempotent_on_current_shape() {
1174        let state = GameState {
1175            fingerers_owned: [("index_finger".to_string(), 9)].into_iter().collect(),
1176            upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1177            achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1178            ..GameState::default()
1179        };
1180
1181        let m = state.migrate();
1182
1183        assert_eq!(m.fingerer_count("index_finger"), 9);
1184        assert!(m.has_upgrade("click_mult_1"));
1185        assert!(m.has_achievement("first_finger"));
1186    }
1187
1188    #[test]
1189    fn unknown_ids_in_save_are_ignored_not_resurrected() {
1190        // Forward-compat: a future version adds `"giga_finger"` to the
1191        // catalog, player plays, saves. User downgrades to current version.
1192        // That unknown id must not crash — it just reads as 0.
1193        let state = GameState {
1194            fingerers_owned: [("giga_finger_from_the_future".to_string(), 42)]
1195                .into_iter()
1196                .collect(),
1197            ..GameState::default()
1198        };
1199
1200        let m = state.migrate();
1201
1202        assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
1203        assert_eq!(m.fingerer_count("index_finger"), 0);
1204        assert!(!m.has_upgrade("click_mult_1"));
1205    }
1206
1207    #[test]
1208    fn save_roundtrip_is_stable_through_json() {
1209        // Serialize → deserialize → get the same state back. Catches any
1210        // accidental rename that would make saves non-idempotent.
1211        let state = GameState {
1212            cuques: 1234.5,
1213            total_clicks: 99,
1214            fingerers_owned: [("index_finger".to_string(), 7)].into_iter().collect(),
1215            upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1216            achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1217            ..GameState::default()
1218        };
1219
1220        let json = serde_json::to_string(&state).expect("serialize");
1221        let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
1222        let m = roundtripped.migrate();
1223
1224        assert_eq!(m.cuques, 1234.5);
1225        assert_eq!(m.total_clicks, 99);
1226        assert_eq!(m.fingerer_count("index_finger"), 7);
1227        assert!(m.has_upgrade("click_mult_1"));
1228        assert!(m.has_achievement("first_finger"));
1229    }
1230
1231    fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
1232        Rect {
1233            x,
1234            y,
1235            width: w,
1236            height: h,
1237        }
1238    }
1239
1240    #[test]
1241    fn frac_screen_roundtrip_at_corners() {
1242        let biscuit = r(10, 5, 40, 20);
1243        // top-left corner
1244        let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
1245        assert!(fx <= 0.001 && fy <= 0.001);
1246        let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
1247        assert_eq!((col, row), (10, 5));
1248
1249        // bottom-right (one beyond, clamps)
1250        let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
1251        assert!(fx >= 0.999 && fy >= 0.999);
1252
1253        // exact center
1254        let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
1255        assert_eq!(col, 30);
1256        assert_eq!(row, 15);
1257    }
1258
1259    #[test]
1260    fn frac_position_survives_biscuit_move() {
1261        // A point at fraction (0.25, 0.5) of the biscuit must resolve to a
1262        // proportionally-shifted absolute coord when the biscuit moves /
1263        // grows.
1264        let small = r(0, 0, 40, 20);
1265        let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
1266        let large = r(10, 5, 80, 40);
1267        let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
1268        // Same fractional spot, very different screen coords.
1269        assert_ne!((col_a, row_a), (col_b, row_b));
1270        // And the shifted point should still sit at the 25%/50% mark of the
1271        // new rect.
1272        assert_eq!(col_b, 30); // 10 + 0.25 * 80
1273        assert_eq!(row_b, 25); // 5  + 0.5  * 40
1274    }
1275
1276    #[test]
1277    fn zero_size_biscuit_doesnt_panic() {
1278        let zero = r(0, 0, 0, 0);
1279        let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
1280        assert_eq!((fx, fy), (0.5, 0.5));
1281        let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
1282        assert_eq!((col, row), (0, 0));
1283    }
1284
1285    // -- Juice-flash invariants ---------------------------------------------
1286
1287    #[test]
1288    fn buy_when_broke_sets_unaffordable_flash() {
1289        // Player clicks an unaffordable fingerer row → buy() returns false
1290        // AND a red row flash is queued so the rejection is visible. This
1291        // is the J11 contract; without it the click looks silent.
1292        let mut s = GameState::default();
1293        s.cuques = 0.0;
1294        let bought = s.buy(0);
1295        assert!(!bought);
1296        assert!(
1297            s.fingerer_unaffordable_flash[0] > 0,
1298            "buy(0) on broke state must flash red"
1299        );
1300        assert!(
1301            s.fingerer_flash_ticks[0] == 0,
1302            "no purchase flash on reject"
1303        );
1304    }
1305
1306    #[test]
1307    fn buy_n_when_broke_sets_unaffordable_flash() {
1308        let mut s = GameState::default();
1309        s.cuques = 0.0;
1310        let bought = s.buy_n(0, 10);
1311        assert_eq!(bought, 0);
1312        assert!(s.fingerer_unaffordable_flash[0] > 0);
1313    }
1314
1315    #[test]
1316    fn bulk_buy_scales_purchase_flash_strength() {
1317        // J8: max-buy is louder than a +1. We don't pin exact values (clamp
1318        // boundaries are tuning), only the relative ordering and bounds.
1319        // `displayed_cuques` must mirror `cuques` here because buy()'s
1320        // affordability gate now reads displayed (matches the visible
1321        // counter on the HUD) — a default-constructed test state has
1322        // displayed=0 and would otherwise reject every buy.
1323        let mut s = GameState {
1324            cuques: 1_000_000.0,
1325            displayed_cuques: 1_000_000.0,
1326            ..Default::default()
1327        };
1328        s.buy(0);
1329        let single = s.purchase_flash_strength;
1330        assert!((1.0..=3.0).contains(&single));
1331
1332        let mut s = GameState {
1333            cuques: 1_000_000.0,
1334            displayed_cuques: 1_000_000.0,
1335            ..Default::default()
1336        };
1337        s.buy_n(0, 50);
1338        let bulk = s.purchase_flash_strength;
1339        assert!(
1340            bulk > single,
1341            "bulk strength must exceed single ({bulk} vs {single})"
1342        );
1343        assert!(bulk <= 3.0, "bulk strength capped at 3.0");
1344    }
1345
1346    #[test]
1347    fn buy_upgrade_when_broke_sets_unaffordable_flash() {
1348        let mut s = GameState::default();
1349        // Pick the cheapest upgrade and try to buy with no money.
1350        let cheapest_idx = (0..UPGRADES.len())
1351            .min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
1352            .unwrap();
1353        let bought = s.buy_upgrade(cheapest_idx);
1354        assert!(!bought);
1355        assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
1356    }
1357
1358    #[test]
1359    fn migrate_resizes_per_catalog_flash_vecs() {
1360        // A serialized state from "before this branch shipped" has empty /
1361        // skipped flash vecs after deserialize. migrate() must size them to
1362        // the live catalog so paint paths can index without bounds checks
1363        // in hot loops.
1364        let json = serde_json::to_string(&GameState::default()).unwrap();
1365        let mut s: GameState = serde_json::from_str(&json).unwrap();
1366        // Simulate stale shape: drop the per-catalog vecs.
1367        s.fingerer_flash_ticks.clear();
1368        s.upgrade_flash_ticks.clear();
1369        s.fingerer_unaffordable_flash.clear();
1370        s.upgrade_unaffordable_flash.clear();
1371        let m = s.migrate();
1372        assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
1373        assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
1374        assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
1375        assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
1376    }
1377
1378    #[test]
1379    fn migrate_seeds_displayed_counters() {
1380        // J5 contract: a freshly-loaded save shows the live counters at full
1381        // value, not "tweening up from zero".
1382        let s = GameState {
1383            cuques: 5_000.0,
1384            ..Default::default()
1385        };
1386        let m = s.migrate();
1387        assert_eq!(m.displayed_cuques, 5_000.0);
1388        // displayed_fps starts at 0 and converges over the first few ticks
1389        // (otherwise we'd snap-show the FPS before any tick has run).
1390        assert_eq!(m.displayed_fps, 0.0);
1391    }
1392
1393    #[test]
1394    fn unlock_pop_sets_active_toast_and_gold_flash() {
1395        // J1 contract: when an achievement triggers, tick() drains
1396        // newly_unlocked into active_unlock_id and lights the gold border
1397        // channel.
1398        let mut s = GameState::default();
1399        // Force a "First Finger" unlock by simulating one click.
1400        let biscuit = r(0, 0, 40, 20);
1401        s.click((20, 10), biscuit);
1402        s.tick();
1403        // The fresh tick should have moved the queued unlock onto the screen.
1404        assert!(s.active_unlock_id.is_some());
1405        assert!(s.active_unlock_ticks > 0);
1406        assert!(s.achievement_flash_ticks > 0);
1407    }
1408}