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::green_coin::GreenCoin;
11use crate::game::modifier::{
12 FingererAggregate, Modifier, ModifierDuration, ModifierEffect, ModifierSource,
13};
14use crate::game::upgrade::{UPGRADES, UpgradeEffect};
15
16pub const TICK_HZ: u32 = 20;
17pub const TICK_DT: f64 = 1.0 / TICK_HZ as f64;
18pub const CLENCH_TICKS: u32 = 6;
22pub const CLENCH_SQUASH_TICKS: u32 = 2;
26const PARTICLE_LIFE: u32 = 20;
27pub const MISCLICK_LIFE: u32 = 8;
29pub const TOAST_TICKS: u32 = TICK_HZ * 4;
31pub const HUD_FLASH_TICKS: u32 = TICK_HZ; pub const ACHIEVEMENT_FLASH_TICKS: u32 = TICK_HZ * 2;
36pub const UNLOCK_FLASH_TICKS: u32 = TICK_HZ / 2; const PARTICLE_FRAC_RISE: f32 = 0.006;
48const GOLDEN_REWARD_SECONDS: f64 = 60.0;
49const GOLDEN_REWARD_FLAT: f64 = 10.0;
50
51#[derive(Clone, Copy, PartialEq, Eq)]
54pub enum ParticleKind {
55 Click,
57 ClickBig,
60 Auto,
62 Golden,
65 Confetti,
67}
68
69#[derive(Clone)]
74pub struct Particle {
75 pub frac_x: f32,
76 pub frac_y: f32,
77 pub life: u32,
78 pub text: String,
79 pub kind: ParticleKind,
80 pub drift_x: f32,
84}
85
86#[derive(Clone)]
90pub struct MisclickParticle {
91 pub col: u16,
92 pub row: u16,
93 pub life: u32,
94}
95
96pub fn screen_to_biscuit_frac(col: u16, row: u16, biscuit: Rect) -> (f32, f32) {
100 if biscuit.width == 0 || biscuit.height == 0 {
101 return (0.5, 0.5);
102 }
103 let fx = ((col as i32 - biscuit.x as i32) as f32) / biscuit.width as f32;
104 let fy = ((row as i32 - biscuit.y as i32) as f32) / biscuit.height as f32;
105 (fx.clamp(0.0, 1.0), fy.clamp(0.0, 1.0))
106}
107
108pub fn biscuit_frac_to_screen(frac_x: f32, frac_y: f32, biscuit: Rect) -> (u16, u16) {
110 let col = biscuit.x as f32 + frac_x.clamp(0.0, 1.0) * biscuit.width as f32;
111 let row = biscuit.y as f32 + frac_y.clamp(0.0, 1.0) * biscuit.height as f32;
112 (
113 col.round().clamp(0.0, u16::MAX as f32) as u16,
114 row.round().clamp(0.0, u16::MAX as f32) as u16,
115 )
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize)]
123pub enum Buff {
124 ClickFrenzy {
125 ticks_remaining: u32,
126 initial_ticks: u32,
127 mult: f64,
128 },
129}
130
131impl Buff {
132 pub fn ticks_remaining(&self) -> u32 {
133 match self {
134 Buff::ClickFrenzy {
135 ticks_remaining, ..
136 } => *ticks_remaining,
137 }
138 }
139
140 pub fn strength(&self) -> f32 {
144 const FADE_TICKS: f32 = 30.0; let remaining = self.ticks_remaining() as f32;
146 if remaining >= FADE_TICKS {
147 1.0
148 } else {
149 let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
150 t * t * (3.0 - 2.0 * t)
151 }
152 }
153
154 fn tick(&mut self) {
155 match self {
156 Buff::ClickFrenzy {
157 ticks_remaining, ..
158 } => {
159 *ticks_remaining = ticks_remaining.saturating_sub(1);
160 }
161 }
162 }
163}
164
165#[derive(Clone, Debug, Default, Serialize, Deserialize)]
175pub struct FingererState {
176 #[serde(default)]
177 pub count: u32,
178 #[serde(default)]
179 pub modifiers: Vec<Modifier>,
180 #[serde(skip)]
184 pub aggregate: FingererAggregate,
185}
186
187#[derive(Clone, Serialize, Deserialize)]
194pub struct GameState {
195 #[serde(default = "default_save_version")]
202 pub version: u32,
203 #[serde(default)]
204 pub cuques: f64,
205 #[serde(default)]
206 pub total_clicks: u64,
207 #[serde(default)]
208 pub lifetime_cuques: f64,
209 #[serde(default)]
210 pub best_fps: f64,
211 #[serde(default)]
217 pub golden_caught: u64,
218 #[serde(default)]
219 pub lucky_caught: u64,
220 #[serde(default)]
221 pub frenzy_caught: u64,
222 #[serde(default)]
223 pub buff_caught: u64,
224 #[serde(default)]
225 pub green_coin_caught: u64,
226
227 #[serde(default)]
229 pub fingerers_state: HashMap<String, FingererState>,
230 #[serde(default)]
232 pub achievements_earned: HashSet<String>,
233 #[serde(default)]
235 pub upgrades_earned: HashSet<String>,
236
237 #[serde(default)]
238 pub prestige: u64,
239 #[serde(default)]
240 pub total_play_ticks: u64,
241 #[serde(default)]
242 pub buffs: Vec<Buff>,
243 #[serde(default)]
248 pub goldens_since_green_coin: u32,
249
250 #[serde(skip)]
251 pub clench_ticks: u32,
252 #[serde(skip)]
253 pub particles: Vec<Particle>,
254 #[serde(skip)]
258 pub misclick_particles: Vec<MisclickParticle>,
259 #[serde(skip)]
265 pub goldens: [Option<GoldenCuque>; 3],
266 #[serde(skip)]
273 pub golden_cooldowns: [u32; 3],
274 #[serde(skip)]
279 pub green_coin: Option<GreenCoin>,
280 #[serde(skip)]
281 pub session_ticks: u64,
282 #[serde(skip)]
285 pub newly_unlocked: Vec<String>,
286 #[serde(skip)]
290 pub active_unlock_id: Option<String>,
291 #[serde(skip)]
292 pub active_unlock_ticks: u32,
293 #[serde(skip)]
294 pub visual_debt: f64,
295 #[serde(skip)]
296 pub lucky_flash_ticks: u32,
297 #[serde(skip)]
298 pub achievement_flash_ticks: u32,
299 #[serde(skip)]
304 pub green_coin_flash_ticks: u32,
305 #[serde(skip)]
311 pub border_phase: u32,
312 #[serde(skip)]
319 pub steady_phase: u32,
320 #[serde(skip)]
321 pub purchase_flash_ticks: u32,
322 #[serde(skip)]
326 pub purchase_flash_strength: f32,
327 #[serde(skip)]
330 pub fingerer_flash_ticks: Vec<u32>,
331 #[serde(skip)]
334 pub upgrade_flash_ticks: Vec<u32>,
335 #[serde(skip)]
338 pub fingerer_unaffordable_flash: Vec<u32>,
339 #[serde(skip)]
340 pub upgrade_unaffordable_flash: Vec<u32>,
341 #[serde(skip)]
347 pub fingerer_unlock_flash: Vec<u32>,
348 #[serde(skip)]
349 pub upgrade_unlock_flash: Vec<u32>,
350 #[serde(skip)]
356 pub fingerer_green_coin_flash: Vec<u32>,
357 #[serde(skip)]
363 pub prev_fingerer_affordable: Vec<bool>,
364 #[serde(skip)]
365 pub prev_upgrade_affordable: Vec<bool>,
366 #[serde(skip)]
381 pub space_pressed_this_tick: bool,
382 #[serde(skip)]
383 pub ticks_since_last_press: u32,
384 #[serde(skip)]
385 pub space_hold_ticks: u32,
386 #[serde(skip)]
390 pub displayed_cuques: f64,
391 #[serde(skip)]
392 pub displayed_fps: f64,
393 #[serde(skip)]
396 pub cuques_flash_ticks: u32,
397 #[serde(skip)]
404 pub cuques_spend_flash_ticks: u32,
405}
406
407pub const LUCKY_FLASH_TICKS: u32 = 70; pub const PURCHASE_FLASH_TICKS: u32 = 20; pub const GREEN_COIN_FLASH_TICKS: u32 = 50; pub const GREEN_COIN_ROW_FLASH_TICKS: u32 = TICK_HZ * 2; fn default_save_version() -> u32 {
424 crate::save::CURRENT_VERSION
425}
426
427impl Default for GameState {
428 fn default() -> Self {
429 Self {
430 version: crate::save::CURRENT_VERSION,
431 cuques: 0.0,
432 total_clicks: 0,
433 lifetime_cuques: 0.0,
434 best_fps: 0.0,
435 golden_caught: 0,
436 lucky_caught: 0,
437 frenzy_caught: 0,
438 buff_caught: 0,
439 green_coin_caught: 0,
440 fingerers_state: HashMap::new(),
441 achievements_earned: HashSet::new(),
442 upgrades_earned: HashSet::new(),
443 prestige: 0,
444 total_play_ticks: 0,
445 buffs: Vec::new(),
446 goldens_since_green_coin: 0,
447 clench_ticks: 0,
448 particles: Vec::new(),
449 misclick_particles: Vec::new(),
450 goldens: [None, None, None],
451 golden_cooldowns: [
452 crate::game::golden::next_cooldown(),
453 crate::game::golden::next_cooldown(),
454 crate::game::golden::next_cooldown(),
455 ],
456 green_coin: None,
457 session_ticks: 0,
458 newly_unlocked: Vec::new(),
459 active_unlock_id: None,
460 active_unlock_ticks: 0,
461 visual_debt: 0.0,
462 lucky_flash_ticks: 0,
463 achievement_flash_ticks: 0,
464 green_coin_flash_ticks: 0,
465 border_phase: 0,
466 steady_phase: 0,
467 purchase_flash_ticks: 0,
468 purchase_flash_strength: 1.0,
469 fingerer_flash_ticks: vec![0; fingerer::count()],
470 upgrade_flash_ticks: vec![0; UPGRADES.len()],
471 fingerer_unaffordable_flash: vec![0; fingerer::count()],
472 upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
473 fingerer_unlock_flash: vec![0; fingerer::count()],
474 upgrade_unlock_flash: vec![0; UPGRADES.len()],
475 fingerer_green_coin_flash: vec![0; fingerer::count()],
476 prev_fingerer_affordable: vec![false; fingerer::count()],
477 prev_upgrade_affordable: vec![false; UPGRADES.len()],
478 space_pressed_this_tick: false,
479 ticks_since_last_press: u32::MAX,
480 space_hold_ticks: 0,
481 displayed_cuques: 0.0,
482 displayed_fps: 0.0,
483 cuques_flash_ticks: 0,
484 cuques_spend_flash_ticks: 0,
485 }
486 }
487}
488
489impl GameState {
490 pub fn migrate_runtime(mut self) -> Self {
500 for st in self.fingerers_state.values_mut() {
503 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
504 }
505 if self.fingerer_flash_ticks.len() != fingerer::count() {
508 self.fingerer_flash_ticks = vec![0; fingerer::count()];
509 }
510 if self.upgrade_flash_ticks.len() != UPGRADES.len() {
511 self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
512 }
513 if self.fingerer_unaffordable_flash.len() != fingerer::count() {
514 self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
515 }
516 if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
517 self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
518 }
519 if self.fingerer_unlock_flash.len() != fingerer::count() {
520 self.fingerer_unlock_flash = vec![0; fingerer::count()];
521 }
522 if self.upgrade_unlock_flash.len() != UPGRADES.len() {
523 self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
524 }
525 if self.fingerer_green_coin_flash.len() != fingerer::count() {
526 self.fingerer_green_coin_flash = vec![0; fingerer::count()];
527 }
528 if self.prev_fingerer_affordable.len() != fingerer::count() {
532 self.prev_fingerer_affordable =
533 (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
534 }
535 if self.prev_upgrade_affordable.len() != UPGRADES.len() {
536 self.prev_upgrade_affordable = (0..UPGRADES.len())
537 .map(|i| {
538 let u = &UPGRADES[i];
539 !self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
540 })
541 .collect();
542 }
543 for cd in self.golden_cooldowns.iter_mut() {
547 if *cd == 0 {
548 *cd = crate::game::golden::next_cooldown();
549 }
550 }
551 self.displayed_cuques = self.cuques;
554 self.displayed_fps = 0.0; if self.purchase_flash_strength <= 0.0 {
556 self.purchase_flash_strength = 1.0;
557 }
558 self
559 }
560
561 pub fn fingerer_count(&self, id: &str) -> u32 {
564 self.fingerers_state.get(id).map(|st| st.count).unwrap_or(0)
565 }
566
567 pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
568 FINGERERS
569 .get(idx)
570 .map(|f| self.fingerer_count(f.id))
571 .unwrap_or(0)
572 }
573
574 pub fn fingerers_owned_total(&self) -> u32 {
575 self.fingerers_state.values().map(|st| st.count).sum()
576 }
577
578 pub fn fingerer_aggregate(&self, id: &str) -> FingererAggregate {
582 self.fingerers_state
583 .get(id)
584 .map(|st| st.aggregate)
585 .unwrap_or_default()
586 }
587
588 pub fn attach_modifier(&mut self, fingerer_id: &str, m: Modifier) {
593 let st = self
594 .fingerers_state
595 .entry(fingerer_id.to_string())
596 .or_default();
597 st.modifiers.push(m);
598 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
599 }
600
601 pub fn attach_modifier_random_owned(&mut self, m: Modifier) -> Option<String> {
606 let owned: Vec<String> = self
607 .fingerers_state
608 .iter()
609 .filter(|(_, st)| st.count > 0)
610 .map(|(id, _)| id.clone())
611 .collect();
612 if owned.is_empty() {
613 return None;
614 }
615 let pick = owned[rand::rng().random_range(0..owned.len())].clone();
616 self.attach_modifier(&pick, m);
617 Some(pick)
618 }
619
620 pub fn attach_modifier_random_visible(&mut self, m: Modifier) -> Option<String> {
632 let visible: Vec<String> = FINGERERS
633 .iter()
634 .enumerate()
635 .filter(|(idx, f)| {
636 let owned = self.fingerer_count(f.id);
637 fingerer::visible(*idx, owned, self.lifetime_cuques)
638 })
639 .map(|(_, f)| f.id.to_string())
640 .collect();
641 if visible.is_empty() {
642 return None;
643 }
644 let pick = visible[rand::rng().random_range(0..visible.len())].clone();
645 self.attach_modifier(&pick, m);
646 Some(pick)
647 }
648
649 pub fn has_upgrade(&self, id: &str) -> bool {
650 self.upgrades_earned.contains(id)
651 }
652
653 pub fn has_achievement(&self, id: &str) -> bool {
654 self.achievements_earned.contains(id)
655 }
656
657 pub fn has_achievement_idx(&self, idx: usize) -> bool {
658 ACHIEVEMENTS
659 .get(idx)
660 .is_some_and(|a| self.has_achievement(a.id))
661 }
662
663 pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
666 let power = self.click_power();
667 self.add_cuques(power);
668 self.total_clicks += 1;
669 self.clench_ticks = CLENCH_TICKS;
670 if power >= 50.0 {
674 self.cuques_flash_ticks = HUD_FLASH_TICKS;
675 }
676 let mut rng = rand::rng();
677 let jitter_x_range = (biscuit.width as i32 / 8).max(3);
682 let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
683 let jitter_y = rng.random_range(-1..=1);
684 let col = (origin.0 as i32 + jitter_x).max(0) as u16;
685 let row = origin
686 .1
687 .saturating_sub(1)
688 .saturating_add_signed(jitter_y as i16);
689 let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
690 let drift_x = rng.random_range(-0.012_f32..=0.012);
691 let frenzy_active = self
692 .buffs
693 .iter()
694 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
695 let kind = if power >= 50.0 || frenzy_active {
699 ParticleKind::ClickBig
700 } else {
701 ParticleKind::Click
702 };
703 self.particles.push(Particle {
704 frac_x,
705 frac_y,
706 life: PARTICLE_LIFE,
707 text: format!("+{}", crate::format::big(power)),
708 kind,
709 drift_x,
710 });
711 if frenzy_active {
714 for _ in 0..2 {
715 let halo_x = rng.random_range(-0.05_f32..=0.05);
716 let halo_y = rng.random_range(-0.04_f32..=0.04);
717 let (hfx, hfy) =
718 screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
719 self.particles.push(Particle {
720 frac_x: (hfx + halo_x).clamp(0.0, 1.0),
721 frac_y: (hfy + halo_y).clamp(0.0, 1.0),
722 life: PARTICLE_LIFE / 2,
723 text: "*".into(),
724 kind: ParticleKind::Confetti,
725 drift_x: rng.random_range(-0.02_f32..=0.02),
726 });
727 }
728 }
729 }
730
731 pub fn spawn_misclick(&mut self, col: u16, row: u16) {
735 if self.misclick_particles.len() >= 16 {
737 self.misclick_particles.remove(0);
738 }
739 self.misclick_particles.push(MisclickParticle {
740 col,
741 row,
742 life: MISCLICK_LIFE,
743 });
744 }
745
746 pub fn spawn_confetti(&mut self, n: u32) {
749 if n == 0 {
750 return;
751 }
752 let mut rng = rand::rng();
753 let glyphs = ['*', '+', '~', '.', 'o'];
754 for _ in 0..n.min(8) {
755 let glyph = glyphs[rng.random_range(0..glyphs.len())];
756 self.particles.push(Particle {
757 frac_x: rng.random_range(0.10_f32..=0.90),
758 frac_y: rng.random_range(0.20_f32..=0.85),
759 life: PARTICLE_LIFE,
760 text: glyph.to_string(),
761 kind: ParticleKind::Confetti,
762 drift_x: rng.random_range(-0.02_f32..=0.02),
763 });
764 }
765 }
766
767 pub fn click_power(&self) -> f64 {
768 let mut m = 1.0;
769 for u in UPGRADES.iter() {
770 if self.has_upgrade(u.id)
771 && let UpgradeEffect::ClickMult(f) = u.effect
772 {
773 m *= f;
774 }
775 }
776 for b in &self.buffs {
777 let Buff::ClickFrenzy { mult, .. } = b;
778 m *= *mult;
779 }
780 m
781 }
782
783 pub fn fingerer_mult(&self, idx: usize) -> f64 {
784 let Some(target) = FINGERERS.get(idx) else {
785 return 1.0;
786 };
787 let mut m = 1.0;
788 for u in UPGRADES.iter() {
789 if !self.has_upgrade(u.id) {
790 continue;
791 }
792 match u.effect {
793 UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
794 UpgradeEffect::AllFingerersMult(f) => m *= f,
795 _ => {}
796 }
797 }
798 m
803 }
804
805 fn add_cuques(&mut self, amount: f64) {
806 self.cuques += amount;
807 self.lifetime_cuques += amount;
808 }
809
810 pub fn dev_add_cuques(&mut self, amount: f64) {
813 self.add_cuques(amount);
814 self.cuques_flash_ticks = HUD_FLASH_TICKS;
815 }
816
817 pub fn catch_golden(&mut self, variant: crate::game::golden::GoldenVariant) -> f64 {
831 use crate::game::golden::GoldenVariant;
832 let Some(golden) = self.goldens[variant as usize].take() else {
833 return 0.0;
834 };
835 self.golden_caught += 1;
836 self.golden_cooldowns[variant as usize] = crate::game::golden::next_cooldown();
839 let (reward, label) = match golden.variant {
840 GoldenVariant::Lucky => {
841 self.lucky_caught += 1;
842 let fps = self.fps();
843 let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
844 self.add_cuques(r);
845 self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
846 self.cuques_flash_ticks = HUD_FLASH_TICKS;
847 (r, format!("+{}", crate::format::big(r)))
848 }
849 GoldenVariant::Frenzy => {
850 self.frenzy_caught += 1;
851 let dur = TICK_HZ * 13;
852 self.buffs.push(Buff::ClickFrenzy {
853 ticks_remaining: dur,
854 initial_ticks: dur,
855 mult: 777.0,
856 });
857 (0.0, "FRENZY x777!".into())
858 }
859 GoldenVariant::Buff => {
860 self.buff_caught += 1;
861 let dur = TICK_HZ * 60;
862 let m = Modifier {
863 source: crate::game::modifier::ModifierSource::PurpleCoin,
864 effects: vec![crate::game::modifier::ModifierEffect::MulFactor(7.0)],
865 duration: ModifierDuration::Ticks(dur),
866 created_at_tick: self.total_play_ticks,
867 };
868 if self.attach_modifier_random_owned(m.clone()).is_none() {
871 let pick = FINGERERS[0].id;
872 self.attach_modifier(pick, m);
873 }
874 (0.0, "BOOSTED x7!".into())
875 }
876 };
877 self.particles.push(Particle {
878 frac_x: golden.frac_x,
879 frac_y: golden.frac_y,
880 life: PARTICLE_LIFE * 2,
881 text: label,
882 kind: ParticleKind::Golden,
883 drift_x: 0.0,
884 });
885 reward
886 }
887
888 pub fn fps(&self) -> f64 {
889 let base: f64 = FINGERERS
894 .iter()
895 .enumerate()
896 .map(|(i, k)| {
897 let count = self.fingerer_count(k.id) as f64;
898 let upgrades_mult = self.fingerer_mult(i);
899 let agg = self.fingerer_aggregate(k.id);
900 let pre = (k.fps_per_unit * count + agg.flat_fps) * upgrades_mult;
901 pre * (1.0 + agg.add_percent) * agg.mul_factor
902 })
903 .sum();
904 base * self.prestige_mult()
905 }
906
907 pub fn border_speed(&self) -> u32 {
908 let mut s: u32 = 1;
909 for b in &self.buffs {
910 match b {
911 Buff::ClickFrenzy { .. } => s = s.max(3),
912 }
913 }
914 if self.fingerers_state.values().any(|st| {
918 st.modifiers
919 .iter()
920 .any(|m| matches!(m.duration, ModifierDuration::Ticks(_)))
921 }) {
922 s = s.max(2);
923 }
924 if self.lucky_flash_ticks > 0 {
925 s = s.max(4);
926 }
927 if self.achievement_flash_ticks > 0 {
928 s = s.max(3);
929 }
930 if self.purchase_flash_ticks > 0 {
931 s += 2;
932 }
933 s
934 }
935
936 pub fn trigger_purchase_flash(&mut self, strength: f32) {
940 self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
941 self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
944 }
945
946 pub fn prestige_mult(&self) -> f64 {
947 1.0 + 0.01 * self.prestige as f64
948 }
949
950 pub fn prestige_earned_total(&self) -> u64 {
951 (self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
952 }
953
954 pub fn prestige_available(&self) -> u64 {
955 self.prestige_earned_total().saturating_sub(self.prestige)
956 }
957
958 pub fn prestige_reset(&mut self) -> bool {
959 let available = self.prestige_available();
960 if available == 0 {
961 return false;
962 }
963 self.prestige = self.prestige_earned_total();
964 self.cuques = 0.0;
965 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
970 self.fingerers_state.clear();
973 self.upgrades_earned.clear();
974 self.buffs.clear();
975 self.visual_debt = 0.0;
976 self.particles.clear();
977 self.misclick_particles.clear();
978 self.goldens = [None, None, None];
979 self.green_coin = None;
980 self.goldens_since_green_coin = 0;
982 self.clench_ticks = 0;
983 for cd in self.golden_cooldowns.iter_mut() {
986 *cd = crate::game::golden::next_cooldown();
987 }
988 true
989 }
990
991 pub fn tick(&mut self) {
992 for st in self.fingerers_state.values_mut() {
998 let before = st.modifiers.len();
999 st.modifiers.retain_mut(|m| match &mut m.duration {
1000 ModifierDuration::Permanent => true,
1001 ModifierDuration::Ticks(0) => false,
1002 ModifierDuration::Ticks(n) => {
1003 *n -= 1;
1004 true
1005 }
1006 });
1007 if before != st.modifiers.len() {
1008 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
1009 }
1010 }
1011
1012 for b in self.buffs.iter_mut() {
1013 b.tick();
1014 }
1015 self.buffs.retain(|b| b.ticks_remaining() > 0);
1016
1017 self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
1018 self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
1019 self.green_coin_flash_ticks = self.green_coin_flash_ticks.saturating_sub(1);
1020 self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
1021 if self.purchase_flash_ticks == 0 {
1022 self.purchase_flash_strength = 1.0;
1023 }
1024 self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
1025 self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
1026 for t in self.fingerer_flash_ticks.iter_mut() {
1027 *t = t.saturating_sub(1);
1028 }
1029 for t in self.upgrade_flash_ticks.iter_mut() {
1030 *t = t.saturating_sub(1);
1031 }
1032 for t in self.fingerer_unaffordable_flash.iter_mut() {
1033 *t = t.saturating_sub(1);
1034 }
1035 for t in self.upgrade_unaffordable_flash.iter_mut() {
1036 *t = t.saturating_sub(1);
1037 }
1038 for t in self.fingerer_unlock_flash.iter_mut() {
1039 *t = t.saturating_sub(1);
1040 }
1041 for t in self.upgrade_unlock_flash.iter_mut() {
1042 *t = t.saturating_sub(1);
1043 }
1044 for t in self.fingerer_green_coin_flash.iter_mut() {
1045 *t = t.saturating_sub(1);
1046 }
1047 if self.space_pressed_this_tick {
1057 self.ticks_since_last_press = 0;
1058 } else {
1059 self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
1060 }
1061 self.space_pressed_this_tick = false;
1062 const HOLD_GRACE_TICKS: u32 = 3; if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
1064 self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
1065 } else {
1066 self.space_hold_ticks = 0;
1067 }
1068 let speed = self.border_speed();
1069 self.border_phase = self.border_phase.wrapping_add(speed);
1070 self.steady_phase = self.steady_phase.wrapping_add(1);
1071
1072 let fps = self.fps();
1073 if fps > self.best_fps {
1074 self.best_fps = fps;
1075 }
1076 let gained = fps * TICK_DT;
1077 self.add_cuques(gained);
1078 self.visual_debt += gained;
1079 self.clench_ticks = self.clench_ticks.saturating_sub(1);
1080 for p in self.particles.iter_mut() {
1081 p.life = p.life.saturating_sub(1);
1082 p.frac_y -= PARTICLE_FRAC_RISE;
1083 p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
1086 }
1087 self.particles.retain(|p| p.life > 0);
1088 for m in self.misclick_particles.iter_mut() {
1089 m.life = m.life.saturating_sub(1);
1090 }
1091 self.misclick_particles.retain(|m| m.life > 0);
1092
1093 let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
1099 let upgrade_now: Vec<bool> = UPGRADES
1100 .iter()
1101 .map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
1102 .collect();
1103 for (i, &now) in fingerer_now.iter().enumerate() {
1104 let was = self
1105 .prev_fingerer_affordable
1106 .get(i)
1107 .copied()
1108 .unwrap_or(false);
1109 if now
1110 && !was
1111 && let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
1112 {
1113 *slot = UNLOCK_FLASH_TICKS;
1114 }
1115 if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
1116 *slot = now;
1117 }
1118 }
1119 for (i, &now) in upgrade_now.iter().enumerate() {
1120 let was = self
1121 .prev_upgrade_affordable
1122 .get(i)
1123 .copied()
1124 .unwrap_or(false);
1125 if now
1126 && !was
1127 && let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
1128 {
1129 *slot = UNLOCK_FLASH_TICKS;
1130 }
1131 if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
1132 *slot = now;
1133 }
1134 }
1135
1136 const SNAP_BELOW: f64 = 5.0;
1147 let tween = 0.18_f64;
1148 let dc = self.cuques - self.displayed_cuques;
1149 if dc.abs() < SNAP_BELOW {
1150 self.displayed_cuques = self.cuques;
1151 } else {
1152 self.displayed_cuques += dc * tween;
1153 }
1154 let df = fps - self.displayed_fps;
1155 if df.abs() < SNAP_BELOW {
1156 self.displayed_fps = fps;
1157 } else {
1158 self.displayed_fps += df * tween;
1159 }
1160
1161 self.session_ticks += 1;
1162 self.total_play_ticks += 1;
1163 self.tick_achievements();
1168
1169 self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
1173 if self.active_unlock_ticks == 0 {
1174 self.active_unlock_id = None;
1175 if !self.newly_unlocked.is_empty() {
1176 self.active_unlock_id = Some(self.newly_unlocked.remove(0));
1177 self.active_unlock_ticks = TOAST_TICKS;
1178 self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
1179 }
1180 }
1181 }
1182
1183 pub fn tick_achievements(&mut self) {
1184 for a in ACHIEVEMENTS.iter() {
1185 if !self.has_achievement(a.id) && (a.unlocked)(self) {
1186 self.achievements_earned.insert(a.id.to_string());
1187 self.newly_unlocked.push(a.id.to_string());
1188 }
1189 }
1190 }
1191
1192 pub fn tick_golden(&mut self) {
1193 for i in 0..self.goldens.len() {
1198 if let Some(g) = self.goldens[i].as_mut() {
1199 if g.life_ticks == 0 {
1200 self.goldens[i] = None;
1201 self.golden_cooldowns[i] = crate::game::golden::next_cooldown();
1202 } else {
1203 g.life_ticks -= 1;
1204 }
1205 } else if self.golden_cooldowns[i] > 0 {
1206 self.golden_cooldowns[i] -= 1;
1207 }
1208 }
1209 }
1210
1211 pub fn tick_green_coin(&mut self) {
1216 if let Some(g) = self.green_coin.as_mut() {
1217 if g.life_ticks == 0 {
1218 self.green_coin = None;
1219 } else {
1220 g.life_ticks -= 1;
1221 }
1222 }
1223 }
1224
1225 pub fn catch_green_coin(&mut self) -> bool {
1235 let Some(g) = self.green_coin.take() else {
1236 return false;
1237 };
1238 let m = Modifier {
1239 source: ModifierSource::GreenCoin,
1240 effects: vec![ModifierEffect::AddPercent(
1241 crate::game::green_coin::GREEN_COIN_ADD_PERCENT,
1242 )],
1243 duration: ModifierDuration::Permanent,
1244 created_at_tick: self.total_play_ticks,
1245 };
1246 let chosen = self.attach_modifier_random_visible(m);
1252 self.golden_caught += 1;
1257 self.green_coin_caught += 1;
1258 self.green_coin_flash_ticks = GREEN_COIN_FLASH_TICKS;
1259 let label = match &chosen {
1264 Some(id) => {
1265 let idx = FINGERERS.iter().position(|f| f.id == id);
1266 if let Some(i) = idx
1267 && let Some(slot) = self.fingerer_green_coin_flash.get_mut(i)
1268 {
1269 *slot = GREEN_COIN_ROW_FLASH_TICKS;
1270 }
1271 let name = idx
1272 .and_then(|i| crate::i18n::t().fingerer_names.get(i).copied())
1273 .unwrap_or("?");
1274 format!("+10% {}", name)
1275 }
1276 None => "+10% ???".to_string(),
1278 };
1279 self.particles.push(Particle {
1280 frac_x: g.frac_x,
1281 frac_y: g.frac_y,
1282 life: PARTICLE_LIFE * 2,
1283 text: label,
1284 kind: ParticleKind::Golden,
1285 drift_x: 0.0,
1286 });
1287 true
1288 }
1289
1290 pub fn trigger_clench(&mut self) {
1291 self.clench_ticks = CLENCH_TICKS;
1292 }
1293
1294 pub fn space_held(&self) -> bool {
1300 self.space_hold_ticks >= TICK_HZ
1301 }
1302
1303 pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
1311 let amount = self.visual_debt.floor() as u64;
1312 if amount == 0 {
1313 return;
1314 }
1315 self.visual_debt -= amount as f64;
1316 let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
1317 self.particles.push(Particle {
1318 frac_x,
1319 frac_y,
1320 life: PARTICLE_LIFE,
1321 text: format!("+{}", crate::format::big(amount as f64)),
1322 kind: ParticleKind::Auto,
1323 drift_x,
1324 });
1325 }
1326
1327 pub fn cost(&self, idx: usize) -> f64 {
1328 let k = &FINGERERS[idx];
1329 let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
1337 raw.floor()
1338 }
1339
1340 pub fn affordable_cuques(&self) -> f64 {
1358 self.cuques.min(self.displayed_cuques.floor())
1359 }
1360
1361 pub fn can_buy(&self, idx: usize) -> bool {
1362 self.affordable_cuques() >= self.cost(idx)
1363 }
1364
1365 fn buy_one_quiet(&mut self, idx: usize) -> bool {
1369 let c = self.cost(idx);
1370 if self.affordable_cuques() >= c
1376 && let Some(f) = FINGERERS.get(idx)
1377 {
1378 self.cuques -= c;
1379 self.fingerers_state
1380 .entry(f.id.to_string())
1381 .or_default()
1382 .count += 1;
1383 true
1384 } else {
1385 false
1386 }
1387 }
1388
1389 fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
1393 if bought == 0 {
1394 return;
1395 }
1396 let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
1399 self.trigger_purchase_flash(strength);
1400 match slot_table {
1401 PurchaseSlot::Fingerer => {
1402 if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
1403 *slot = PURCHASE_FLASH_TICKS;
1404 }
1405 }
1406 PurchaseSlot::Upgrade => {
1407 if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
1408 *slot = PURCHASE_FLASH_TICKS;
1409 }
1410 }
1411 }
1412 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1418 if bought >= 5 {
1419 self.spawn_confetti(bought.min(8));
1420 }
1421 }
1422
1423 fn flash_unaffordable_fingerer(&mut self, idx: usize) {
1424 if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
1425 *slot = PURCHASE_FLASH_TICKS / 2;
1426 }
1427 }
1428
1429 fn flash_unaffordable_upgrade(&mut self, idx: usize) {
1430 if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
1431 *slot = PURCHASE_FLASH_TICKS / 2;
1432 }
1433 }
1434
1435 pub fn buy(&mut self, idx: usize) -> bool {
1436 if self.buy_one_quiet(idx) {
1437 self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
1438 true
1439 } else {
1440 self.flash_unaffordable_fingerer(idx);
1441 false
1442 }
1443 }
1444
1445 pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
1446 let mut bought = 0;
1447 for _ in 0..n {
1448 if !self.buy_one_quiet(idx) {
1449 break;
1450 }
1451 bought += 1;
1452 }
1453 if bought == 0 {
1454 self.flash_unaffordable_fingerer(idx);
1455 } else {
1456 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1457 }
1458 bought
1459 }
1460
1461 pub fn buy_max(&mut self, idx: usize) -> u32 {
1462 let mut bought = 0;
1463 while self.buy_one_quiet(idx) {
1464 bought += 1;
1465 }
1466 if bought == 0 {
1467 self.flash_unaffordable_fingerer(idx);
1468 } else {
1469 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1470 }
1471 bought
1472 }
1473
1474 pub fn buy_upgrade(&mut self, idx: usize) -> bool {
1475 let Some(u) = UPGRADES.get(idx) else {
1476 return false;
1477 };
1478 if self.has_upgrade(u.id) {
1479 return false;
1480 }
1481 if !u.req.met(self) || self.affordable_cuques() < u.cost {
1484 self.flash_unaffordable_upgrade(idx);
1485 return false;
1486 }
1487 self.cuques -= u.cost;
1488 self.upgrades_earned.insert(u.id.to_string());
1489 self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
1490 true
1491 }
1492}
1493
1494#[derive(Clone, Copy)]
1495enum PurchaseSlot {
1496 Fingerer,
1497 Upgrade,
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 use super::*;
1503 use crate::game::modifier::{Modifier, ModifierEffect, ModifierSource};
1504
1505 fn fs_with_count(count: u32) -> FingererState {
1506 FingererState {
1507 count,
1508 ..Default::default()
1509 }
1510 }
1511
1512 #[test]
1513 fn migrate_is_idempotent_on_current_shape() {
1514 let state = GameState {
1515 fingerers_state: [("index_finger".to_string(), fs_with_count(9))]
1516 .into_iter()
1517 .collect(),
1518 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1519 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1520 ..GameState::default()
1521 };
1522
1523 let m = state.migrate_runtime();
1524
1525 assert_eq!(m.fingerer_count("index_finger"), 9);
1526 assert!(m.has_upgrade("click_mult_1"));
1527 assert!(m.has_achievement("first_finger"));
1528 }
1529
1530 #[test]
1531 fn unknown_ids_in_save_are_ignored_not_resurrected() {
1532 let state = GameState {
1536 fingerers_state: [("giga_finger_from_the_future".to_string(), fs_with_count(42))]
1537 .into_iter()
1538 .collect(),
1539 ..GameState::default()
1540 };
1541
1542 let m = state.migrate_runtime();
1543
1544 assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
1545 assert_eq!(m.fingerer_count("index_finger"), 0);
1546 assert!(!m.has_upgrade("click_mult_1"));
1547 }
1548
1549 #[test]
1550 fn save_roundtrip_is_stable_through_json() {
1551 let state = GameState {
1554 cuques: 1234.5,
1555 total_clicks: 99,
1556 fingerers_state: [("index_finger".to_string(), fs_with_count(7))]
1557 .into_iter()
1558 .collect(),
1559 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1560 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1561 ..GameState::default()
1562 };
1563
1564 let json = serde_json::to_string(&state).expect("serialize");
1565 let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
1566 let m = roundtripped.migrate_runtime();
1567
1568 assert_eq!(m.cuques, 1234.5);
1569 assert_eq!(m.total_clicks, 99);
1570 assert_eq!(m.fingerer_count("index_finger"), 7);
1571 assert!(m.has_upgrade("click_mult_1"));
1572 assert!(m.has_achievement("first_finger"));
1573 }
1574
1575 fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
1576 Rect {
1577 x,
1578 y,
1579 width: w,
1580 height: h,
1581 }
1582 }
1583
1584 #[test]
1585 fn frac_screen_roundtrip_at_corners() {
1586 let biscuit = r(10, 5, 40, 20);
1587 let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
1589 assert!(fx <= 0.001 && fy <= 0.001);
1590 let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
1591 assert_eq!((col, row), (10, 5));
1592
1593 let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
1595 assert!(fx >= 0.999 && fy >= 0.999);
1596
1597 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
1599 assert_eq!(col, 30);
1600 assert_eq!(row, 15);
1601 }
1602
1603 #[test]
1604 fn frac_position_survives_biscuit_move() {
1605 let small = r(0, 0, 40, 20);
1609 let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
1610 let large = r(10, 5, 80, 40);
1611 let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
1612 assert_ne!((col_a, row_a), (col_b, row_b));
1614 assert_eq!(col_b, 30); assert_eq!(row_b, 25); }
1619
1620 #[test]
1621 fn zero_size_biscuit_doesnt_panic() {
1622 let zero = r(0, 0, 0, 0);
1623 let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
1624 assert_eq!((fx, fy), (0.5, 0.5));
1625 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
1626 assert_eq!((col, row), (0, 0));
1627 }
1628
1629 #[test]
1632 fn buy_when_broke_sets_unaffordable_flash() {
1633 let mut s = GameState::default();
1638 let bought = s.buy(0);
1639 assert!(!bought);
1640 assert!(
1641 s.fingerer_unaffordable_flash[0] > 0,
1642 "buy(0) on broke state must flash red"
1643 );
1644 assert!(
1645 s.fingerer_flash_ticks[0] == 0,
1646 "no purchase flash on reject"
1647 );
1648 }
1649
1650 #[test]
1651 fn buy_n_when_broke_sets_unaffordable_flash() {
1652 let mut s = GameState::default();
1653 let bought = s.buy_n(0, 10);
1654 assert_eq!(bought, 0);
1655 assert!(s.fingerer_unaffordable_flash[0] > 0);
1656 }
1657
1658 #[test]
1659 fn bulk_buy_scales_purchase_flash_strength() {
1660 let mut s = GameState {
1667 cuques: 1_000_000.0,
1668 displayed_cuques: 1_000_000.0,
1669 ..Default::default()
1670 };
1671 s.buy(0);
1672 let single = s.purchase_flash_strength;
1673 assert!((1.0..=3.0).contains(&single));
1674
1675 let mut s = GameState {
1676 cuques: 1_000_000.0,
1677 displayed_cuques: 1_000_000.0,
1678 ..Default::default()
1679 };
1680 s.buy_n(0, 50);
1681 let bulk = s.purchase_flash_strength;
1682 assert!(
1683 bulk > single,
1684 "bulk strength must exceed single ({bulk} vs {single})"
1685 );
1686 assert!(bulk <= 3.0, "bulk strength capped at 3.0");
1687 }
1688
1689 #[test]
1690 fn buy_upgrade_when_broke_sets_unaffordable_flash() {
1691 let mut s = GameState::default();
1692 let cheapest_idx = (0..UPGRADES.len())
1694 .min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
1695 .unwrap();
1696 let bought = s.buy_upgrade(cheapest_idx);
1697 assert!(!bought);
1698 assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
1699 }
1700
1701 #[test]
1702 fn migrate_resizes_per_catalog_flash_vecs() {
1703 let json = serde_json::to_string(&GameState::default()).unwrap();
1708 let mut s: GameState = serde_json::from_str(&json).unwrap();
1709 s.fingerer_flash_ticks.clear();
1711 s.upgrade_flash_ticks.clear();
1712 s.fingerer_unaffordable_flash.clear();
1713 s.upgrade_unaffordable_flash.clear();
1714 let m = s.migrate_runtime();
1715 assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
1716 assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
1717 assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
1718 assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
1719 }
1720
1721 #[test]
1722 fn migrate_seeds_displayed_counters() {
1723 let s = GameState {
1726 cuques: 5_000.0,
1727 ..Default::default()
1728 };
1729 let m = s.migrate_runtime();
1730 assert_eq!(m.displayed_cuques, 5_000.0);
1731 assert_eq!(m.displayed_fps, 0.0);
1734 }
1735
1736 #[test]
1737 fn unlock_pop_sets_active_toast_and_gold_flash() {
1738 let mut s = GameState::default();
1742 let biscuit = r(0, 0, 40, 20);
1744 s.click((20, 10), biscuit);
1745 s.tick();
1746 assert!(s.active_unlock_id.is_some());
1748 assert!(s.active_unlock_ticks > 0);
1749 assert!(s.achievement_flash_ticks > 0);
1750 }
1751
1752 fn perm_add_percent(pct: f64) -> Modifier {
1755 Modifier {
1756 source: ModifierSource::GreenCoin,
1757 effects: vec![ModifierEffect::AddPercent(pct)],
1758 duration: ModifierDuration::Permanent,
1759 created_at_tick: 0,
1760 }
1761 }
1762
1763 fn timed_mul(mult: f64, ticks: u32) -> Modifier {
1764 Modifier {
1765 source: ModifierSource::PurpleCoin,
1766 effects: vec![ModifierEffect::MulFactor(mult)],
1767 duration: ModifierDuration::Ticks(ticks),
1768 created_at_tick: 0,
1769 }
1770 }
1771
1772 #[test]
1773 fn attach_modifier_rebuilds_aggregate() {
1774 let mut s = GameState::default();
1775 s.fingerers_state
1776 .insert("index_finger".into(), fs_with_count(1));
1777 s.attach_modifier("index_finger", perm_add_percent(0.10));
1778 let agg = s.fingerer_aggregate("index_finger");
1779 assert!((agg.add_percent - 0.10).abs() < 1e-9);
1780
1781 s.attach_modifier("index_finger", perm_add_percent(0.10));
1783 let agg = s.fingerer_aggregate("index_finger");
1784 assert!((agg.add_percent - 0.20).abs() < 1e-9);
1785 }
1786
1787 #[test]
1788 fn attach_modifier_creates_state_entry_if_absent() {
1789 let mut s = GameState::default();
1794 s.attach_modifier("hand_of_god", perm_add_percent(0.10));
1795 let st = s.fingerers_state.get("hand_of_god").expect("entry exists");
1796 assert_eq!(st.count, 0);
1797 assert_eq!(st.modifiers.len(), 1);
1798 }
1799
1800 #[test]
1801 fn attach_modifier_random_owned_picks_only_owned() {
1802 let mut s = GameState::default();
1803 s.fingerers_state
1804 .insert("index_finger".into(), fs_with_count(5));
1805 s.fingerers_state
1807 .insert("hand_of_god".into(), fs_with_count(0));
1808 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1809 assert_eq!(chosen.as_deref(), Some("index_finger"));
1810 }
1811
1812 #[test]
1813 fn attach_modifier_random_owned_returns_none_when_nothing_owned() {
1814 let mut s = GameState::default();
1815 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1816 assert!(chosen.is_none());
1817 assert!(s.fingerers_state.is_empty());
1819 }
1820
1821 #[test]
1822 fn tick_decrements_timed_modifiers() {
1823 let mut s = GameState::default();
1824 s.fingerers_state
1825 .insert("index_finger".into(), fs_with_count(1));
1826 s.attach_modifier("index_finger", timed_mul(2.0, 5));
1827 s.tick();
1828 let st = s.fingerers_state.get("index_finger").unwrap();
1829 assert_eq!(st.modifiers.len(), 1);
1830 assert!(matches!(
1831 st.modifiers[0].duration,
1832 ModifierDuration::Ticks(4)
1833 ));
1834 }
1835
1836 #[test]
1837 fn tick_removes_expired_and_rebuilds_aggregate() {
1838 let mut s = GameState::default();
1839 s.fingerers_state
1840 .insert("index_finger".into(), fs_with_count(1));
1841 s.attach_modifier("index_finger", timed_mul(2.0, 1));
1842 s.tick();
1844 assert_eq!(
1845 s.fingerers_state
1846 .get("index_finger")
1847 .unwrap()
1848 .modifiers
1849 .len(),
1850 1
1851 );
1852 s.tick();
1854 let st = s.fingerers_state.get("index_finger").unwrap();
1855 assert_eq!(st.modifiers.len(), 0);
1856 assert!((st.aggregate.mul_factor - 1.0).abs() < 1e-9);
1857 }
1858
1859 #[test]
1860 fn permanent_modifier_does_not_decrement() {
1861 let mut s = GameState::default();
1862 s.fingerers_state
1863 .insert("index_finger".into(), fs_with_count(1));
1864 s.attach_modifier("index_finger", perm_add_percent(0.10));
1865 for _ in 0..50 {
1866 s.tick();
1867 }
1868 let st = s.fingerers_state.get("index_finger").unwrap();
1869 assert_eq!(st.modifiers.len(), 1);
1870 assert!(matches!(
1871 st.modifiers[0].duration,
1872 ModifierDuration::Permanent
1873 ));
1874 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1875 }
1876
1877 #[test]
1878 fn prestige_reset_clears_modifiers() {
1879 let mut s = GameState {
1883 lifetime_cuques: 1_000_000_000.0,
1884 ..Default::default()
1885 };
1886 s.fingerers_state
1887 .insert("index_finger".into(), fs_with_count(5));
1888 s.attach_modifier("index_finger", perm_add_percent(0.30));
1889 assert!(s.prestige_reset());
1890 assert!(s.fingerers_state.is_empty());
1891 }
1892
1893 #[test]
1894 fn fps_uses_aggregate_add_percent() {
1895 let mut bare = GameState::default();
1897 bare.fingerers_state
1898 .insert("index_finger".into(), fs_with_count(1));
1899 let bare_fps = bare.fps();
1900
1901 let mut boosted = GameState::default();
1902 boosted
1903 .fingerers_state
1904 .insert("index_finger".into(), fs_with_count(1));
1905 boosted.attach_modifier("index_finger", perm_add_percent(0.10));
1906 let boosted_fps = boosted.fps();
1907
1908 assert!(bare_fps > 0.0);
1909 assert!((boosted_fps - bare_fps * 1.10).abs() < 1e-9);
1910 }
1911
1912 #[test]
1913 fn migrate_runtime_rebuilds_aggregate_after_serde_skip() {
1914 let mut s = GameState::default();
1918 s.fingerers_state.insert(
1919 "index_finger".into(),
1920 FingererState {
1921 count: 1,
1922 modifiers: vec![perm_add_percent(0.25)],
1923 aggregate: FingererAggregate::default(), },
1925 );
1926 let m = s.migrate_runtime();
1927 let agg = m.fingerer_aggregate("index_finger");
1928 assert!((agg.add_percent - 0.25).abs() < 1e-9);
1929 }
1930
1931 use crate::game::green_coin::{GREEN_COIN_LIFE_TICKS, GreenCoin};
1934
1935 fn fake_green_coin() -> GreenCoin {
1936 GreenCoin {
1937 frac_x: 0.5,
1938 frac_y: 0.5,
1939 life_ticks: GREEN_COIN_LIFE_TICKS,
1940 }
1941 }
1942
1943 #[test]
1944 fn catch_green_coin_increments_grand_total_and_per_variant_counter() {
1945 let mut s = GameState {
1946 green_coin: Some(fake_green_coin()),
1947 ..Default::default()
1948 };
1949 s.fingerers_state
1950 .insert("index_finger".into(), fs_with_count(1));
1951 assert!(s.catch_green_coin());
1952 assert_eq!(s.golden_caught, 1, "rollup increments");
1953 assert_eq!(s.green_coin_caught, 1, "per-variant increments");
1954 assert_eq!(s.lucky_caught, 0);
1955 assert_eq!(s.frenzy_caught, 0);
1956 assert_eq!(s.buff_caught, 0);
1957 }
1958
1959 #[test]
1960 fn catch_green_coin_attaches_permanent_modifier() {
1961 let mut s = GameState::default();
1962 s.fingerers_state
1963 .insert("index_finger".into(), fs_with_count(3));
1964 s.green_coin = Some(fake_green_coin());
1965
1966 let caught = s.catch_green_coin();
1967
1968 assert!(caught);
1969 assert!(s.green_coin.is_none());
1970 let st = s.fingerers_state.get("index_finger").unwrap();
1971 assert_eq!(st.modifiers.len(), 1);
1972 let m = &st.modifiers[0];
1973 assert!(matches!(m.source, ModifierSource::GreenCoin));
1974 assert!(matches!(m.duration, ModifierDuration::Permanent));
1975 assert!(matches!(
1976 m.effects[0],
1977 ModifierEffect::AddPercent(v) if (v - 0.10).abs() < 1e-9
1978 ));
1979 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1980 }
1981
1982 #[test]
1983 fn catch_green_coin_with_no_owned_lands_on_index_finger() {
1984 let mut s = GameState {
1989 green_coin: Some(fake_green_coin()),
1990 ..Default::default()
1991 };
1992
1993 let caught = s.catch_green_coin();
1994
1995 assert!(caught);
1996 assert!(s.green_coin.is_none());
1997 let st = s
1998 .fingerers_state
1999 .get(FINGERERS[0].id)
2000 .expect("modifier landed on Index Finger");
2001 assert_eq!(st.modifiers.len(), 1);
2002 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
2003 }
2004
2005 #[test]
2006 fn attach_modifier_random_visible_can_pick_unowned_when_lifetime_unlocks_it() {
2007 let mut s = GameState {
2013 lifetime_cuques: 60.0,
2014 ..Default::default()
2015 };
2016 let m = perm_add_percent(0.10);
2017 let chosen = s.attach_modifier_random_visible(m);
2018 let id = chosen.expect("at least one visible fingerer always exists");
2019 let visible_ids: Vec<&str> = FINGERERS
2021 .iter()
2022 .enumerate()
2023 .filter(|(idx, f)| {
2024 fingerer::visible(*idx, 0, s.lifetime_cuques) && (*idx == 0 || f.id == "whole_hand")
2025 })
2026 .map(|(_, f)| f.id)
2027 .collect();
2028 assert!(visible_ids.contains(&id.as_str()));
2029 }
2030
2031 #[test]
2032 fn catch_green_coin_returns_false_when_no_coin() {
2033 let mut s = GameState::default();
2034 assert!(!s.catch_green_coin());
2035 }
2036
2037 #[test]
2038 fn tick_green_coin_decrements_lifetime_and_clears_at_zero() {
2039 let mut s = GameState {
2040 green_coin: Some(GreenCoin {
2041 frac_x: 0.5,
2042 frac_y: 0.5,
2043 life_ticks: 2,
2044 }),
2045 ..Default::default()
2046 };
2047 s.tick_green_coin();
2048 assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 1);
2049 s.tick_green_coin();
2050 assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 0);
2052 s.tick_green_coin();
2053 assert!(s.green_coin.is_none());
2055 }
2056
2057 #[test]
2058 fn green_coin_stacks_additively_on_repeat_catches() {
2059 let mut s = GameState::default();
2061 s.fingerers_state
2062 .insert("index_finger".into(), fs_with_count(1));
2063 for _ in 0..2 {
2064 s.green_coin = Some(fake_green_coin());
2065 s.catch_green_coin();
2066 }
2067 let st = s.fingerers_state.get("index_finger").unwrap();
2068 assert_eq!(st.modifiers.len(), 2);
2070 assert!((st.aggregate.add_percent - 0.20).abs() < 1e-9);
2071 }
2072
2073 #[test]
2074 fn prestige_reset_clears_green_coin_state() {
2075 let mut s = GameState {
2076 lifetime_cuques: 1_000_000_000.0,
2077 ..Default::default()
2078 };
2079 s.fingerers_state
2080 .insert("index_finger".into(), fs_with_count(1));
2081 s.goldens_since_green_coin = 7;
2082 s.green_coin = Some(fake_green_coin());
2083 s.prestige_reset();
2084 assert!(s.green_coin.is_none());
2085 assert_eq!(s.goldens_since_green_coin, 0);
2086 }
2087}