1use std::collections::{HashMap, HashSet};
2
3use rand::RngExt;
4use ratatui::layout::Rect;
5use serde::{Deserialize, Serialize};
6
7use crate::game::achievement::ACHIEVEMENTS;
8use crate::game::fingerer::{self, FINGERERS};
9use crate::game::golden::GoldenCuque;
10use crate::game::upgrade::{UPGRADES, UpgradeEffect};
11
12pub const TICK_HZ: u32 = 20;
13pub const TICK_DT: f64 = 1.0 / TICK_HZ as f64;
14pub const CLENCH_TICKS: u32 = 6;
18pub const CLENCH_SQUASH_TICKS: u32 = 2;
22const PARTICLE_LIFE: u32 = 20;
23pub const MISCLICK_LIFE: u32 = 8;
25pub const TOAST_TICKS: u32 = TICK_HZ * 4;
27pub const HUD_FLASH_TICKS: u32 = TICK_HZ; pub const ACHIEVEMENT_FLASH_TICKS: u32 = TICK_HZ * 2;
32pub const UNLOCK_FLASH_TICKS: u32 = TICK_HZ / 2; const PARTICLE_FRAC_RISE: f32 = 0.006;
44const GOLDEN_REWARD_SECONDS: f64 = 60.0;
45const GOLDEN_REWARD_FLAT: f64 = 10.0;
46
47#[derive(Clone, Copy, PartialEq, Eq)]
50pub enum ParticleKind {
51 Click,
53 ClickBig,
56 Auto,
58 Golden,
61 Confetti,
63}
64
65#[derive(Clone)]
70pub struct Particle {
71 pub frac_x: f32,
72 pub frac_y: f32,
73 pub life: u32,
74 pub text: String,
75 pub kind: ParticleKind,
76 pub drift_x: f32,
80}
81
82#[derive(Clone)]
86pub struct MisclickParticle {
87 pub col: u16,
88 pub row: u16,
89 pub life: u32,
90}
91
92pub fn screen_to_biscuit_frac(col: u16, row: u16, biscuit: Rect) -> (f32, f32) {
96 if biscuit.width == 0 || biscuit.height == 0 {
97 return (0.5, 0.5);
98 }
99 let fx = ((col as i32 - biscuit.x as i32) as f32) / biscuit.width as f32;
100 let fy = ((row as i32 - biscuit.y as i32) as f32) / biscuit.height as f32;
101 (fx.clamp(0.0, 1.0), fy.clamp(0.0, 1.0))
102}
103
104pub fn biscuit_frac_to_screen(frac_x: f32, frac_y: f32, biscuit: Rect) -> (u16, u16) {
106 let col = biscuit.x as f32 + frac_x.clamp(0.0, 1.0) * biscuit.width as f32;
107 let row = biscuit.y as f32 + frac_y.clamp(0.0, 1.0) * biscuit.height as f32;
108 (
109 col.round().clamp(0.0, u16::MAX as f32) as u16,
110 row.round().clamp(0.0, u16::MAX as f32) as u16,
111 )
112}
113
114#[derive(Clone, Debug, Serialize, Deserialize)]
115pub enum Buff {
116 ClickFrenzy {
117 ticks_remaining: u32,
118 initial_ticks: u32,
119 mult: f64,
120 },
121 FingererBoost {
125 ticks_remaining: u32,
126 initial_ticks: u32,
127 fingerer_id: String,
128 mult: f64,
129 },
130}
131
132impl Buff {
133 pub fn ticks_remaining(&self) -> u32 {
134 match self {
135 Buff::ClickFrenzy {
136 ticks_remaining, ..
137 } => *ticks_remaining,
138 Buff::FingererBoost {
139 ticks_remaining, ..
140 } => *ticks_remaining,
141 }
142 }
143
144 pub fn strength(&self) -> f32 {
148 const FADE_TICKS: f32 = 30.0; let remaining = self.ticks_remaining() as f32;
150 if remaining >= FADE_TICKS {
151 1.0
152 } else {
153 let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
154 t * t * (3.0 - 2.0 * t)
155 }
156 }
157
158 fn tick(&mut self) {
159 match self {
160 Buff::ClickFrenzy {
161 ticks_remaining, ..
162 } => {
163 *ticks_remaining = ticks_remaining.saturating_sub(1);
164 }
165 Buff::FingererBoost {
166 ticks_remaining, ..
167 } => {
168 *ticks_remaining = ticks_remaining.saturating_sub(1);
169 }
170 }
171 }
172}
173
174#[derive(Clone, Serialize, Deserialize)]
181pub struct GameState {
182 #[serde(default)]
183 pub cuques: f64,
184 #[serde(default)]
185 pub total_clicks: u64,
186 #[serde(default)]
187 pub lifetime_cuques: f64,
188 #[serde(default)]
189 pub best_fps: f64,
190 #[serde(default)]
191 pub golden_caught: u64,
192
193 #[serde(default)]
195 pub fingerers_owned: HashMap<String, u32>,
196 #[serde(default)]
198 pub achievements_earned: HashSet<String>,
199 #[serde(default)]
201 pub upgrades_earned: HashSet<String>,
202
203 #[serde(default)]
204 pub prestige: u64,
205 #[serde(default)]
206 pub total_play_ticks: u64,
207 #[serde(default)]
208 pub buffs: Vec<Buff>,
209
210 #[serde(skip)]
211 pub clench_ticks: u32,
212 #[serde(skip)]
213 pub particles: Vec<Particle>,
214 #[serde(skip)]
218 pub misclick_particles: Vec<MisclickParticle>,
219 #[serde(skip)]
220 pub golden: Option<GoldenCuque>,
221 #[serde(skip)]
222 pub golden_cooldown: u32,
223 #[serde(skip)]
224 pub session_ticks: u64,
225 #[serde(skip)]
228 pub newly_unlocked: Vec<String>,
229 #[serde(skip)]
233 pub active_unlock_id: Option<String>,
234 #[serde(skip)]
235 pub active_unlock_ticks: u32,
236 #[serde(skip)]
237 pub visual_debt: f64,
238 #[serde(skip)]
239 pub lucky_flash_ticks: u32,
240 #[serde(skip)]
241 pub achievement_flash_ticks: u32,
242 #[serde(skip)]
248 pub border_phase: u32,
249 #[serde(skip)]
256 pub steady_phase: u32,
257 #[serde(skip)]
258 pub purchase_flash_ticks: u32,
259 #[serde(skip)]
263 pub purchase_flash_strength: f32,
264 #[serde(skip)]
267 pub fingerer_flash_ticks: Vec<u32>,
268 #[serde(skip)]
271 pub upgrade_flash_ticks: Vec<u32>,
272 #[serde(skip)]
275 pub fingerer_unaffordable_flash: Vec<u32>,
276 #[serde(skip)]
277 pub upgrade_unaffordable_flash: Vec<u32>,
278 #[serde(skip)]
284 pub fingerer_unlock_flash: Vec<u32>,
285 #[serde(skip)]
286 pub upgrade_unlock_flash: Vec<u32>,
287 #[serde(skip)]
293 pub prev_fingerer_affordable: Vec<bool>,
294 #[serde(skip)]
295 pub prev_upgrade_affordable: Vec<bool>,
296 #[serde(skip)]
311 pub space_pressed_this_tick: bool,
312 #[serde(skip)]
313 pub ticks_since_last_press: u32,
314 #[serde(skip)]
315 pub space_hold_ticks: u32,
316 #[serde(skip)]
320 pub displayed_cuques: f64,
321 #[serde(skip)]
322 pub displayed_fps: f64,
323 #[serde(skip)]
326 pub cuques_flash_ticks: u32,
327 #[serde(skip)]
334 pub cuques_spend_flash_ticks: u32,
335}
336
337pub const LUCKY_FLASH_TICKS: u32 = 70; pub const PURCHASE_FLASH_TICKS: u32 = 20; impl Default for GameState {
341 fn default() -> Self {
342 Self {
343 cuques: 0.0,
344 total_clicks: 0,
345 lifetime_cuques: 0.0,
346 best_fps: 0.0,
347 golden_caught: 0,
348 fingerers_owned: HashMap::new(),
349 achievements_earned: HashSet::new(),
350 upgrades_earned: HashSet::new(),
351 prestige: 0,
352 total_play_ticks: 0,
353 buffs: Vec::new(),
354 clench_ticks: 0,
355 particles: Vec::new(),
356 misclick_particles: Vec::new(),
357 golden: None,
358 golden_cooldown: crate::game::golden::next_cooldown(),
359 session_ticks: 0,
360 newly_unlocked: Vec::new(),
361 active_unlock_id: None,
362 active_unlock_ticks: 0,
363 visual_debt: 0.0,
364 lucky_flash_ticks: 0,
365 achievement_flash_ticks: 0,
366 border_phase: 0,
367 steady_phase: 0,
368 purchase_flash_ticks: 0,
369 purchase_flash_strength: 1.0,
370 fingerer_flash_ticks: vec![0; fingerer::count()],
371 upgrade_flash_ticks: vec![0; UPGRADES.len()],
372 fingerer_unaffordable_flash: vec![0; fingerer::count()],
373 upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
374 fingerer_unlock_flash: vec![0; fingerer::count()],
375 upgrade_unlock_flash: vec![0; UPGRADES.len()],
376 prev_fingerer_affordable: vec![false; fingerer::count()],
377 prev_upgrade_affordable: vec![false; UPGRADES.len()],
378 space_pressed_this_tick: false,
379 ticks_since_last_press: u32::MAX,
380 space_hold_ticks: 0,
381 displayed_cuques: 0.0,
382 displayed_fps: 0.0,
383 cuques_flash_ticks: 0,
384 cuques_spend_flash_ticks: 0,
385 }
386 }
387}
388
389impl GameState {
390 pub fn migrate(mut self) -> Self {
395 if self.fingerer_flash_ticks.len() != fingerer::count() {
398 self.fingerer_flash_ticks = vec![0; fingerer::count()];
399 }
400 if self.upgrade_flash_ticks.len() != UPGRADES.len() {
401 self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
402 }
403 if self.fingerer_unaffordable_flash.len() != fingerer::count() {
404 self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
405 }
406 if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
407 self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
408 }
409 if self.fingerer_unlock_flash.len() != fingerer::count() {
410 self.fingerer_unlock_flash = vec![0; fingerer::count()];
411 }
412 if self.upgrade_unlock_flash.len() != UPGRADES.len() {
413 self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
414 }
415 if self.prev_fingerer_affordable.len() != fingerer::count() {
419 self.prev_fingerer_affordable =
420 (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
421 }
422 if self.prev_upgrade_affordable.len() != UPGRADES.len() {
423 self.prev_upgrade_affordable = (0..UPGRADES.len())
424 .map(|i| {
425 let u = &UPGRADES[i];
426 !self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
427 })
428 .collect();
429 }
430 if self.golden_cooldown == 0 {
431 self.golden_cooldown = crate::game::golden::next_cooldown();
432 }
433 self.displayed_cuques = self.cuques;
436 self.displayed_fps = 0.0; if self.purchase_flash_strength <= 0.0 {
438 self.purchase_flash_strength = 1.0;
439 }
440 self
441 }
442
443 pub fn fingerer_count(&self, id: &str) -> u32 {
446 self.fingerers_owned.get(id).copied().unwrap_or(0)
447 }
448
449 pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
450 FINGERERS
451 .get(idx)
452 .map(|f| self.fingerer_count(f.id))
453 .unwrap_or(0)
454 }
455
456 pub fn fingerers_owned_total(&self) -> u32 {
457 self.fingerers_owned.values().sum()
458 }
459
460 pub fn has_upgrade(&self, id: &str) -> bool {
461 self.upgrades_earned.contains(id)
462 }
463
464 pub fn has_achievement(&self, id: &str) -> bool {
465 self.achievements_earned.contains(id)
466 }
467
468 pub fn has_achievement_idx(&self, idx: usize) -> bool {
469 ACHIEVEMENTS
470 .get(idx)
471 .is_some_and(|a| self.has_achievement(a.id))
472 }
473
474 pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
477 let power = self.click_power();
478 self.add_cuques(power);
479 self.total_clicks += 1;
480 self.clench_ticks = CLENCH_TICKS;
481 if power >= 50.0 {
485 self.cuques_flash_ticks = HUD_FLASH_TICKS;
486 }
487 let mut rng = rand::rng();
488 let jitter_x_range = (biscuit.width as i32 / 8).max(3);
493 let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
494 let jitter_y = rng.random_range(-1..=1);
495 let col = (origin.0 as i32 + jitter_x).max(0) as u16;
496 let row = origin
497 .1
498 .saturating_sub(1)
499 .saturating_add_signed(jitter_y as i16);
500 let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
501 let drift_x = rng.random_range(-0.012_f32..=0.012);
502 let frenzy_active = self
503 .buffs
504 .iter()
505 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
506 let kind = if power >= 50.0 || frenzy_active {
510 ParticleKind::ClickBig
511 } else {
512 ParticleKind::Click
513 };
514 self.particles.push(Particle {
515 frac_x,
516 frac_y,
517 life: PARTICLE_LIFE,
518 text: format!("+{}", crate::format::big(power)),
519 kind,
520 drift_x,
521 });
522 if frenzy_active {
525 for _ in 0..2 {
526 let halo_x = rng.random_range(-0.05_f32..=0.05);
527 let halo_y = rng.random_range(-0.04_f32..=0.04);
528 let (hfx, hfy) =
529 screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
530 self.particles.push(Particle {
531 frac_x: (hfx + halo_x).clamp(0.0, 1.0),
532 frac_y: (hfy + halo_y).clamp(0.0, 1.0),
533 life: PARTICLE_LIFE / 2,
534 text: "*".into(),
535 kind: ParticleKind::Confetti,
536 drift_x: rng.random_range(-0.02_f32..=0.02),
537 });
538 }
539 }
540 }
541
542 pub fn spawn_misclick(&mut self, col: u16, row: u16) {
546 if self.misclick_particles.len() >= 16 {
548 self.misclick_particles.remove(0);
549 }
550 self.misclick_particles.push(MisclickParticle {
551 col,
552 row,
553 life: MISCLICK_LIFE,
554 });
555 }
556
557 pub fn spawn_confetti(&mut self, n: u32) {
560 if n == 0 {
561 return;
562 }
563 let mut rng = rand::rng();
564 let glyphs = ['*', '+', '~', '.', 'o'];
565 for _ in 0..n.min(8) {
566 let glyph = glyphs[rng.random_range(0..glyphs.len())];
567 self.particles.push(Particle {
568 frac_x: rng.random_range(0.10_f32..=0.90),
569 frac_y: rng.random_range(0.20_f32..=0.85),
570 life: PARTICLE_LIFE,
571 text: glyph.to_string(),
572 kind: ParticleKind::Confetti,
573 drift_x: rng.random_range(-0.02_f32..=0.02),
574 });
575 }
576 }
577
578 pub fn click_power(&self) -> f64 {
579 let mut m = 1.0;
580 for u in UPGRADES.iter() {
581 if self.has_upgrade(u.id)
582 && let UpgradeEffect::ClickMult(f) = u.effect
583 {
584 m *= f;
585 }
586 }
587 for b in &self.buffs {
588 if let Buff::ClickFrenzy { mult, .. } = b {
589 m *= *mult;
590 }
591 }
592 m
593 }
594
595 pub fn fingerer_mult(&self, idx: usize) -> f64 {
596 let Some(target) = FINGERERS.get(idx) else {
597 return 1.0;
598 };
599 let mut m = 1.0;
600 for u in UPGRADES.iter() {
601 if !self.has_upgrade(u.id) {
602 continue;
603 }
604 match u.effect {
605 UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
606 UpgradeEffect::AllFingerersMult(f) => m *= f,
607 _ => {}
608 }
609 }
610 for b in &self.buffs {
611 if let Buff::FingererBoost {
612 fingerer_id, mult, ..
613 } = b
614 && fingerer_id == target.id
615 {
616 m *= *mult;
617 }
618 }
619 m
620 }
621
622 fn add_cuques(&mut self, amount: f64) {
623 self.cuques += amount;
624 self.lifetime_cuques += amount;
625 }
626
627 pub fn dev_add_cuques(&mut self, amount: f64) {
630 self.add_cuques(amount);
631 self.cuques_flash_ticks = HUD_FLASH_TICKS;
632 }
633
634 pub fn catch_golden(&mut self) -> f64 {
639 use crate::game::golden::GoldenVariant;
640 let Some(golden) = self.golden.take() else {
641 return 0.0;
642 };
643 self.golden_caught += 1;
644 self.golden_cooldown = crate::game::golden::next_cooldown();
645 let (reward, label) = match golden.variant {
646 GoldenVariant::Lucky => {
647 let fps = self.fps();
648 let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
649 self.add_cuques(r);
650 self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
651 self.cuques_flash_ticks = HUD_FLASH_TICKS;
652 (r, format!("+{}", crate::format::big(r)))
653 }
654 GoldenVariant::Frenzy => {
655 let dur = TICK_HZ * 13;
656 self.buffs.push(Buff::ClickFrenzy {
657 ticks_remaining: dur,
658 initial_ticks: dur,
659 mult: 777.0,
660 });
661 (0.0, "FRENZY x777!".into())
662 }
663 GoldenVariant::Buff => {
664 let active_ids: Vec<&'static str> = FINGERERS
665 .iter()
666 .filter(|f| self.fingerer_count(f.id) > 0)
667 .map(|f| f.id)
668 .collect();
669 let pick = if active_ids.is_empty() {
670 FINGERERS[0].id
671 } else {
672 use rand::RngExt;
673 active_ids[rand::rng().random_range(0..active_ids.len())]
674 };
675 let dur = TICK_HZ * 60;
676 self.buffs.push(Buff::FingererBoost {
677 ticks_remaining: dur,
678 initial_ticks: dur,
679 fingerer_id: pick.to_string(),
680 mult: 7.0,
681 });
682 (0.0, "BOOSTED x7!".into())
683 }
684 };
685 self.particles.push(Particle {
686 frac_x: golden.frac_x,
687 frac_y: golden.frac_y,
688 life: PARTICLE_LIFE * 2,
689 text: label,
690 kind: ParticleKind::Golden,
691 drift_x: 0.0,
692 });
693 reward
694 }
695
696 pub fn fps(&self) -> f64 {
697 let base: f64 = FINGERERS
698 .iter()
699 .enumerate()
700 .map(|(i, k)| k.fps_per_unit * self.fingerer_count(k.id) as f64 * self.fingerer_mult(i))
701 .sum();
702 base * self.prestige_mult()
703 }
704
705 pub fn border_speed(&self) -> u32 {
706 let mut s: u32 = 1;
707 for b in &self.buffs {
708 match b {
709 Buff::ClickFrenzy { .. } => s = s.max(3),
710 Buff::FingererBoost { .. } => s = s.max(2),
711 }
712 }
713 if self.lucky_flash_ticks > 0 {
714 s = s.max(4);
715 }
716 if self.achievement_flash_ticks > 0 {
717 s = s.max(3);
718 }
719 if self.purchase_flash_ticks > 0 {
720 s += 2;
721 }
722 s
723 }
724
725 pub fn trigger_purchase_flash(&mut self, strength: f32) {
729 self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
730 self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
733 }
734
735 pub fn prestige_mult(&self) -> f64 {
736 1.0 + 0.01 * self.prestige as f64
737 }
738
739 pub fn prestige_earned_total(&self) -> u64 {
740 (self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
741 }
742
743 pub fn prestige_available(&self) -> u64 {
744 self.prestige_earned_total().saturating_sub(self.prestige)
745 }
746
747 pub fn prestige_reset(&mut self) -> bool {
748 let available = self.prestige_available();
749 if available == 0 {
750 return false;
751 }
752 self.prestige = self.prestige_earned_total();
753 self.cuques = 0.0;
754 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
759 self.fingerers_owned.clear();
760 self.upgrades_earned.clear();
761 self.buffs.clear();
762 self.visual_debt = 0.0;
763 self.particles.clear();
764 self.misclick_particles.clear();
765 self.golden = None;
766 self.clench_ticks = 0;
767 self.golden_cooldown = crate::game::golden::next_cooldown();
768 true
769 }
770
771 pub fn tick(&mut self) {
772 for b in self.buffs.iter_mut() {
773 b.tick();
774 }
775 self.buffs.retain(|b| b.ticks_remaining() > 0);
776
777 self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
778 self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
779 self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
780 if self.purchase_flash_ticks == 0 {
781 self.purchase_flash_strength = 1.0;
782 }
783 self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
784 self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
785 for t in self.fingerer_flash_ticks.iter_mut() {
786 *t = t.saturating_sub(1);
787 }
788 for t in self.upgrade_flash_ticks.iter_mut() {
789 *t = t.saturating_sub(1);
790 }
791 for t in self.fingerer_unaffordable_flash.iter_mut() {
792 *t = t.saturating_sub(1);
793 }
794 for t in self.upgrade_unaffordable_flash.iter_mut() {
795 *t = t.saturating_sub(1);
796 }
797 for t in self.fingerer_unlock_flash.iter_mut() {
798 *t = t.saturating_sub(1);
799 }
800 for t in self.upgrade_unlock_flash.iter_mut() {
801 *t = t.saturating_sub(1);
802 }
803 if self.space_pressed_this_tick {
813 self.ticks_since_last_press = 0;
814 } else {
815 self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
816 }
817 self.space_pressed_this_tick = false;
818 const HOLD_GRACE_TICKS: u32 = 3; if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
820 self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
821 } else {
822 self.space_hold_ticks = 0;
823 }
824 let speed = self.border_speed();
825 self.border_phase = self.border_phase.wrapping_add(speed);
826 self.steady_phase = self.steady_phase.wrapping_add(1);
827
828 let fps = self.fps();
829 if fps > self.best_fps {
830 self.best_fps = fps;
831 }
832 let gained = fps * TICK_DT;
833 self.add_cuques(gained);
834 self.visual_debt += gained;
835 self.clench_ticks = self.clench_ticks.saturating_sub(1);
836 for p in self.particles.iter_mut() {
837 p.life = p.life.saturating_sub(1);
838 p.frac_y -= PARTICLE_FRAC_RISE;
839 p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
842 }
843 self.particles.retain(|p| p.life > 0);
844 for m in self.misclick_particles.iter_mut() {
845 m.life = m.life.saturating_sub(1);
846 }
847 self.misclick_particles.retain(|m| m.life > 0);
848
849 let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
855 let upgrade_now: Vec<bool> = UPGRADES
856 .iter()
857 .map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
858 .collect();
859 for (i, &now) in fingerer_now.iter().enumerate() {
860 let was = self
861 .prev_fingerer_affordable
862 .get(i)
863 .copied()
864 .unwrap_or(false);
865 if now
866 && !was
867 && let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
868 {
869 *slot = UNLOCK_FLASH_TICKS;
870 }
871 if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
872 *slot = now;
873 }
874 }
875 for (i, &now) in upgrade_now.iter().enumerate() {
876 let was = self
877 .prev_upgrade_affordable
878 .get(i)
879 .copied()
880 .unwrap_or(false);
881 if now
882 && !was
883 && let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
884 {
885 *slot = UNLOCK_FLASH_TICKS;
886 }
887 if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
888 *slot = now;
889 }
890 }
891
892 const SNAP_BELOW: f64 = 5.0;
903 let tween = 0.18_f64;
904 let dc = self.cuques - self.displayed_cuques;
905 if dc.abs() < SNAP_BELOW {
906 self.displayed_cuques = self.cuques;
907 } else {
908 self.displayed_cuques += dc * tween;
909 }
910 let df = fps - self.displayed_fps;
911 if df.abs() < SNAP_BELOW {
912 self.displayed_fps = fps;
913 } else {
914 self.displayed_fps += df * tween;
915 }
916
917 self.session_ticks += 1;
918 self.total_play_ticks += 1;
919 self.tick_achievements();
924
925 self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
929 if self.active_unlock_ticks == 0 {
930 self.active_unlock_id = None;
931 if !self.newly_unlocked.is_empty() {
932 self.active_unlock_id = Some(self.newly_unlocked.remove(0));
933 self.active_unlock_ticks = TOAST_TICKS;
934 self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
935 }
936 }
937 }
938
939 pub fn tick_achievements(&mut self) {
940 for a in ACHIEVEMENTS.iter() {
941 if !self.has_achievement(a.id) && (a.unlocked)(self) {
942 self.achievements_earned.insert(a.id.to_string());
943 self.newly_unlocked.push(a.id.to_string());
944 }
945 }
946 }
947
948 pub fn tick_golden(&mut self) {
949 if let Some(g) = self.golden.as_mut() {
950 if g.life_ticks == 0 {
951 self.golden = None;
952 self.golden_cooldown = crate::game::golden::next_cooldown();
953 } else {
954 g.life_ticks -= 1;
955 }
956 } else if self.golden_cooldown > 0 {
957 self.golden_cooldown -= 1;
958 }
959 }
960
961 pub fn trigger_clench(&mut self) {
962 self.clench_ticks = CLENCH_TICKS;
963 }
964
965 pub fn space_held(&self) -> bool {
971 self.space_hold_ticks >= TICK_HZ
972 }
973
974 pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
982 let amount = self.visual_debt.floor() as u64;
983 if amount == 0 {
984 return;
985 }
986 self.visual_debt -= amount as f64;
987 let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
988 self.particles.push(Particle {
989 frac_x,
990 frac_y,
991 life: PARTICLE_LIFE,
992 text: format!("+{}", crate::format::big(amount as f64)),
993 kind: ParticleKind::Auto,
994 drift_x,
995 });
996 }
997
998 pub fn cost(&self, idx: usize) -> f64 {
999 let k = &FINGERERS[idx];
1000 let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
1008 raw.floor()
1009 }
1010
1011 pub fn affordable_cuques(&self) -> f64 {
1029 self.cuques.min(self.displayed_cuques.floor())
1030 }
1031
1032 pub fn can_buy(&self, idx: usize) -> bool {
1033 self.affordable_cuques() >= self.cost(idx)
1034 }
1035
1036 fn buy_one_quiet(&mut self, idx: usize) -> bool {
1040 let c = self.cost(idx);
1041 if self.affordable_cuques() >= c
1047 && let Some(f) = FINGERERS.get(idx)
1048 {
1049 self.cuques -= c;
1050 *self.fingerers_owned.entry(f.id.to_string()).or_insert(0) += 1;
1051 true
1052 } else {
1053 false
1054 }
1055 }
1056
1057 fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
1061 if bought == 0 {
1062 return;
1063 }
1064 let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
1067 self.trigger_purchase_flash(strength);
1068 match slot_table {
1069 PurchaseSlot::Fingerer => {
1070 if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
1071 *slot = PURCHASE_FLASH_TICKS;
1072 }
1073 }
1074 PurchaseSlot::Upgrade => {
1075 if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
1076 *slot = PURCHASE_FLASH_TICKS;
1077 }
1078 }
1079 }
1080 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1086 if bought >= 5 {
1087 self.spawn_confetti(bought.min(8));
1088 }
1089 }
1090
1091 fn flash_unaffordable_fingerer(&mut self, idx: usize) {
1092 if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
1093 *slot = PURCHASE_FLASH_TICKS / 2;
1094 }
1095 }
1096
1097 fn flash_unaffordable_upgrade(&mut self, idx: usize) {
1098 if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
1099 *slot = PURCHASE_FLASH_TICKS / 2;
1100 }
1101 }
1102
1103 pub fn buy(&mut self, idx: usize) -> bool {
1104 if self.buy_one_quiet(idx) {
1105 self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
1106 true
1107 } else {
1108 self.flash_unaffordable_fingerer(idx);
1109 false
1110 }
1111 }
1112
1113 pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
1114 let mut bought = 0;
1115 for _ in 0..n {
1116 if !self.buy_one_quiet(idx) {
1117 break;
1118 }
1119 bought += 1;
1120 }
1121 if bought == 0 {
1122 self.flash_unaffordable_fingerer(idx);
1123 } else {
1124 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1125 }
1126 bought
1127 }
1128
1129 pub fn buy_max(&mut self, idx: usize) -> u32 {
1130 let mut bought = 0;
1131 while self.buy_one_quiet(idx) {
1132 bought += 1;
1133 }
1134 if bought == 0 {
1135 self.flash_unaffordable_fingerer(idx);
1136 } else {
1137 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1138 }
1139 bought
1140 }
1141
1142 pub fn buy_upgrade(&mut self, idx: usize) -> bool {
1143 let Some(u) = UPGRADES.get(idx) else {
1144 return false;
1145 };
1146 if self.has_upgrade(u.id) {
1147 return false;
1148 }
1149 if !u.req.met(self) || self.affordable_cuques() < u.cost {
1152 self.flash_unaffordable_upgrade(idx);
1153 return false;
1154 }
1155 self.cuques -= u.cost;
1156 self.upgrades_earned.insert(u.id.to_string());
1157 self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
1158 true
1159 }
1160}
1161
1162#[derive(Clone, Copy)]
1163enum PurchaseSlot {
1164 Fingerer,
1165 Upgrade,
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171
1172 #[test]
1173 fn migrate_is_idempotent_on_current_shape() {
1174 let state = GameState {
1175 fingerers_owned: [("index_finger".to_string(), 9)].into_iter().collect(),
1176 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1177 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1178 ..GameState::default()
1179 };
1180
1181 let m = state.migrate();
1182
1183 assert_eq!(m.fingerer_count("index_finger"), 9);
1184 assert!(m.has_upgrade("click_mult_1"));
1185 assert!(m.has_achievement("first_finger"));
1186 }
1187
1188 #[test]
1189 fn unknown_ids_in_save_are_ignored_not_resurrected() {
1190 let state = GameState {
1194 fingerers_owned: [("giga_finger_from_the_future".to_string(), 42)]
1195 .into_iter()
1196 .collect(),
1197 ..GameState::default()
1198 };
1199
1200 let m = state.migrate();
1201
1202 assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
1203 assert_eq!(m.fingerer_count("index_finger"), 0);
1204 assert!(!m.has_upgrade("click_mult_1"));
1205 }
1206
1207 #[test]
1208 fn save_roundtrip_is_stable_through_json() {
1209 let state = GameState {
1212 cuques: 1234.5,
1213 total_clicks: 99,
1214 fingerers_owned: [("index_finger".to_string(), 7)].into_iter().collect(),
1215 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1216 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1217 ..GameState::default()
1218 };
1219
1220 let json = serde_json::to_string(&state).expect("serialize");
1221 let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
1222 let m = roundtripped.migrate();
1223
1224 assert_eq!(m.cuques, 1234.5);
1225 assert_eq!(m.total_clicks, 99);
1226 assert_eq!(m.fingerer_count("index_finger"), 7);
1227 assert!(m.has_upgrade("click_mult_1"));
1228 assert!(m.has_achievement("first_finger"));
1229 }
1230
1231 fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
1232 Rect {
1233 x,
1234 y,
1235 width: w,
1236 height: h,
1237 }
1238 }
1239
1240 #[test]
1241 fn frac_screen_roundtrip_at_corners() {
1242 let biscuit = r(10, 5, 40, 20);
1243 let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
1245 assert!(fx <= 0.001 && fy <= 0.001);
1246 let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
1247 assert_eq!((col, row), (10, 5));
1248
1249 let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
1251 assert!(fx >= 0.999 && fy >= 0.999);
1252
1253 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
1255 assert_eq!(col, 30);
1256 assert_eq!(row, 15);
1257 }
1258
1259 #[test]
1260 fn frac_position_survives_biscuit_move() {
1261 let small = r(0, 0, 40, 20);
1265 let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
1266 let large = r(10, 5, 80, 40);
1267 let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
1268 assert_ne!((col_a, row_a), (col_b, row_b));
1270 assert_eq!(col_b, 30); assert_eq!(row_b, 25); }
1275
1276 #[test]
1277 fn zero_size_biscuit_doesnt_panic() {
1278 let zero = r(0, 0, 0, 0);
1279 let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
1280 assert_eq!((fx, fy), (0.5, 0.5));
1281 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
1282 assert_eq!((col, row), (0, 0));
1283 }
1284
1285 #[test]
1288 fn buy_when_broke_sets_unaffordable_flash() {
1289 let mut s = GameState::default();
1293 s.cuques = 0.0;
1294 let bought = s.buy(0);
1295 assert!(!bought);
1296 assert!(
1297 s.fingerer_unaffordable_flash[0] > 0,
1298 "buy(0) on broke state must flash red"
1299 );
1300 assert!(
1301 s.fingerer_flash_ticks[0] == 0,
1302 "no purchase flash on reject"
1303 );
1304 }
1305
1306 #[test]
1307 fn buy_n_when_broke_sets_unaffordable_flash() {
1308 let mut s = GameState::default();
1309 s.cuques = 0.0;
1310 let bought = s.buy_n(0, 10);
1311 assert_eq!(bought, 0);
1312 assert!(s.fingerer_unaffordable_flash[0] > 0);
1313 }
1314
1315 #[test]
1316 fn bulk_buy_scales_purchase_flash_strength() {
1317 let mut s = GameState {
1324 cuques: 1_000_000.0,
1325 displayed_cuques: 1_000_000.0,
1326 ..Default::default()
1327 };
1328 s.buy(0);
1329 let single = s.purchase_flash_strength;
1330 assert!((1.0..=3.0).contains(&single));
1331
1332 let mut s = GameState {
1333 cuques: 1_000_000.0,
1334 displayed_cuques: 1_000_000.0,
1335 ..Default::default()
1336 };
1337 s.buy_n(0, 50);
1338 let bulk = s.purchase_flash_strength;
1339 assert!(
1340 bulk > single,
1341 "bulk strength must exceed single ({bulk} vs {single})"
1342 );
1343 assert!(bulk <= 3.0, "bulk strength capped at 3.0");
1344 }
1345
1346 #[test]
1347 fn buy_upgrade_when_broke_sets_unaffordable_flash() {
1348 let mut s = GameState::default();
1349 let cheapest_idx = (0..UPGRADES.len())
1351 .min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
1352 .unwrap();
1353 let bought = s.buy_upgrade(cheapest_idx);
1354 assert!(!bought);
1355 assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
1356 }
1357
1358 #[test]
1359 fn migrate_resizes_per_catalog_flash_vecs() {
1360 let json = serde_json::to_string(&GameState::default()).unwrap();
1365 let mut s: GameState = serde_json::from_str(&json).unwrap();
1366 s.fingerer_flash_ticks.clear();
1368 s.upgrade_flash_ticks.clear();
1369 s.fingerer_unaffordable_flash.clear();
1370 s.upgrade_unaffordable_flash.clear();
1371 let m = s.migrate();
1372 assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
1373 assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
1374 assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
1375 assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
1376 }
1377
1378 #[test]
1379 fn migrate_seeds_displayed_counters() {
1380 let s = GameState {
1383 cuques: 5_000.0,
1384 ..Default::default()
1385 };
1386 let m = s.migrate();
1387 assert_eq!(m.displayed_cuques, 5_000.0);
1388 assert_eq!(m.displayed_fps, 0.0);
1391 }
1392
1393 #[test]
1394 fn unlock_pop_sets_active_toast_and_gold_flash() {
1395 let mut s = GameState::default();
1399 let biscuit = r(0, 0, 40, 20);
1401 s.click((20, 10), biscuit);
1402 s.tick();
1403 assert!(s.active_unlock_id.is_some());
1405 assert!(s.active_unlock_ticks > 0);
1406 assert!(s.achievement_flash_ticks > 0);
1407 }
1408}