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)]
260 pub golden: Option<GoldenCuque>,
261 #[serde(skip)]
262 pub golden_cooldown: u32,
263 #[serde(skip)]
268 pub green_coin: Option<GreenCoin>,
269 #[serde(skip)]
270 pub session_ticks: u64,
271 #[serde(skip)]
274 pub newly_unlocked: Vec<String>,
275 #[serde(skip)]
279 pub active_unlock_id: Option<String>,
280 #[serde(skip)]
281 pub active_unlock_ticks: u32,
282 #[serde(skip)]
283 pub visual_debt: f64,
284 #[serde(skip)]
285 pub lucky_flash_ticks: u32,
286 #[serde(skip)]
287 pub achievement_flash_ticks: u32,
288 #[serde(skip)]
293 pub green_coin_flash_ticks: u32,
294 #[serde(skip)]
300 pub border_phase: u32,
301 #[serde(skip)]
308 pub steady_phase: u32,
309 #[serde(skip)]
310 pub purchase_flash_ticks: u32,
311 #[serde(skip)]
315 pub purchase_flash_strength: f32,
316 #[serde(skip)]
319 pub fingerer_flash_ticks: Vec<u32>,
320 #[serde(skip)]
323 pub upgrade_flash_ticks: Vec<u32>,
324 #[serde(skip)]
327 pub fingerer_unaffordable_flash: Vec<u32>,
328 #[serde(skip)]
329 pub upgrade_unaffordable_flash: Vec<u32>,
330 #[serde(skip)]
336 pub fingerer_unlock_flash: Vec<u32>,
337 #[serde(skip)]
338 pub upgrade_unlock_flash: Vec<u32>,
339 #[serde(skip)]
345 pub prev_fingerer_affordable: Vec<bool>,
346 #[serde(skip)]
347 pub prev_upgrade_affordable: Vec<bool>,
348 #[serde(skip)]
363 pub space_pressed_this_tick: bool,
364 #[serde(skip)]
365 pub ticks_since_last_press: u32,
366 #[serde(skip)]
367 pub space_hold_ticks: u32,
368 #[serde(skip)]
372 pub displayed_cuques: f64,
373 #[serde(skip)]
374 pub displayed_fps: f64,
375 #[serde(skip)]
378 pub cuques_flash_ticks: u32,
379 #[serde(skip)]
386 pub cuques_spend_flash_ticks: u32,
387}
388
389pub const LUCKY_FLASH_TICKS: u32 = 70; pub const PURCHASE_FLASH_TICKS: u32 = 20; pub const GREEN_COIN_FLASH_TICKS: u32 = 50; fn default_save_version() -> u32 {
401 crate::save::CURRENT_VERSION
402}
403
404impl Default for GameState {
405 fn default() -> Self {
406 Self {
407 version: crate::save::CURRENT_VERSION,
408 cuques: 0.0,
409 total_clicks: 0,
410 lifetime_cuques: 0.0,
411 best_fps: 0.0,
412 golden_caught: 0,
413 lucky_caught: 0,
414 frenzy_caught: 0,
415 buff_caught: 0,
416 green_coin_caught: 0,
417 fingerers_state: HashMap::new(),
418 achievements_earned: HashSet::new(),
419 upgrades_earned: HashSet::new(),
420 prestige: 0,
421 total_play_ticks: 0,
422 buffs: Vec::new(),
423 goldens_since_green_coin: 0,
424 clench_ticks: 0,
425 particles: Vec::new(),
426 misclick_particles: Vec::new(),
427 golden: None,
428 golden_cooldown: crate::game::golden::next_cooldown(),
429 green_coin: None,
430 session_ticks: 0,
431 newly_unlocked: Vec::new(),
432 active_unlock_id: None,
433 active_unlock_ticks: 0,
434 visual_debt: 0.0,
435 lucky_flash_ticks: 0,
436 achievement_flash_ticks: 0,
437 green_coin_flash_ticks: 0,
438 border_phase: 0,
439 steady_phase: 0,
440 purchase_flash_ticks: 0,
441 purchase_flash_strength: 1.0,
442 fingerer_flash_ticks: vec![0; fingerer::count()],
443 upgrade_flash_ticks: vec![0; UPGRADES.len()],
444 fingerer_unaffordable_flash: vec![0; fingerer::count()],
445 upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
446 fingerer_unlock_flash: vec![0; fingerer::count()],
447 upgrade_unlock_flash: vec![0; UPGRADES.len()],
448 prev_fingerer_affordable: vec![false; fingerer::count()],
449 prev_upgrade_affordable: vec![false; UPGRADES.len()],
450 space_pressed_this_tick: false,
451 ticks_since_last_press: u32::MAX,
452 space_hold_ticks: 0,
453 displayed_cuques: 0.0,
454 displayed_fps: 0.0,
455 cuques_flash_ticks: 0,
456 cuques_spend_flash_ticks: 0,
457 }
458 }
459}
460
461impl GameState {
462 pub fn migrate_runtime(mut self) -> Self {
472 for st in self.fingerers_state.values_mut() {
475 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
476 }
477 if self.fingerer_flash_ticks.len() != fingerer::count() {
480 self.fingerer_flash_ticks = vec![0; fingerer::count()];
481 }
482 if self.upgrade_flash_ticks.len() != UPGRADES.len() {
483 self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
484 }
485 if self.fingerer_unaffordable_flash.len() != fingerer::count() {
486 self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
487 }
488 if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
489 self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
490 }
491 if self.fingerer_unlock_flash.len() != fingerer::count() {
492 self.fingerer_unlock_flash = vec![0; fingerer::count()];
493 }
494 if self.upgrade_unlock_flash.len() != UPGRADES.len() {
495 self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
496 }
497 if self.prev_fingerer_affordable.len() != fingerer::count() {
501 self.prev_fingerer_affordable =
502 (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
503 }
504 if self.prev_upgrade_affordable.len() != UPGRADES.len() {
505 self.prev_upgrade_affordable = (0..UPGRADES.len())
506 .map(|i| {
507 let u = &UPGRADES[i];
508 !self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
509 })
510 .collect();
511 }
512 if self.golden_cooldown == 0 {
513 self.golden_cooldown = crate::game::golden::next_cooldown();
514 }
515 self.displayed_cuques = self.cuques;
518 self.displayed_fps = 0.0; if self.purchase_flash_strength <= 0.0 {
520 self.purchase_flash_strength = 1.0;
521 }
522 self
523 }
524
525 pub fn fingerer_count(&self, id: &str) -> u32 {
528 self.fingerers_state.get(id).map(|st| st.count).unwrap_or(0)
529 }
530
531 pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
532 FINGERERS
533 .get(idx)
534 .map(|f| self.fingerer_count(f.id))
535 .unwrap_or(0)
536 }
537
538 pub fn fingerers_owned_total(&self) -> u32 {
539 self.fingerers_state.values().map(|st| st.count).sum()
540 }
541
542 pub fn fingerer_aggregate(&self, id: &str) -> FingererAggregate {
546 self.fingerers_state
547 .get(id)
548 .map(|st| st.aggregate)
549 .unwrap_or_default()
550 }
551
552 pub fn attach_modifier(&mut self, fingerer_id: &str, m: Modifier) {
557 let st = self
558 .fingerers_state
559 .entry(fingerer_id.to_string())
560 .or_default();
561 st.modifiers.push(m);
562 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
563 }
564
565 pub fn attach_modifier_random_owned(&mut self, m: Modifier) -> Option<String> {
570 let owned: Vec<String> = self
571 .fingerers_state
572 .iter()
573 .filter(|(_, st)| st.count > 0)
574 .map(|(id, _)| id.clone())
575 .collect();
576 if owned.is_empty() {
577 return None;
578 }
579 let pick = owned[rand::rng().random_range(0..owned.len())].clone();
580 self.attach_modifier(&pick, m);
581 Some(pick)
582 }
583
584 pub fn attach_modifier_random_visible(&mut self, m: Modifier) -> Option<String> {
596 let visible: Vec<String> = FINGERERS
597 .iter()
598 .enumerate()
599 .filter(|(idx, f)| {
600 let owned = self.fingerer_count(f.id);
601 fingerer::visible(*idx, owned, self.lifetime_cuques)
602 })
603 .map(|(_, f)| f.id.to_string())
604 .collect();
605 if visible.is_empty() {
606 return None;
607 }
608 let pick = visible[rand::rng().random_range(0..visible.len())].clone();
609 self.attach_modifier(&pick, m);
610 Some(pick)
611 }
612
613 pub fn has_upgrade(&self, id: &str) -> bool {
614 self.upgrades_earned.contains(id)
615 }
616
617 pub fn has_achievement(&self, id: &str) -> bool {
618 self.achievements_earned.contains(id)
619 }
620
621 pub fn has_achievement_idx(&self, idx: usize) -> bool {
622 ACHIEVEMENTS
623 .get(idx)
624 .is_some_and(|a| self.has_achievement(a.id))
625 }
626
627 pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
630 let power = self.click_power();
631 self.add_cuques(power);
632 self.total_clicks += 1;
633 self.clench_ticks = CLENCH_TICKS;
634 if power >= 50.0 {
638 self.cuques_flash_ticks = HUD_FLASH_TICKS;
639 }
640 let mut rng = rand::rng();
641 let jitter_x_range = (biscuit.width as i32 / 8).max(3);
646 let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
647 let jitter_y = rng.random_range(-1..=1);
648 let col = (origin.0 as i32 + jitter_x).max(0) as u16;
649 let row = origin
650 .1
651 .saturating_sub(1)
652 .saturating_add_signed(jitter_y as i16);
653 let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
654 let drift_x = rng.random_range(-0.012_f32..=0.012);
655 let frenzy_active = self
656 .buffs
657 .iter()
658 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
659 let kind = if power >= 50.0 || frenzy_active {
663 ParticleKind::ClickBig
664 } else {
665 ParticleKind::Click
666 };
667 self.particles.push(Particle {
668 frac_x,
669 frac_y,
670 life: PARTICLE_LIFE,
671 text: format!("+{}", crate::format::big(power)),
672 kind,
673 drift_x,
674 });
675 if frenzy_active {
678 for _ in 0..2 {
679 let halo_x = rng.random_range(-0.05_f32..=0.05);
680 let halo_y = rng.random_range(-0.04_f32..=0.04);
681 let (hfx, hfy) =
682 screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
683 self.particles.push(Particle {
684 frac_x: (hfx + halo_x).clamp(0.0, 1.0),
685 frac_y: (hfy + halo_y).clamp(0.0, 1.0),
686 life: PARTICLE_LIFE / 2,
687 text: "*".into(),
688 kind: ParticleKind::Confetti,
689 drift_x: rng.random_range(-0.02_f32..=0.02),
690 });
691 }
692 }
693 }
694
695 pub fn spawn_misclick(&mut self, col: u16, row: u16) {
699 if self.misclick_particles.len() >= 16 {
701 self.misclick_particles.remove(0);
702 }
703 self.misclick_particles.push(MisclickParticle {
704 col,
705 row,
706 life: MISCLICK_LIFE,
707 });
708 }
709
710 pub fn spawn_confetti(&mut self, n: u32) {
713 if n == 0 {
714 return;
715 }
716 let mut rng = rand::rng();
717 let glyphs = ['*', '+', '~', '.', 'o'];
718 for _ in 0..n.min(8) {
719 let glyph = glyphs[rng.random_range(0..glyphs.len())];
720 self.particles.push(Particle {
721 frac_x: rng.random_range(0.10_f32..=0.90),
722 frac_y: rng.random_range(0.20_f32..=0.85),
723 life: PARTICLE_LIFE,
724 text: glyph.to_string(),
725 kind: ParticleKind::Confetti,
726 drift_x: rng.random_range(-0.02_f32..=0.02),
727 });
728 }
729 }
730
731 pub fn click_power(&self) -> f64 {
732 let mut m = 1.0;
733 for u in UPGRADES.iter() {
734 if self.has_upgrade(u.id)
735 && let UpgradeEffect::ClickMult(f) = u.effect
736 {
737 m *= f;
738 }
739 }
740 for b in &self.buffs {
741 let Buff::ClickFrenzy { mult, .. } = b;
742 m *= *mult;
743 }
744 m
745 }
746
747 pub fn fingerer_mult(&self, idx: usize) -> f64 {
748 let Some(target) = FINGERERS.get(idx) else {
749 return 1.0;
750 };
751 let mut m = 1.0;
752 for u in UPGRADES.iter() {
753 if !self.has_upgrade(u.id) {
754 continue;
755 }
756 match u.effect {
757 UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
758 UpgradeEffect::AllFingerersMult(f) => m *= f,
759 _ => {}
760 }
761 }
762 m
767 }
768
769 fn add_cuques(&mut self, amount: f64) {
770 self.cuques += amount;
771 self.lifetime_cuques += amount;
772 }
773
774 pub fn dev_add_cuques(&mut self, amount: f64) {
777 self.add_cuques(amount);
778 self.cuques_flash_ticks = HUD_FLASH_TICKS;
779 }
780
781 pub fn catch_golden(&mut self) -> f64 {
791 use crate::game::golden::GoldenVariant;
792 let Some(golden) = self.golden.take() else {
793 return 0.0;
794 };
795 self.golden_caught += 1;
796 self.golden_cooldown = crate::game::golden::next_cooldown();
797 let (reward, label) = match golden.variant {
798 GoldenVariant::Lucky => {
799 self.lucky_caught += 1;
800 let fps = self.fps();
801 let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
802 self.add_cuques(r);
803 self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
804 self.cuques_flash_ticks = HUD_FLASH_TICKS;
805 (r, format!("+{}", crate::format::big(r)))
806 }
807 GoldenVariant::Frenzy => {
808 self.frenzy_caught += 1;
809 let dur = TICK_HZ * 13;
810 self.buffs.push(Buff::ClickFrenzy {
811 ticks_remaining: dur,
812 initial_ticks: dur,
813 mult: 777.0,
814 });
815 (0.0, "FRENZY x777!".into())
816 }
817 GoldenVariant::Buff => {
818 self.buff_caught += 1;
819 let dur = TICK_HZ * 60;
820 let m = Modifier {
821 source: crate::game::modifier::ModifierSource::PurpleCoin,
822 effects: vec![crate::game::modifier::ModifierEffect::MulFactor(7.0)],
823 duration: ModifierDuration::Ticks(dur),
824 created_at_tick: self.total_play_ticks,
825 };
826 if self.attach_modifier_random_owned(m.clone()).is_none() {
829 let pick = FINGERERS[0].id;
830 self.attach_modifier(pick, m);
831 }
832 (0.0, "BOOSTED x7!".into())
833 }
834 };
835 self.particles.push(Particle {
836 frac_x: golden.frac_x,
837 frac_y: golden.frac_y,
838 life: PARTICLE_LIFE * 2,
839 text: label,
840 kind: ParticleKind::Golden,
841 drift_x: 0.0,
842 });
843 reward
844 }
845
846 pub fn fps(&self) -> f64 {
847 let base: f64 = FINGERERS
852 .iter()
853 .enumerate()
854 .map(|(i, k)| {
855 let count = self.fingerer_count(k.id) as f64;
856 let upgrades_mult = self.fingerer_mult(i);
857 let agg = self.fingerer_aggregate(k.id);
858 let pre = (k.fps_per_unit * count + agg.flat_fps) * upgrades_mult;
859 pre * (1.0 + agg.add_percent) * agg.mul_factor
860 })
861 .sum();
862 base * self.prestige_mult()
863 }
864
865 pub fn border_speed(&self) -> u32 {
866 let mut s: u32 = 1;
867 for b in &self.buffs {
868 match b {
869 Buff::ClickFrenzy { .. } => s = s.max(3),
870 }
871 }
872 if self.fingerers_state.values().any(|st| {
876 st.modifiers
877 .iter()
878 .any(|m| matches!(m.duration, ModifierDuration::Ticks(_)))
879 }) {
880 s = s.max(2);
881 }
882 if self.lucky_flash_ticks > 0 {
883 s = s.max(4);
884 }
885 if self.achievement_flash_ticks > 0 {
886 s = s.max(3);
887 }
888 if self.purchase_flash_ticks > 0 {
889 s += 2;
890 }
891 s
892 }
893
894 pub fn trigger_purchase_flash(&mut self, strength: f32) {
898 self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
899 self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
902 }
903
904 pub fn prestige_mult(&self) -> f64 {
905 1.0 + 0.01 * self.prestige as f64
906 }
907
908 pub fn prestige_earned_total(&self) -> u64 {
909 (self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
910 }
911
912 pub fn prestige_available(&self) -> u64 {
913 self.prestige_earned_total().saturating_sub(self.prestige)
914 }
915
916 pub fn prestige_reset(&mut self) -> bool {
917 let available = self.prestige_available();
918 if available == 0 {
919 return false;
920 }
921 self.prestige = self.prestige_earned_total();
922 self.cuques = 0.0;
923 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
928 self.fingerers_state.clear();
931 self.upgrades_earned.clear();
932 self.buffs.clear();
933 self.visual_debt = 0.0;
934 self.particles.clear();
935 self.misclick_particles.clear();
936 self.golden = None;
937 self.green_coin = None;
938 self.goldens_since_green_coin = 0;
940 self.clench_ticks = 0;
941 self.golden_cooldown = crate::game::golden::next_cooldown();
942 true
943 }
944
945 pub fn tick(&mut self) {
946 for st in self.fingerers_state.values_mut() {
952 let before = st.modifiers.len();
953 st.modifiers.retain_mut(|m| match &mut m.duration {
954 ModifierDuration::Permanent => true,
955 ModifierDuration::Ticks(0) => false,
956 ModifierDuration::Ticks(n) => {
957 *n -= 1;
958 true
959 }
960 });
961 if before != st.modifiers.len() {
962 st.aggregate = FingererAggregate::rebuild(&st.modifiers);
963 }
964 }
965
966 for b in self.buffs.iter_mut() {
967 b.tick();
968 }
969 self.buffs.retain(|b| b.ticks_remaining() > 0);
970
971 self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
972 self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
973 self.green_coin_flash_ticks = self.green_coin_flash_ticks.saturating_sub(1);
974 self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
975 if self.purchase_flash_ticks == 0 {
976 self.purchase_flash_strength = 1.0;
977 }
978 self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
979 self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
980 for t in self.fingerer_flash_ticks.iter_mut() {
981 *t = t.saturating_sub(1);
982 }
983 for t in self.upgrade_flash_ticks.iter_mut() {
984 *t = t.saturating_sub(1);
985 }
986 for t in self.fingerer_unaffordable_flash.iter_mut() {
987 *t = t.saturating_sub(1);
988 }
989 for t in self.upgrade_unaffordable_flash.iter_mut() {
990 *t = t.saturating_sub(1);
991 }
992 for t in self.fingerer_unlock_flash.iter_mut() {
993 *t = t.saturating_sub(1);
994 }
995 for t in self.upgrade_unlock_flash.iter_mut() {
996 *t = t.saturating_sub(1);
997 }
998 if self.space_pressed_this_tick {
1008 self.ticks_since_last_press = 0;
1009 } else {
1010 self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
1011 }
1012 self.space_pressed_this_tick = false;
1013 const HOLD_GRACE_TICKS: u32 = 3; if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
1015 self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
1016 } else {
1017 self.space_hold_ticks = 0;
1018 }
1019 let speed = self.border_speed();
1020 self.border_phase = self.border_phase.wrapping_add(speed);
1021 self.steady_phase = self.steady_phase.wrapping_add(1);
1022
1023 let fps = self.fps();
1024 if fps > self.best_fps {
1025 self.best_fps = fps;
1026 }
1027 let gained = fps * TICK_DT;
1028 self.add_cuques(gained);
1029 self.visual_debt += gained;
1030 self.clench_ticks = self.clench_ticks.saturating_sub(1);
1031 for p in self.particles.iter_mut() {
1032 p.life = p.life.saturating_sub(1);
1033 p.frac_y -= PARTICLE_FRAC_RISE;
1034 p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
1037 }
1038 self.particles.retain(|p| p.life > 0);
1039 for m in self.misclick_particles.iter_mut() {
1040 m.life = m.life.saturating_sub(1);
1041 }
1042 self.misclick_particles.retain(|m| m.life > 0);
1043
1044 let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
1050 let upgrade_now: Vec<bool> = UPGRADES
1051 .iter()
1052 .map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
1053 .collect();
1054 for (i, &now) in fingerer_now.iter().enumerate() {
1055 let was = self
1056 .prev_fingerer_affordable
1057 .get(i)
1058 .copied()
1059 .unwrap_or(false);
1060 if now
1061 && !was
1062 && let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
1063 {
1064 *slot = UNLOCK_FLASH_TICKS;
1065 }
1066 if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
1067 *slot = now;
1068 }
1069 }
1070 for (i, &now) in upgrade_now.iter().enumerate() {
1071 let was = self
1072 .prev_upgrade_affordable
1073 .get(i)
1074 .copied()
1075 .unwrap_or(false);
1076 if now
1077 && !was
1078 && let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
1079 {
1080 *slot = UNLOCK_FLASH_TICKS;
1081 }
1082 if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
1083 *slot = now;
1084 }
1085 }
1086
1087 const SNAP_BELOW: f64 = 5.0;
1098 let tween = 0.18_f64;
1099 let dc = self.cuques - self.displayed_cuques;
1100 if dc.abs() < SNAP_BELOW {
1101 self.displayed_cuques = self.cuques;
1102 } else {
1103 self.displayed_cuques += dc * tween;
1104 }
1105 let df = fps - self.displayed_fps;
1106 if df.abs() < SNAP_BELOW {
1107 self.displayed_fps = fps;
1108 } else {
1109 self.displayed_fps += df * tween;
1110 }
1111
1112 self.session_ticks += 1;
1113 self.total_play_ticks += 1;
1114 self.tick_achievements();
1119
1120 self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
1124 if self.active_unlock_ticks == 0 {
1125 self.active_unlock_id = None;
1126 if !self.newly_unlocked.is_empty() {
1127 self.active_unlock_id = Some(self.newly_unlocked.remove(0));
1128 self.active_unlock_ticks = TOAST_TICKS;
1129 self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
1130 }
1131 }
1132 }
1133
1134 pub fn tick_achievements(&mut self) {
1135 for a in ACHIEVEMENTS.iter() {
1136 if !self.has_achievement(a.id) && (a.unlocked)(self) {
1137 self.achievements_earned.insert(a.id.to_string());
1138 self.newly_unlocked.push(a.id.to_string());
1139 }
1140 }
1141 }
1142
1143 pub fn tick_golden(&mut self) {
1144 if let Some(g) = self.golden.as_mut() {
1145 if g.life_ticks == 0 {
1146 self.golden = None;
1147 self.golden_cooldown = crate::game::golden::next_cooldown();
1148 } else {
1149 g.life_ticks -= 1;
1150 }
1151 } else if self.golden_cooldown > 0 {
1152 self.golden_cooldown -= 1;
1153 }
1154 }
1155
1156 pub fn tick_green_coin(&mut self) {
1161 if let Some(g) = self.green_coin.as_mut() {
1162 if g.life_ticks == 0 {
1163 self.green_coin = None;
1164 } else {
1165 g.life_ticks -= 1;
1166 }
1167 }
1168 }
1169
1170 pub fn catch_green_coin(&mut self) -> bool {
1180 let Some(g) = self.green_coin.take() else {
1181 return false;
1182 };
1183 let m = Modifier {
1184 source: ModifierSource::GreenCoin,
1185 effects: vec![ModifierEffect::AddPercent(
1186 crate::game::green_coin::GREEN_COIN_ADD_PERCENT,
1187 )],
1188 duration: ModifierDuration::Permanent,
1189 created_at_tick: self.total_play_ticks,
1190 };
1191 let chosen = self.attach_modifier_random_visible(m);
1197 self.golden_caught += 1;
1202 self.green_coin_caught += 1;
1203 self.green_coin_flash_ticks = GREEN_COIN_FLASH_TICKS;
1204 let label = match &chosen {
1208 Some(id) => {
1209 let idx = FINGERERS.iter().position(|f| f.id == id);
1210 let name = idx
1211 .and_then(|i| crate::i18n::t().fingerer_names.get(i).copied())
1212 .unwrap_or("?");
1213 format!("+10% {}", name)
1214 }
1215 None => "+10% ???".to_string(),
1217 };
1218 self.particles.push(Particle {
1219 frac_x: g.frac_x,
1220 frac_y: g.frac_y,
1221 life: PARTICLE_LIFE * 2,
1222 text: label,
1223 kind: ParticleKind::Golden,
1224 drift_x: 0.0,
1225 });
1226 true
1227 }
1228
1229 pub fn trigger_clench(&mut self) {
1230 self.clench_ticks = CLENCH_TICKS;
1231 }
1232
1233 pub fn space_held(&self) -> bool {
1239 self.space_hold_ticks >= TICK_HZ
1240 }
1241
1242 pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
1250 let amount = self.visual_debt.floor() as u64;
1251 if amount == 0 {
1252 return;
1253 }
1254 self.visual_debt -= amount as f64;
1255 let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
1256 self.particles.push(Particle {
1257 frac_x,
1258 frac_y,
1259 life: PARTICLE_LIFE,
1260 text: format!("+{}", crate::format::big(amount as f64)),
1261 kind: ParticleKind::Auto,
1262 drift_x,
1263 });
1264 }
1265
1266 pub fn cost(&self, idx: usize) -> f64 {
1267 let k = &FINGERERS[idx];
1268 let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
1276 raw.floor()
1277 }
1278
1279 pub fn affordable_cuques(&self) -> f64 {
1297 self.cuques.min(self.displayed_cuques.floor())
1298 }
1299
1300 pub fn can_buy(&self, idx: usize) -> bool {
1301 self.affordable_cuques() >= self.cost(idx)
1302 }
1303
1304 fn buy_one_quiet(&mut self, idx: usize) -> bool {
1308 let c = self.cost(idx);
1309 if self.affordable_cuques() >= c
1315 && let Some(f) = FINGERERS.get(idx)
1316 {
1317 self.cuques -= c;
1318 self.fingerers_state
1319 .entry(f.id.to_string())
1320 .or_default()
1321 .count += 1;
1322 true
1323 } else {
1324 false
1325 }
1326 }
1327
1328 fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
1332 if bought == 0 {
1333 return;
1334 }
1335 let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
1338 self.trigger_purchase_flash(strength);
1339 match slot_table {
1340 PurchaseSlot::Fingerer => {
1341 if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
1342 *slot = PURCHASE_FLASH_TICKS;
1343 }
1344 }
1345 PurchaseSlot::Upgrade => {
1346 if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
1347 *slot = PURCHASE_FLASH_TICKS;
1348 }
1349 }
1350 }
1351 self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
1357 if bought >= 5 {
1358 self.spawn_confetti(bought.min(8));
1359 }
1360 }
1361
1362 fn flash_unaffordable_fingerer(&mut self, idx: usize) {
1363 if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
1364 *slot = PURCHASE_FLASH_TICKS / 2;
1365 }
1366 }
1367
1368 fn flash_unaffordable_upgrade(&mut self, idx: usize) {
1369 if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
1370 *slot = PURCHASE_FLASH_TICKS / 2;
1371 }
1372 }
1373
1374 pub fn buy(&mut self, idx: usize) -> bool {
1375 if self.buy_one_quiet(idx) {
1376 self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
1377 true
1378 } else {
1379 self.flash_unaffordable_fingerer(idx);
1380 false
1381 }
1382 }
1383
1384 pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
1385 let mut bought = 0;
1386 for _ in 0..n {
1387 if !self.buy_one_quiet(idx) {
1388 break;
1389 }
1390 bought += 1;
1391 }
1392 if bought == 0 {
1393 self.flash_unaffordable_fingerer(idx);
1394 } else {
1395 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1396 }
1397 bought
1398 }
1399
1400 pub fn buy_max(&mut self, idx: usize) -> u32 {
1401 let mut bought = 0;
1402 while self.buy_one_quiet(idx) {
1403 bought += 1;
1404 }
1405 if bought == 0 {
1406 self.flash_unaffordable_fingerer(idx);
1407 } else {
1408 self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
1409 }
1410 bought
1411 }
1412
1413 pub fn buy_upgrade(&mut self, idx: usize) -> bool {
1414 let Some(u) = UPGRADES.get(idx) else {
1415 return false;
1416 };
1417 if self.has_upgrade(u.id) {
1418 return false;
1419 }
1420 if !u.req.met(self) || self.affordable_cuques() < u.cost {
1423 self.flash_unaffordable_upgrade(idx);
1424 return false;
1425 }
1426 self.cuques -= u.cost;
1427 self.upgrades_earned.insert(u.id.to_string());
1428 self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
1429 true
1430 }
1431}
1432
1433#[derive(Clone, Copy)]
1434enum PurchaseSlot {
1435 Fingerer,
1436 Upgrade,
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442 use crate::game::modifier::{Modifier, ModifierEffect, ModifierSource};
1443
1444 fn fs_with_count(count: u32) -> FingererState {
1445 FingererState {
1446 count,
1447 ..Default::default()
1448 }
1449 }
1450
1451 #[test]
1452 fn migrate_is_idempotent_on_current_shape() {
1453 let state = GameState {
1454 fingerers_state: [("index_finger".to_string(), fs_with_count(9))]
1455 .into_iter()
1456 .collect(),
1457 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1458 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1459 ..GameState::default()
1460 };
1461
1462 let m = state.migrate_runtime();
1463
1464 assert_eq!(m.fingerer_count("index_finger"), 9);
1465 assert!(m.has_upgrade("click_mult_1"));
1466 assert!(m.has_achievement("first_finger"));
1467 }
1468
1469 #[test]
1470 fn unknown_ids_in_save_are_ignored_not_resurrected() {
1471 let state = GameState {
1475 fingerers_state: [("giga_finger_from_the_future".to_string(), fs_with_count(42))]
1476 .into_iter()
1477 .collect(),
1478 ..GameState::default()
1479 };
1480
1481 let m = state.migrate_runtime();
1482
1483 assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
1484 assert_eq!(m.fingerer_count("index_finger"), 0);
1485 assert!(!m.has_upgrade("click_mult_1"));
1486 }
1487
1488 #[test]
1489 fn save_roundtrip_is_stable_through_json() {
1490 let state = GameState {
1493 cuques: 1234.5,
1494 total_clicks: 99,
1495 fingerers_state: [("index_finger".to_string(), fs_with_count(7))]
1496 .into_iter()
1497 .collect(),
1498 upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
1499 achievements_earned: ["first_finger".to_string()].into_iter().collect(),
1500 ..GameState::default()
1501 };
1502
1503 let json = serde_json::to_string(&state).expect("serialize");
1504 let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
1505 let m = roundtripped.migrate_runtime();
1506
1507 assert_eq!(m.cuques, 1234.5);
1508 assert_eq!(m.total_clicks, 99);
1509 assert_eq!(m.fingerer_count("index_finger"), 7);
1510 assert!(m.has_upgrade("click_mult_1"));
1511 assert!(m.has_achievement("first_finger"));
1512 }
1513
1514 fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
1515 Rect {
1516 x,
1517 y,
1518 width: w,
1519 height: h,
1520 }
1521 }
1522
1523 #[test]
1524 fn frac_screen_roundtrip_at_corners() {
1525 let biscuit = r(10, 5, 40, 20);
1526 let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
1528 assert!(fx <= 0.001 && fy <= 0.001);
1529 let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
1530 assert_eq!((col, row), (10, 5));
1531
1532 let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
1534 assert!(fx >= 0.999 && fy >= 0.999);
1535
1536 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
1538 assert_eq!(col, 30);
1539 assert_eq!(row, 15);
1540 }
1541
1542 #[test]
1543 fn frac_position_survives_biscuit_move() {
1544 let small = r(0, 0, 40, 20);
1548 let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
1549 let large = r(10, 5, 80, 40);
1550 let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
1551 assert_ne!((col_a, row_a), (col_b, row_b));
1553 assert_eq!(col_b, 30); assert_eq!(row_b, 25); }
1558
1559 #[test]
1560 fn zero_size_biscuit_doesnt_panic() {
1561 let zero = r(0, 0, 0, 0);
1562 let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
1563 assert_eq!((fx, fy), (0.5, 0.5));
1564 let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
1565 assert_eq!((col, row), (0, 0));
1566 }
1567
1568 #[test]
1571 fn buy_when_broke_sets_unaffordable_flash() {
1572 let mut s = GameState::default();
1577 let bought = s.buy(0);
1578 assert!(!bought);
1579 assert!(
1580 s.fingerer_unaffordable_flash[0] > 0,
1581 "buy(0) on broke state must flash red"
1582 );
1583 assert!(
1584 s.fingerer_flash_ticks[0] == 0,
1585 "no purchase flash on reject"
1586 );
1587 }
1588
1589 #[test]
1590 fn buy_n_when_broke_sets_unaffordable_flash() {
1591 let mut s = GameState::default();
1592 let bought = s.buy_n(0, 10);
1593 assert_eq!(bought, 0);
1594 assert!(s.fingerer_unaffordable_flash[0] > 0);
1595 }
1596
1597 #[test]
1598 fn bulk_buy_scales_purchase_flash_strength() {
1599 let mut s = GameState {
1606 cuques: 1_000_000.0,
1607 displayed_cuques: 1_000_000.0,
1608 ..Default::default()
1609 };
1610 s.buy(0);
1611 let single = s.purchase_flash_strength;
1612 assert!((1.0..=3.0).contains(&single));
1613
1614 let mut s = GameState {
1615 cuques: 1_000_000.0,
1616 displayed_cuques: 1_000_000.0,
1617 ..Default::default()
1618 };
1619 s.buy_n(0, 50);
1620 let bulk = s.purchase_flash_strength;
1621 assert!(
1622 bulk > single,
1623 "bulk strength must exceed single ({bulk} vs {single})"
1624 );
1625 assert!(bulk <= 3.0, "bulk strength capped at 3.0");
1626 }
1627
1628 #[test]
1629 fn buy_upgrade_when_broke_sets_unaffordable_flash() {
1630 let mut s = GameState::default();
1631 let cheapest_idx = (0..UPGRADES.len())
1633 .min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
1634 .unwrap();
1635 let bought = s.buy_upgrade(cheapest_idx);
1636 assert!(!bought);
1637 assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
1638 }
1639
1640 #[test]
1641 fn migrate_resizes_per_catalog_flash_vecs() {
1642 let json = serde_json::to_string(&GameState::default()).unwrap();
1647 let mut s: GameState = serde_json::from_str(&json).unwrap();
1648 s.fingerer_flash_ticks.clear();
1650 s.upgrade_flash_ticks.clear();
1651 s.fingerer_unaffordable_flash.clear();
1652 s.upgrade_unaffordable_flash.clear();
1653 let m = s.migrate_runtime();
1654 assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
1655 assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
1656 assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
1657 assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
1658 }
1659
1660 #[test]
1661 fn migrate_seeds_displayed_counters() {
1662 let s = GameState {
1665 cuques: 5_000.0,
1666 ..Default::default()
1667 };
1668 let m = s.migrate_runtime();
1669 assert_eq!(m.displayed_cuques, 5_000.0);
1670 assert_eq!(m.displayed_fps, 0.0);
1673 }
1674
1675 #[test]
1676 fn unlock_pop_sets_active_toast_and_gold_flash() {
1677 let mut s = GameState::default();
1681 let biscuit = r(0, 0, 40, 20);
1683 s.click((20, 10), biscuit);
1684 s.tick();
1685 assert!(s.active_unlock_id.is_some());
1687 assert!(s.active_unlock_ticks > 0);
1688 assert!(s.achievement_flash_ticks > 0);
1689 }
1690
1691 fn perm_add_percent(pct: f64) -> Modifier {
1694 Modifier {
1695 source: ModifierSource::GreenCoin,
1696 effects: vec![ModifierEffect::AddPercent(pct)],
1697 duration: ModifierDuration::Permanent,
1698 created_at_tick: 0,
1699 }
1700 }
1701
1702 fn timed_mul(mult: f64, ticks: u32) -> Modifier {
1703 Modifier {
1704 source: ModifierSource::PurpleCoin,
1705 effects: vec![ModifierEffect::MulFactor(mult)],
1706 duration: ModifierDuration::Ticks(ticks),
1707 created_at_tick: 0,
1708 }
1709 }
1710
1711 #[test]
1712 fn attach_modifier_rebuilds_aggregate() {
1713 let mut s = GameState::default();
1714 s.fingerers_state
1715 .insert("index_finger".into(), fs_with_count(1));
1716 s.attach_modifier("index_finger", perm_add_percent(0.10));
1717 let agg = s.fingerer_aggregate("index_finger");
1718 assert!((agg.add_percent - 0.10).abs() < 1e-9);
1719
1720 s.attach_modifier("index_finger", perm_add_percent(0.10));
1722 let agg = s.fingerer_aggregate("index_finger");
1723 assert!((agg.add_percent - 0.20).abs() < 1e-9);
1724 }
1725
1726 #[test]
1727 fn attach_modifier_creates_state_entry_if_absent() {
1728 let mut s = GameState::default();
1733 s.attach_modifier("hand_of_god", perm_add_percent(0.10));
1734 let st = s.fingerers_state.get("hand_of_god").expect("entry exists");
1735 assert_eq!(st.count, 0);
1736 assert_eq!(st.modifiers.len(), 1);
1737 }
1738
1739 #[test]
1740 fn attach_modifier_random_owned_picks_only_owned() {
1741 let mut s = GameState::default();
1742 s.fingerers_state
1743 .insert("index_finger".into(), fs_with_count(5));
1744 s.fingerers_state
1746 .insert("hand_of_god".into(), fs_with_count(0));
1747 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1748 assert_eq!(chosen.as_deref(), Some("index_finger"));
1749 }
1750
1751 #[test]
1752 fn attach_modifier_random_owned_returns_none_when_nothing_owned() {
1753 let mut s = GameState::default();
1754 let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
1755 assert!(chosen.is_none());
1756 assert!(s.fingerers_state.is_empty());
1758 }
1759
1760 #[test]
1761 fn tick_decrements_timed_modifiers() {
1762 let mut s = GameState::default();
1763 s.fingerers_state
1764 .insert("index_finger".into(), fs_with_count(1));
1765 s.attach_modifier("index_finger", timed_mul(2.0, 5));
1766 s.tick();
1767 let st = s.fingerers_state.get("index_finger").unwrap();
1768 assert_eq!(st.modifiers.len(), 1);
1769 assert!(matches!(
1770 st.modifiers[0].duration,
1771 ModifierDuration::Ticks(4)
1772 ));
1773 }
1774
1775 #[test]
1776 fn tick_removes_expired_and_rebuilds_aggregate() {
1777 let mut s = GameState::default();
1778 s.fingerers_state
1779 .insert("index_finger".into(), fs_with_count(1));
1780 s.attach_modifier("index_finger", timed_mul(2.0, 1));
1781 s.tick();
1783 assert_eq!(
1784 s.fingerers_state
1785 .get("index_finger")
1786 .unwrap()
1787 .modifiers
1788 .len(),
1789 1
1790 );
1791 s.tick();
1793 let st = s.fingerers_state.get("index_finger").unwrap();
1794 assert_eq!(st.modifiers.len(), 0);
1795 assert!((st.aggregate.mul_factor - 1.0).abs() < 1e-9);
1796 }
1797
1798 #[test]
1799 fn permanent_modifier_does_not_decrement() {
1800 let mut s = GameState::default();
1801 s.fingerers_state
1802 .insert("index_finger".into(), fs_with_count(1));
1803 s.attach_modifier("index_finger", perm_add_percent(0.10));
1804 for _ in 0..50 {
1805 s.tick();
1806 }
1807 let st = s.fingerers_state.get("index_finger").unwrap();
1808 assert_eq!(st.modifiers.len(), 1);
1809 assert!(matches!(
1810 st.modifiers[0].duration,
1811 ModifierDuration::Permanent
1812 ));
1813 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1814 }
1815
1816 #[test]
1817 fn prestige_reset_clears_modifiers() {
1818 let mut s = GameState {
1822 lifetime_cuques: 1_000_000_000.0,
1823 ..Default::default()
1824 };
1825 s.fingerers_state
1826 .insert("index_finger".into(), fs_with_count(5));
1827 s.attach_modifier("index_finger", perm_add_percent(0.30));
1828 assert!(s.prestige_reset());
1829 assert!(s.fingerers_state.is_empty());
1830 }
1831
1832 #[test]
1833 fn fps_uses_aggregate_add_percent() {
1834 let mut bare = GameState::default();
1836 bare.fingerers_state
1837 .insert("index_finger".into(), fs_with_count(1));
1838 let bare_fps = bare.fps();
1839
1840 let mut boosted = GameState::default();
1841 boosted
1842 .fingerers_state
1843 .insert("index_finger".into(), fs_with_count(1));
1844 boosted.attach_modifier("index_finger", perm_add_percent(0.10));
1845 let boosted_fps = boosted.fps();
1846
1847 assert!(bare_fps > 0.0);
1848 assert!((boosted_fps - bare_fps * 1.10).abs() < 1e-9);
1849 }
1850
1851 #[test]
1852 fn migrate_runtime_rebuilds_aggregate_after_serde_skip() {
1853 let mut s = GameState::default();
1857 s.fingerers_state.insert(
1858 "index_finger".into(),
1859 FingererState {
1860 count: 1,
1861 modifiers: vec![perm_add_percent(0.25)],
1862 aggregate: FingererAggregate::default(), },
1864 );
1865 let m = s.migrate_runtime();
1866 let agg = m.fingerer_aggregate("index_finger");
1867 assert!((agg.add_percent - 0.25).abs() < 1e-9);
1868 }
1869
1870 use crate::game::green_coin::{GREEN_COIN_LIFE_TICKS, GreenCoin};
1873
1874 fn fake_green_coin() -> GreenCoin {
1875 GreenCoin {
1876 frac_x: 0.5,
1877 frac_y: 0.5,
1878 life_ticks: GREEN_COIN_LIFE_TICKS,
1879 }
1880 }
1881
1882 #[test]
1883 fn catch_green_coin_increments_grand_total_and_per_variant_counter() {
1884 let mut s = GameState {
1885 green_coin: Some(fake_green_coin()),
1886 ..Default::default()
1887 };
1888 s.fingerers_state
1889 .insert("index_finger".into(), fs_with_count(1));
1890 assert!(s.catch_green_coin());
1891 assert_eq!(s.golden_caught, 1, "rollup increments");
1892 assert_eq!(s.green_coin_caught, 1, "per-variant increments");
1893 assert_eq!(s.lucky_caught, 0);
1894 assert_eq!(s.frenzy_caught, 0);
1895 assert_eq!(s.buff_caught, 0);
1896 }
1897
1898 #[test]
1899 fn catch_green_coin_attaches_permanent_modifier() {
1900 let mut s = GameState::default();
1901 s.fingerers_state
1902 .insert("index_finger".into(), fs_with_count(3));
1903 s.green_coin = Some(fake_green_coin());
1904
1905 let caught = s.catch_green_coin();
1906
1907 assert!(caught);
1908 assert!(s.green_coin.is_none());
1909 let st = s.fingerers_state.get("index_finger").unwrap();
1910 assert_eq!(st.modifiers.len(), 1);
1911 let m = &st.modifiers[0];
1912 assert!(matches!(m.source, ModifierSource::GreenCoin));
1913 assert!(matches!(m.duration, ModifierDuration::Permanent));
1914 assert!(matches!(
1915 m.effects[0],
1916 ModifierEffect::AddPercent(v) if (v - 0.10).abs() < 1e-9
1917 ));
1918 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1919 }
1920
1921 #[test]
1922 fn catch_green_coin_with_no_owned_lands_on_index_finger() {
1923 let mut s = GameState {
1928 green_coin: Some(fake_green_coin()),
1929 ..Default::default()
1930 };
1931
1932 let caught = s.catch_green_coin();
1933
1934 assert!(caught);
1935 assert!(s.green_coin.is_none());
1936 let st = s
1937 .fingerers_state
1938 .get(FINGERERS[0].id)
1939 .expect("modifier landed on Index Finger");
1940 assert_eq!(st.modifiers.len(), 1);
1941 assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
1942 }
1943
1944 #[test]
1945 fn attach_modifier_random_visible_can_pick_unowned_when_lifetime_unlocks_it() {
1946 let mut s = GameState {
1952 lifetime_cuques: 60.0,
1953 ..Default::default()
1954 };
1955 let m = perm_add_percent(0.10);
1956 let chosen = s.attach_modifier_random_visible(m);
1957 let id = chosen.expect("at least one visible fingerer always exists");
1958 let visible_ids: Vec<&str> = FINGERERS
1960 .iter()
1961 .enumerate()
1962 .filter(|(idx, f)| {
1963 fingerer::visible(*idx, 0, s.lifetime_cuques) && (*idx == 0 || f.id == "whole_hand")
1964 })
1965 .map(|(_, f)| f.id)
1966 .collect();
1967 assert!(visible_ids.contains(&id.as_str()));
1968 }
1969
1970 #[test]
1971 fn catch_green_coin_returns_false_when_no_coin() {
1972 let mut s = GameState::default();
1973 assert!(!s.catch_green_coin());
1974 }
1975
1976 #[test]
1977 fn tick_green_coin_decrements_lifetime_and_clears_at_zero() {
1978 let mut s = GameState {
1979 green_coin: Some(GreenCoin {
1980 frac_x: 0.5,
1981 frac_y: 0.5,
1982 life_ticks: 2,
1983 }),
1984 ..Default::default()
1985 };
1986 s.tick_green_coin();
1987 assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 1);
1988 s.tick_green_coin();
1989 assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 0);
1991 s.tick_green_coin();
1992 assert!(s.green_coin.is_none());
1994 }
1995
1996 #[test]
1997 fn green_coin_stacks_additively_on_repeat_catches() {
1998 let mut s = GameState::default();
2000 s.fingerers_state
2001 .insert("index_finger".into(), fs_with_count(1));
2002 for _ in 0..2 {
2003 s.green_coin = Some(fake_green_coin());
2004 s.catch_green_coin();
2005 }
2006 let st = s.fingerers_state.get("index_finger").unwrap();
2007 assert_eq!(st.modifiers.len(), 2);
2009 assert!((st.aggregate.add_percent - 0.20).abs() < 1e-9);
2010 }
2011
2012 #[test]
2013 fn prestige_reset_clears_green_coin_state() {
2014 let mut s = GameState {
2015 lifetime_cuques: 1_000_000_000.0,
2016 ..Default::default()
2017 };
2018 s.fingerers_state
2019 .insert("index_finger".into(), fs_with_count(1));
2020 s.goldens_since_green_coin = 7;
2021 s.green_coin = Some(fake_green_coin());
2022 s.prestige_reset();
2023 assert!(s.green_coin.is_none());
2024 assert_eq!(s.goldens_since_green_coin, 0);
2025 }
2026}