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