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