use std::collections::{HashMap, HashSet};
use rand::RngExt;
use ratatui::layout::Rect;
use serde::{Deserialize, Serialize};
use crate::game::achievement::ACHIEVEMENTS;
use crate::game::fingerer::{self, FINGERERS};
use crate::game::golden::GoldenCuque;
use crate::game::green_coin::GreenCoin;
use crate::game::modifier::{
FingererAggregate, Modifier, ModifierDuration, ModifierEffect, ModifierSource,
};
use crate::game::upgrade::{UPGRADES, UpgradeEffect};
pub const TICK_HZ: u32 = 20;
pub const TICK_DT: f64 = 1.0 / TICK_HZ as f64;
pub const CLENCH_TICKS: u32 = 6;
pub const CLENCH_SQUASH_TICKS: u32 = 2;
const PARTICLE_LIFE: u32 = 20;
pub const MISCLICK_LIFE: u32 = 8;
pub const TOAST_TICKS: u32 = TICK_HZ * 4;
pub const HUD_FLASH_TICKS: u32 = TICK_HZ; pub const ACHIEVEMENT_FLASH_TICKS: u32 = TICK_HZ * 2;
pub const UNLOCK_FLASH_TICKS: u32 = TICK_HZ / 2; const PARTICLE_FRAC_RISE: f32 = 0.006;
const GOLDEN_REWARD_SECONDS: f64 = 60.0;
const GOLDEN_REWARD_FLAT: f64 = 10.0;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ParticleKind {
Click,
ClickBig,
Auto,
Golden,
Confetti,
}
#[derive(Clone)]
pub struct Particle {
pub frac_x: f32,
pub frac_y: f32,
pub life: u32,
pub text: String,
pub kind: ParticleKind,
pub drift_x: f32,
}
#[derive(Clone)]
pub struct MisclickParticle {
pub col: u16,
pub row: u16,
pub life: u32,
}
pub fn screen_to_biscuit_frac(col: u16, row: u16, biscuit: Rect) -> (f32, f32) {
if biscuit.width == 0 || biscuit.height == 0 {
return (0.5, 0.5);
}
let fx = ((col as i32 - biscuit.x as i32) as f32) / biscuit.width as f32;
let fy = ((row as i32 - biscuit.y as i32) as f32) / biscuit.height as f32;
(fx.clamp(0.0, 1.0), fy.clamp(0.0, 1.0))
}
pub fn biscuit_frac_to_screen(frac_x: f32, frac_y: f32, biscuit: Rect) -> (u16, u16) {
let col = biscuit.x as f32 + frac_x.clamp(0.0, 1.0) * biscuit.width as f32;
let row = biscuit.y as f32 + frac_y.clamp(0.0, 1.0) * biscuit.height as f32;
(
col.round().clamp(0.0, u16::MAX as f32) as u16,
row.round().clamp(0.0, u16::MAX as f32) as u16,
)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Buff {
ClickFrenzy {
ticks_remaining: u32,
initial_ticks: u32,
mult: f64,
},
}
impl Buff {
pub fn ticks_remaining(&self) -> u32 {
match self {
Buff::ClickFrenzy {
ticks_remaining, ..
} => *ticks_remaining,
}
}
pub fn strength(&self) -> f32 {
const FADE_TICKS: f32 = 30.0; let remaining = self.ticks_remaining() as f32;
if remaining >= FADE_TICKS {
1.0
} else {
let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
}
fn tick(&mut self) {
match self {
Buff::ClickFrenzy {
ticks_remaining, ..
} => {
*ticks_remaining = ticks_remaining.saturating_sub(1);
}
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct FingererState {
#[serde(default)]
pub count: u32,
#[serde(default)]
pub modifiers: Vec<Modifier>,
#[serde(skip)]
pub aggregate: FingererAggregate,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameState {
#[serde(default = "default_save_version")]
pub version: u32,
#[serde(default)]
pub cuques: f64,
#[serde(default)]
pub total_clicks: u64,
#[serde(default)]
pub lifetime_cuques: f64,
#[serde(default)]
pub best_fps: f64,
#[serde(default)]
pub golden_caught: u64,
#[serde(default)]
pub lucky_caught: u64,
#[serde(default)]
pub frenzy_caught: u64,
#[serde(default)]
pub buff_caught: u64,
#[serde(default)]
pub green_coin_caught: u64,
#[serde(default)]
pub fingerers_state: HashMap<String, FingererState>,
#[serde(default)]
pub achievements_earned: HashSet<String>,
#[serde(default)]
pub upgrades_earned: HashSet<String>,
#[serde(default)]
pub prestige: u64,
#[serde(default)]
pub total_play_ticks: u64,
#[serde(default)]
pub buffs: Vec<Buff>,
#[serde(default)]
pub goldens_since_green_coin: u32,
#[serde(skip)]
pub clench_ticks: u32,
#[serde(skip)]
pub particles: Vec<Particle>,
#[serde(skip)]
pub misclick_particles: Vec<MisclickParticle>,
#[serde(skip)]
pub goldens: [Option<GoldenCuque>; 3],
#[serde(skip)]
pub golden_cooldowns: [u32; 3],
#[serde(skip)]
pub green_coin: Option<GreenCoin>,
#[serde(skip)]
pub session_ticks: u64,
#[serde(skip)]
pub newly_unlocked: Vec<String>,
#[serde(skip)]
pub active_unlock_id: Option<String>,
#[serde(skip)]
pub active_unlock_ticks: u32,
#[serde(skip)]
pub visual_debt: f64,
#[serde(skip)]
pub lucky_flash_ticks: u32,
#[serde(skip)]
pub achievement_flash_ticks: u32,
#[serde(skip)]
pub green_coin_flash_ticks: u32,
#[serde(skip)]
pub border_phase: u32,
#[serde(skip)]
pub steady_phase: u32,
#[serde(skip)]
pub purchase_flash_ticks: u32,
#[serde(skip)]
pub purchase_flash_strength: f32,
#[serde(skip)]
pub fingerer_flash_ticks: Vec<u32>,
#[serde(skip)]
pub upgrade_flash_ticks: Vec<u32>,
#[serde(skip)]
pub fingerer_unaffordable_flash: Vec<u32>,
#[serde(skip)]
pub upgrade_unaffordable_flash: Vec<u32>,
#[serde(skip)]
pub fingerer_unlock_flash: Vec<u32>,
#[serde(skip)]
pub upgrade_unlock_flash: Vec<u32>,
#[serde(skip)]
pub fingerer_green_coin_flash: Vec<u32>,
#[serde(skip)]
pub prev_fingerer_affordable: Vec<bool>,
#[serde(skip)]
pub prev_upgrade_affordable: Vec<bool>,
#[serde(skip)]
pub space_pressed_this_tick: bool,
#[serde(skip)]
pub ticks_since_last_press: u32,
#[serde(skip)]
pub space_hold_ticks: u32,
#[serde(skip)]
pub displayed_cuques: f64,
#[serde(skip)]
pub displayed_fps: f64,
#[serde(skip)]
pub cuques_flash_ticks: u32,
#[serde(skip)]
pub cuques_spend_flash_ticks: u32,
}
pub 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 {
crate::save::CURRENT_VERSION
}
impl Default for GameState {
fn default() -> Self {
Self {
version: crate::save::CURRENT_VERSION,
cuques: 0.0,
total_clicks: 0,
lifetime_cuques: 0.0,
best_fps: 0.0,
golden_caught: 0,
lucky_caught: 0,
frenzy_caught: 0,
buff_caught: 0,
green_coin_caught: 0,
fingerers_state: HashMap::new(),
achievements_earned: HashSet::new(),
upgrades_earned: HashSet::new(),
prestige: 0,
total_play_ticks: 0,
buffs: Vec::new(),
goldens_since_green_coin: 0,
clench_ticks: 0,
particles: Vec::new(),
misclick_particles: Vec::new(),
goldens: [None, None, None],
golden_cooldowns: [
crate::game::golden::next_cooldown(),
crate::game::golden::next_cooldown(),
crate::game::golden::next_cooldown(),
],
green_coin: None,
session_ticks: 0,
newly_unlocked: Vec::new(),
active_unlock_id: None,
active_unlock_ticks: 0,
visual_debt: 0.0,
lucky_flash_ticks: 0,
achievement_flash_ticks: 0,
green_coin_flash_ticks: 0,
border_phase: 0,
steady_phase: 0,
purchase_flash_ticks: 0,
purchase_flash_strength: 1.0,
fingerer_flash_ticks: vec![0; fingerer::count()],
upgrade_flash_ticks: vec![0; UPGRADES.len()],
fingerer_unaffordable_flash: vec![0; fingerer::count()],
upgrade_unaffordable_flash: vec![0; UPGRADES.len()],
fingerer_unlock_flash: vec![0; fingerer::count()],
upgrade_unlock_flash: vec![0; UPGRADES.len()],
fingerer_green_coin_flash: vec![0; fingerer::count()],
prev_fingerer_affordable: vec![false; fingerer::count()],
prev_upgrade_affordable: vec![false; UPGRADES.len()],
space_pressed_this_tick: false,
ticks_since_last_press: u32::MAX,
space_hold_ticks: 0,
displayed_cuques: 0.0,
displayed_fps: 0.0,
cuques_flash_ticks: 0,
cuques_spend_flash_ticks: 0,
}
}
}
impl GameState {
pub fn migrate_runtime(mut self) -> Self {
for st in self.fingerers_state.values_mut() {
st.aggregate = FingererAggregate::rebuild(&st.modifiers);
}
if self.fingerer_flash_ticks.len() != fingerer::count() {
self.fingerer_flash_ticks = vec![0; fingerer::count()];
}
if self.upgrade_flash_ticks.len() != UPGRADES.len() {
self.upgrade_flash_ticks = vec![0; UPGRADES.len()];
}
if self.fingerer_unaffordable_flash.len() != fingerer::count() {
self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
}
if self.upgrade_unaffordable_flash.len() != UPGRADES.len() {
self.upgrade_unaffordable_flash = vec![0; UPGRADES.len()];
}
if self.fingerer_unlock_flash.len() != fingerer::count() {
self.fingerer_unlock_flash = vec![0; fingerer::count()];
}
if self.upgrade_unlock_flash.len() != UPGRADES.len() {
self.upgrade_unlock_flash = vec![0; UPGRADES.len()];
}
if self.fingerer_green_coin_flash.len() != fingerer::count() {
self.fingerer_green_coin_flash = vec![0; fingerer::count()];
}
if self.prev_fingerer_affordable.len() != fingerer::count() {
self.prev_fingerer_affordable =
(0..fingerer::count()).map(|i| self.can_buy(i)).collect();
}
if self.prev_upgrade_affordable.len() != UPGRADES.len() {
self.prev_upgrade_affordable = (0..UPGRADES.len())
.map(|i| {
let u = &UPGRADES[i];
!self.has_upgrade(u.id) && u.req.met(&self) && self.cuques >= u.cost
})
.collect();
}
for cd in self.golden_cooldowns.iter_mut() {
if *cd == 0 {
*cd = crate::game::golden::next_cooldown();
}
}
self.displayed_cuques = self.cuques;
self.displayed_fps = 0.0; if self.purchase_flash_strength <= 0.0 {
self.purchase_flash_strength = 1.0;
}
self
}
pub fn fingerer_count(&self, id: &str) -> u32 {
self.fingerers_state.get(id).map(|st| st.count).unwrap_or(0)
}
pub fn fingerer_count_idx(&self, idx: usize) -> u32 {
FINGERERS
.get(idx)
.map(|f| self.fingerer_count(f.id))
.unwrap_or(0)
}
pub fn fingerers_owned_total(&self) -> u32 {
self.fingerers_state.values().map(|st| st.count).sum()
}
pub fn fingerer_aggregate(&self, id: &str) -> FingererAggregate {
self.fingerers_state
.get(id)
.map(|st| st.aggregate)
.unwrap_or_default()
}
pub fn attach_modifier(&mut self, fingerer_id: &str, m: Modifier) {
let st = self
.fingerers_state
.entry(fingerer_id.to_string())
.or_default();
st.modifiers.push(m);
st.aggregate = FingererAggregate::rebuild(&st.modifiers);
}
pub fn attach_modifier_random_owned(&mut self, m: Modifier) -> Option<String> {
let owned: Vec<String> = self
.fingerers_state
.iter()
.filter(|(_, st)| st.count > 0)
.map(|(id, _)| id.clone())
.collect();
if owned.is_empty() {
return None;
}
let pick = owned[rand::rng().random_range(0..owned.len())].clone();
self.attach_modifier(&pick, m);
Some(pick)
}
pub fn attach_modifier_random_visible(&mut self, m: Modifier) -> Option<String> {
let visible: Vec<String> = FINGERERS
.iter()
.enumerate()
.filter(|(idx, f)| {
let owned = self.fingerer_count(f.id);
fingerer::visible(*idx, owned, self.lifetime_cuques)
})
.map(|(_, f)| f.id.to_string())
.collect();
if visible.is_empty() {
return None;
}
let pick = visible[rand::rng().random_range(0..visible.len())].clone();
self.attach_modifier(&pick, m);
Some(pick)
}
pub fn has_upgrade(&self, id: &str) -> bool {
self.upgrades_earned.contains(id)
}
pub fn has_achievement(&self, id: &str) -> bool {
self.achievements_earned.contains(id)
}
pub fn has_achievement_idx(&self, idx: usize) -> bool {
ACHIEVEMENTS
.get(idx)
.is_some_and(|a| self.has_achievement(a.id))
}
pub fn click(&mut self, origin: (u16, u16), biscuit: Rect) {
let power = self.click_power();
self.add_cuques(power);
self.total_clicks += 1;
self.clench_ticks = CLENCH_TICKS;
if power >= 50.0 {
self.cuques_flash_ticks = HUD_FLASH_TICKS;
}
let mut rng = rand::rng();
let jitter_x_range = (biscuit.width as i32 / 8).max(3);
let jitter_x = rng.random_range(-jitter_x_range..=jitter_x_range);
let jitter_y = rng.random_range(-1..=1);
let col = (origin.0 as i32 + jitter_x).max(0) as u16;
let row = origin
.1
.saturating_sub(1)
.saturating_add_signed(jitter_y as i16);
let (frac_x, frac_y) = screen_to_biscuit_frac(col, row, biscuit);
let drift_x = rng.random_range(-0.012_f32..=0.012);
let frenzy_active = self
.buffs
.iter()
.any(|b| matches!(b, Buff::ClickFrenzy { .. }));
let kind = if power >= 50.0 || frenzy_active {
ParticleKind::ClickBig
} else {
ParticleKind::Click
};
self.particles.push(Particle {
frac_x,
frac_y,
life: PARTICLE_LIFE,
text: format!("+{}", crate::format::big(power)),
kind,
drift_x,
});
if frenzy_active {
for _ in 0..2 {
let halo_x = rng.random_range(-0.05_f32..=0.05);
let halo_y = rng.random_range(-0.04_f32..=0.04);
let (hfx, hfy) =
screen_to_biscuit_frac(origin.0, origin.1.saturating_sub(1), biscuit);
self.particles.push(Particle {
frac_x: (hfx + halo_x).clamp(0.0, 1.0),
frac_y: (hfy + halo_y).clamp(0.0, 1.0),
life: PARTICLE_LIFE / 2,
text: "*".into(),
kind: ParticleKind::Confetti,
drift_x: rng.random_range(-0.02_f32..=0.02),
});
}
}
}
pub fn spawn_misclick(&mut self, col: u16, row: u16) {
if self.misclick_particles.len() >= 16 {
self.misclick_particles.remove(0);
}
self.misclick_particles.push(MisclickParticle {
col,
row,
life: MISCLICK_LIFE,
});
}
pub fn spawn_confetti(&mut self, n: u32) {
if n == 0 {
return;
}
let mut rng = rand::rng();
let glyphs = ['*', '+', '~', '.', 'o'];
for _ in 0..n.min(8) {
let glyph = glyphs[rng.random_range(0..glyphs.len())];
self.particles.push(Particle {
frac_x: rng.random_range(0.10_f32..=0.90),
frac_y: rng.random_range(0.20_f32..=0.85),
life: PARTICLE_LIFE,
text: glyph.to_string(),
kind: ParticleKind::Confetti,
drift_x: rng.random_range(-0.02_f32..=0.02),
});
}
}
pub fn click_power(&self) -> f64 {
let mut m = 1.0;
for u in UPGRADES.iter() {
if self.has_upgrade(u.id)
&& let UpgradeEffect::ClickMult(f) = u.effect
{
m *= f;
}
}
for b in &self.buffs {
let Buff::ClickFrenzy { mult, .. } = b;
m *= *mult;
}
m
}
pub fn fingerer_mult(&self, idx: usize) -> f64 {
let Some(target) = FINGERERS.get(idx) else {
return 1.0;
};
let mut m = 1.0;
for u in UPGRADES.iter() {
if !self.has_upgrade(u.id) {
continue;
}
match u.effect {
UpgradeEffect::FingererMult(id, f) if id == target.id => m *= f,
UpgradeEffect::AllFingerersMult(f) => m *= f,
_ => {}
}
}
m
}
fn add_cuques(&mut self, amount: f64) {
self.cuques += amount;
self.lifetime_cuques += amount;
}
pub fn dev_add_cuques(&mut self, amount: f64) {
self.add_cuques(amount);
self.cuques_flash_ticks = HUD_FLASH_TICKS;
}
pub fn catch_golden(&mut self, variant: crate::game::golden::GoldenVariant) -> f64 {
use crate::game::golden::GoldenVariant;
let Some(golden) = self.goldens[variant as usize].take() else {
return 0.0;
};
self.golden_caught += 1;
self.golden_cooldowns[variant as usize] = crate::game::golden::next_cooldown();
let (reward, label) = match golden.variant {
GoldenVariant::Lucky => {
self.lucky_caught += 1;
let fps = self.fps();
let r = (fps * GOLDEN_REWARD_SECONDS).max(GOLDEN_REWARD_FLAT);
self.add_cuques(r);
self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
self.cuques_flash_ticks = HUD_FLASH_TICKS;
(r, format!("+{}", crate::format::big(r)))
}
GoldenVariant::Frenzy => {
self.frenzy_caught += 1;
let dur = TICK_HZ * 13;
self.buffs.push(Buff::ClickFrenzy {
ticks_remaining: dur,
initial_ticks: dur,
mult: 777.0,
});
(0.0, "FRENZY x777!".into())
}
GoldenVariant::Buff => {
self.buff_caught += 1;
let dur = TICK_HZ * 60;
let m = Modifier {
source: crate::game::modifier::ModifierSource::PurpleCoin,
effects: vec![crate::game::modifier::ModifierEffect::MulFactor(7.0)],
duration: ModifierDuration::Ticks(dur),
created_at_tick: self.total_play_ticks,
};
if self.attach_modifier_random_owned(m.clone()).is_none() {
let pick = FINGERERS[0].id;
self.attach_modifier(pick, m);
}
(0.0, "BOOSTED x7!".into())
}
};
self.particles.push(Particle {
frac_x: golden.frac_x,
frac_y: golden.frac_y,
life: PARTICLE_LIFE * 2,
text: label,
kind: ParticleKind::Golden,
drift_x: 0.0,
});
reward
}
pub fn fps(&self) -> f64 {
let base: f64 = FINGERERS
.iter()
.enumerate()
.map(|(i, k)| {
let count = self.fingerer_count(k.id) as f64;
let upgrades_mult = self.fingerer_mult(i);
let agg = self.fingerer_aggregate(k.id);
let pre = (k.fps_per_unit * count + agg.flat_fps) * upgrades_mult;
pre * (1.0 + agg.add_percent) * agg.mul_factor
})
.sum();
base * self.prestige_mult()
}
pub fn border_speed(&self) -> u32 {
let mut s: u32 = 1;
for b in &self.buffs {
match b {
Buff::ClickFrenzy { .. } => s = s.max(3),
}
}
if self.fingerers_state.values().any(|st| {
st.modifiers
.iter()
.any(|m| matches!(m.duration, ModifierDuration::Ticks(_)))
}) {
s = s.max(2);
}
if self.lucky_flash_ticks > 0 {
s = s.max(4);
}
if self.achievement_flash_ticks > 0 {
s = s.max(3);
}
if self.purchase_flash_ticks > 0 {
s += 2;
}
s
}
pub fn trigger_purchase_flash(&mut self, strength: f32) {
self.purchase_flash_ticks = PURCHASE_FLASH_TICKS;
self.purchase_flash_strength = self.purchase_flash_strength.max(strength).clamp(1.0, 3.0);
}
pub fn prestige_mult(&self) -> f64 {
1.0 + 0.01 * self.prestige as f64
}
pub fn prestige_earned_total(&self) -> u64 {
(self.lifetime_cuques / 1_000_000.0).sqrt().floor() as u64
}
pub fn prestige_available(&self) -> u64 {
self.prestige_earned_total().saturating_sub(self.prestige)
}
pub fn prestige_reset(&mut self) -> bool {
let available = self.prestige_available();
if available == 0 {
return false;
}
self.prestige = self.prestige_earned_total();
self.cuques = 0.0;
self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
self.fingerers_state.clear();
self.upgrades_earned.clear();
self.buffs.clear();
self.visual_debt = 0.0;
self.particles.clear();
self.misclick_particles.clear();
self.goldens = [None, None, None];
self.green_coin = None;
self.goldens_since_green_coin = 0;
self.clench_ticks = 0;
for cd in self.golden_cooldowns.iter_mut() {
*cd = crate::game::golden::next_cooldown();
}
true
}
pub fn tick(&mut self) {
for st in self.fingerers_state.values_mut() {
let before = st.modifiers.len();
st.modifiers.retain_mut(|m| match &mut m.duration {
ModifierDuration::Permanent => true,
ModifierDuration::Ticks(0) => false,
ModifierDuration::Ticks(n) => {
*n -= 1;
true
}
});
if before != st.modifiers.len() {
st.aggregate = FingererAggregate::rebuild(&st.modifiers);
}
}
for b in self.buffs.iter_mut() {
b.tick();
}
self.buffs.retain(|b| b.ticks_remaining() > 0);
self.lucky_flash_ticks = self.lucky_flash_ticks.saturating_sub(1);
self.achievement_flash_ticks = self.achievement_flash_ticks.saturating_sub(1);
self.green_coin_flash_ticks = self.green_coin_flash_ticks.saturating_sub(1);
self.purchase_flash_ticks = self.purchase_flash_ticks.saturating_sub(1);
if self.purchase_flash_ticks == 0 {
self.purchase_flash_strength = 1.0;
}
self.cuques_flash_ticks = self.cuques_flash_ticks.saturating_sub(1);
self.cuques_spend_flash_ticks = self.cuques_spend_flash_ticks.saturating_sub(1);
for t in self.fingerer_flash_ticks.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.upgrade_flash_ticks.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.fingerer_unaffordable_flash.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.upgrade_unaffordable_flash.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.fingerer_unlock_flash.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.upgrade_unlock_flash.iter_mut() {
*t = t.saturating_sub(1);
}
for t in self.fingerer_green_coin_flash.iter_mut() {
*t = t.saturating_sub(1);
}
if self.space_pressed_this_tick {
self.ticks_since_last_press = 0;
} else {
self.ticks_since_last_press = self.ticks_since_last_press.saturating_add(1);
}
self.space_pressed_this_tick = false;
const HOLD_GRACE_TICKS: u32 = 3; if self.ticks_since_last_press <= HOLD_GRACE_TICKS {
self.space_hold_ticks = self.space_hold_ticks.saturating_add(1);
} else {
self.space_hold_ticks = 0;
}
let speed = self.border_speed();
self.border_phase = self.border_phase.wrapping_add(speed);
self.steady_phase = self.steady_phase.wrapping_add(1);
let fps = self.fps();
if fps > self.best_fps {
self.best_fps = fps;
}
let gained = fps * TICK_DT;
self.add_cuques(gained);
self.visual_debt += gained;
self.clench_ticks = self.clench_ticks.saturating_sub(1);
for p in self.particles.iter_mut() {
p.life = p.life.saturating_sub(1);
p.frac_y -= PARTICLE_FRAC_RISE;
p.frac_x = (p.frac_x + p.drift_x).clamp(0.0, 1.0);
}
self.particles.retain(|p| p.life > 0);
for m in self.misclick_particles.iter_mut() {
m.life = m.life.saturating_sub(1);
}
self.misclick_particles.retain(|m| m.life > 0);
let fingerer_now: Vec<bool> = (0..fingerer::count()).map(|i| self.can_buy(i)).collect();
let upgrade_now: Vec<bool> = UPGRADES
.iter()
.map(|u| !self.has_upgrade(u.id) && u.req.met(self) && self.cuques >= u.cost)
.collect();
for (i, &now) in fingerer_now.iter().enumerate() {
let was = self
.prev_fingerer_affordable
.get(i)
.copied()
.unwrap_or(false);
if now
&& !was
&& let Some(slot) = self.fingerer_unlock_flash.get_mut(i)
{
*slot = UNLOCK_FLASH_TICKS;
}
if let Some(slot) = self.prev_fingerer_affordable.get_mut(i) {
*slot = now;
}
}
for (i, &now) in upgrade_now.iter().enumerate() {
let was = self
.prev_upgrade_affordable
.get(i)
.copied()
.unwrap_or(false);
if now
&& !was
&& let Some(slot) = self.upgrade_unlock_flash.get_mut(i)
{
*slot = UNLOCK_FLASH_TICKS;
}
if let Some(slot) = self.prev_upgrade_affordable.get_mut(i) {
*slot = now;
}
}
const SNAP_BELOW: f64 = 5.0;
let tween = 0.18_f64;
let dc = self.cuques - self.displayed_cuques;
if dc.abs() < SNAP_BELOW {
self.displayed_cuques = self.cuques;
} else {
self.displayed_cuques += dc * tween;
}
let df = fps - self.displayed_fps;
if df.abs() < SNAP_BELOW {
self.displayed_fps = fps;
} else {
self.displayed_fps += df * tween;
}
self.session_ticks += 1;
self.total_play_ticks += 1;
self.tick_achievements();
self.active_unlock_ticks = self.active_unlock_ticks.saturating_sub(1);
if self.active_unlock_ticks == 0 {
self.active_unlock_id = None;
if !self.newly_unlocked.is_empty() {
self.active_unlock_id = Some(self.newly_unlocked.remove(0));
self.active_unlock_ticks = TOAST_TICKS;
self.achievement_flash_ticks = ACHIEVEMENT_FLASH_TICKS;
}
}
}
pub fn tick_achievements(&mut self) {
for a in ACHIEVEMENTS.iter() {
if !self.has_achievement(a.id) && (a.unlocked)(self) {
self.achievements_earned.insert(a.id.to_string());
self.newly_unlocked.push(a.id.to_string());
}
}
}
pub fn tick_golden(&mut self) {
for i in 0..self.goldens.len() {
if let Some(g) = self.goldens[i].as_mut() {
if g.life_ticks == 0 {
self.goldens[i] = None;
self.golden_cooldowns[i] = crate::game::golden::next_cooldown();
} else {
g.life_ticks -= 1;
}
} else if self.golden_cooldowns[i] > 0 {
self.golden_cooldowns[i] -= 1;
}
}
}
pub fn tick_green_coin(&mut self) {
if let Some(g) = self.green_coin.as_mut() {
if g.life_ticks == 0 {
self.green_coin = None;
} else {
g.life_ticks -= 1;
}
}
}
pub fn catch_green_coin(&mut self) -> bool {
let Some(g) = self.green_coin.take() else {
return false;
};
let m = Modifier {
source: ModifierSource::GreenCoin,
effects: vec![ModifierEffect::AddPercent(
crate::game::green_coin::GREEN_COIN_ADD_PERCENT,
)],
duration: ModifierDuration::Permanent,
created_at_tick: self.total_play_ticks,
};
let chosen = self.attach_modifier_random_visible(m);
self.golden_caught += 1;
self.green_coin_caught += 1;
self.green_coin_flash_ticks = GREEN_COIN_FLASH_TICKS;
let label = match &chosen {
Some(id) => {
let idx = FINGERERS.iter().position(|f| f.id == id);
if let Some(i) = idx
&& let Some(slot) = self.fingerer_green_coin_flash.get_mut(i)
{
*slot = GREEN_COIN_ROW_FLASH_TICKS;
}
let name = idx
.and_then(|i| crate::i18n::t().fingerer_names.get(i).copied())
.unwrap_or("?");
format!("+10% {}", name)
}
None => "+10% ???".to_string(),
};
self.particles.push(Particle {
frac_x: g.frac_x,
frac_y: g.frac_y,
life: PARTICLE_LIFE * 2,
text: label,
kind: ParticleKind::Golden,
drift_x: 0.0,
});
true
}
pub fn trigger_clench(&mut self) {
self.clench_ticks = CLENCH_TICKS;
}
pub fn space_held(&self) -> bool {
self.space_hold_ticks >= TICK_HZ
}
pub fn spawn_auto_particle(&mut self, frac_x: f32, frac_y: f32) {
let amount = self.visual_debt.floor() as u64;
if amount == 0 {
return;
}
self.visual_debt -= amount as f64;
let drift_x = rand::rng().random_range(-0.008_f32..=0.008);
self.particles.push(Particle {
frac_x,
frac_y,
life: PARTICLE_LIFE,
text: format!("+{}", crate::format::big(amount as f64)),
kind: ParticleKind::Auto,
drift_x,
});
}
pub fn cost(&self, idx: usize) -> f64 {
let k = &FINGERERS[idx];
let raw = k.base_cost * k.cost_scale.powi(self.fingerer_count_idx(idx) as i32);
raw.floor()
}
pub fn affordable_cuques(&self) -> f64 {
self.cuques.min(self.displayed_cuques.floor())
}
pub fn can_buy(&self, idx: usize) -> bool {
self.affordable_cuques() >= self.cost(idx)
}
fn buy_one_quiet(&mut self, idx: usize) -> bool {
let c = self.cost(idx);
if self.affordable_cuques() >= c
&& let Some(f) = FINGERERS.get(idx)
{
self.cuques -= c;
self.fingerers_state
.entry(f.id.to_string())
.or_default()
.count += 1;
true
} else {
false
}
}
fn flash_purchase(&mut self, idx: usize, bought: u32, slot_table: PurchaseSlot) {
if bought == 0 {
return;
}
let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
self.trigger_purchase_flash(strength);
match slot_table {
PurchaseSlot::Fingerer => {
if let Some(slot) = self.fingerer_flash_ticks.get_mut(idx) {
*slot = PURCHASE_FLASH_TICKS;
}
}
PurchaseSlot::Upgrade => {
if let Some(slot) = self.upgrade_flash_ticks.get_mut(idx) {
*slot = PURCHASE_FLASH_TICKS;
}
}
}
self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
if bought >= 5 {
self.spawn_confetti(bought.min(8));
}
}
fn flash_unaffordable_fingerer(&mut self, idx: usize) {
if let Some(slot) = self.fingerer_unaffordable_flash.get_mut(idx) {
*slot = PURCHASE_FLASH_TICKS / 2;
}
}
fn flash_unaffordable_upgrade(&mut self, idx: usize) {
if let Some(slot) = self.upgrade_unaffordable_flash.get_mut(idx) {
*slot = PURCHASE_FLASH_TICKS / 2;
}
}
pub fn buy(&mut self, idx: usize) -> bool {
if self.buy_one_quiet(idx) {
self.flash_purchase(idx, 1, PurchaseSlot::Fingerer);
true
} else {
self.flash_unaffordable_fingerer(idx);
false
}
}
pub fn buy_n(&mut self, idx: usize, n: u32) -> u32 {
let mut bought = 0;
for _ in 0..n {
if !self.buy_one_quiet(idx) {
break;
}
bought += 1;
}
if bought == 0 {
self.flash_unaffordable_fingerer(idx);
} else {
self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
}
bought
}
pub fn buy_max(&mut self, idx: usize) -> u32 {
let mut bought = 0;
while self.buy_one_quiet(idx) {
bought += 1;
}
if bought == 0 {
self.flash_unaffordable_fingerer(idx);
} else {
self.flash_purchase(idx, bought, PurchaseSlot::Fingerer);
}
bought
}
pub fn buy_upgrade(&mut self, idx: usize) -> bool {
let Some(u) = UPGRADES.get(idx) else {
return false;
};
if self.has_upgrade(u.id) {
return false;
}
if !u.req.met(self) || self.affordable_cuques() < u.cost {
self.flash_unaffordable_upgrade(idx);
return false;
}
self.cuques -= u.cost;
self.upgrades_earned.insert(u.id.to_string());
self.flash_purchase(idx, 1, PurchaseSlot::Upgrade);
true
}
}
#[derive(Clone, Copy)]
enum PurchaseSlot {
Fingerer,
Upgrade,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::modifier::{Modifier, ModifierEffect, ModifierSource};
fn fs_with_count(count: u32) -> FingererState {
FingererState {
count,
..Default::default()
}
}
#[test]
fn migrate_is_idempotent_on_current_shape() {
let state = GameState {
fingerers_state: [("index_finger".to_string(), fs_with_count(9))]
.into_iter()
.collect(),
upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
achievements_earned: ["first_finger".to_string()].into_iter().collect(),
..GameState::default()
};
let m = state.migrate_runtime();
assert_eq!(m.fingerer_count("index_finger"), 9);
assert!(m.has_upgrade("click_mult_1"));
assert!(m.has_achievement("first_finger"));
}
#[test]
fn unknown_ids_in_save_are_ignored_not_resurrected() {
let state = GameState {
fingerers_state: [("giga_finger_from_the_future".to_string(), fs_with_count(42))]
.into_iter()
.collect(),
..GameState::default()
};
let m = state.migrate_runtime();
assert_eq!(m.fingerer_count("giga_finger_from_the_future"), 42);
assert_eq!(m.fingerer_count("index_finger"), 0);
assert!(!m.has_upgrade("click_mult_1"));
}
#[test]
fn save_roundtrip_is_stable_through_json() {
let state = GameState {
cuques: 1234.5,
total_clicks: 99,
fingerers_state: [("index_finger".to_string(), fs_with_count(7))]
.into_iter()
.collect(),
upgrades_earned: ["click_mult_1".to_string()].into_iter().collect(),
achievements_earned: ["first_finger".to_string()].into_iter().collect(),
..GameState::default()
};
let json = serde_json::to_string(&state).expect("serialize");
let roundtripped: GameState = serde_json::from_str(&json).expect("deserialize");
let m = roundtripped.migrate_runtime();
assert_eq!(m.cuques, 1234.5);
assert_eq!(m.total_clicks, 99);
assert_eq!(m.fingerer_count("index_finger"), 7);
assert!(m.has_upgrade("click_mult_1"));
assert!(m.has_achievement("first_finger"));
}
fn r(x: u16, y: u16, w: u16, h: u16) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn frac_screen_roundtrip_at_corners() {
let biscuit = r(10, 5, 40, 20);
let (fx, fy) = screen_to_biscuit_frac(10, 5, biscuit);
assert!(fx <= 0.001 && fy <= 0.001);
let (col, row) = biscuit_frac_to_screen(fx, fy, biscuit);
assert_eq!((col, row), (10, 5));
let (fx, fy) = screen_to_biscuit_frac(50, 25, biscuit);
assert!(fx >= 0.999 && fy >= 0.999);
let (col, row) = biscuit_frac_to_screen(0.5, 0.5, biscuit);
assert_eq!(col, 30);
assert_eq!(row, 15);
}
#[test]
fn frac_position_survives_biscuit_move() {
let small = r(0, 0, 40, 20);
let (col_a, row_a) = biscuit_frac_to_screen(0.25, 0.5, small);
let large = r(10, 5, 80, 40);
let (col_b, row_b) = biscuit_frac_to_screen(0.25, 0.5, large);
assert_ne!((col_a, row_a), (col_b, row_b));
assert_eq!(col_b, 30); assert_eq!(row_b, 25); }
#[test]
fn zero_size_biscuit_doesnt_panic() {
let zero = r(0, 0, 0, 0);
let (fx, fy) = screen_to_biscuit_frac(5, 5, zero);
assert_eq!((fx, fy), (0.5, 0.5));
let (col, row) = biscuit_frac_to_screen(0.5, 0.5, zero);
assert_eq!((col, row), (0, 0));
}
#[test]
fn buy_when_broke_sets_unaffordable_flash() {
let mut s = GameState::default();
let bought = s.buy(0);
assert!(!bought);
assert!(
s.fingerer_unaffordable_flash[0] > 0,
"buy(0) on broke state must flash red"
);
assert!(
s.fingerer_flash_ticks[0] == 0,
"no purchase flash on reject"
);
}
#[test]
fn buy_n_when_broke_sets_unaffordable_flash() {
let mut s = GameState::default();
let bought = s.buy_n(0, 10);
assert_eq!(bought, 0);
assert!(s.fingerer_unaffordable_flash[0] > 0);
}
#[test]
fn bulk_buy_scales_purchase_flash_strength() {
let mut s = GameState {
cuques: 1_000_000.0,
displayed_cuques: 1_000_000.0,
..Default::default()
};
s.buy(0);
let single = s.purchase_flash_strength;
assert!((1.0..=3.0).contains(&single));
let mut s = GameState {
cuques: 1_000_000.0,
displayed_cuques: 1_000_000.0,
..Default::default()
};
s.buy_n(0, 50);
let bulk = s.purchase_flash_strength;
assert!(
bulk > single,
"bulk strength must exceed single ({bulk} vs {single})"
);
assert!(bulk <= 3.0, "bulk strength capped at 3.0");
}
#[test]
fn buy_upgrade_when_broke_sets_unaffordable_flash() {
let mut s = GameState::default();
let cheapest_idx = (0..UPGRADES.len())
.min_by(|&a, &b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
.unwrap();
let bought = s.buy_upgrade(cheapest_idx);
assert!(!bought);
assert!(s.upgrade_unaffordable_flash[cheapest_idx] > 0);
}
#[test]
fn migrate_resizes_per_catalog_flash_vecs() {
let json = serde_json::to_string(&GameState::default()).unwrap();
let mut s: GameState = serde_json::from_str(&json).unwrap();
s.fingerer_flash_ticks.clear();
s.upgrade_flash_ticks.clear();
s.fingerer_unaffordable_flash.clear();
s.upgrade_unaffordable_flash.clear();
let m = s.migrate_runtime();
assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
assert_eq!(m.upgrade_flash_ticks.len(), UPGRADES.len());
assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
assert_eq!(m.upgrade_unaffordable_flash.len(), UPGRADES.len());
}
#[test]
fn migrate_seeds_displayed_counters() {
let s = GameState {
cuques: 5_000.0,
..Default::default()
};
let m = s.migrate_runtime();
assert_eq!(m.displayed_cuques, 5_000.0);
assert_eq!(m.displayed_fps, 0.0);
}
#[test]
fn unlock_pop_sets_active_toast_and_gold_flash() {
let mut s = GameState::default();
let biscuit = r(0, 0, 40, 20);
s.click((20, 10), biscuit);
s.tick();
assert!(s.active_unlock_id.is_some());
assert!(s.active_unlock_ticks > 0);
assert!(s.achievement_flash_ticks > 0);
}
fn perm_add_percent(pct: f64) -> Modifier {
Modifier {
source: ModifierSource::GreenCoin,
effects: vec![ModifierEffect::AddPercent(pct)],
duration: ModifierDuration::Permanent,
created_at_tick: 0,
}
}
fn timed_mul(mult: f64, ticks: u32) -> Modifier {
Modifier {
source: ModifierSource::PurpleCoin,
effects: vec![ModifierEffect::MulFactor(mult)],
duration: ModifierDuration::Ticks(ticks),
created_at_tick: 0,
}
}
#[test]
fn attach_modifier_rebuilds_aggregate() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
s.attach_modifier("index_finger", perm_add_percent(0.10));
let agg = s.fingerer_aggregate("index_finger");
assert!((agg.add_percent - 0.10).abs() < 1e-9);
s.attach_modifier("index_finger", perm_add_percent(0.10));
let agg = s.fingerer_aggregate("index_finger");
assert!((agg.add_percent - 0.20).abs() < 1e-9);
}
#[test]
fn attach_modifier_creates_state_entry_if_absent() {
let mut s = GameState::default();
s.attach_modifier("hand_of_god", perm_add_percent(0.10));
let st = s.fingerers_state.get("hand_of_god").expect("entry exists");
assert_eq!(st.count, 0);
assert_eq!(st.modifiers.len(), 1);
}
#[test]
fn attach_modifier_random_owned_picks_only_owned() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(5));
s.fingerers_state
.insert("hand_of_god".into(), fs_with_count(0));
let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
assert_eq!(chosen.as_deref(), Some("index_finger"));
}
#[test]
fn attach_modifier_random_owned_returns_none_when_nothing_owned() {
let mut s = GameState::default();
let chosen = s.attach_modifier_random_owned(perm_add_percent(0.10));
assert!(chosen.is_none());
assert!(s.fingerers_state.is_empty());
}
#[test]
fn tick_decrements_timed_modifiers() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
s.attach_modifier("index_finger", timed_mul(2.0, 5));
s.tick();
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 1);
assert!(matches!(
st.modifiers[0].duration,
ModifierDuration::Ticks(4)
));
}
#[test]
fn tick_removes_expired_and_rebuilds_aggregate() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
s.attach_modifier("index_finger", timed_mul(2.0, 1));
s.tick();
assert_eq!(
s.fingerers_state
.get("index_finger")
.unwrap()
.modifiers
.len(),
1
);
s.tick();
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 0);
assert!((st.aggregate.mul_factor - 1.0).abs() < 1e-9);
}
#[test]
fn permanent_modifier_does_not_decrement() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
s.attach_modifier("index_finger", perm_add_percent(0.10));
for _ in 0..50 {
s.tick();
}
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 1);
assert!(matches!(
st.modifiers[0].duration,
ModifierDuration::Permanent
));
assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
}
#[test]
fn prestige_reset_clears_modifiers() {
let mut s = GameState {
lifetime_cuques: 1_000_000_000.0,
..Default::default()
};
s.fingerers_state
.insert("index_finger".into(), fs_with_count(5));
s.attach_modifier("index_finger", perm_add_percent(0.30));
assert!(s.prestige_reset());
assert!(s.fingerers_state.is_empty());
}
#[test]
fn fps_uses_aggregate_add_percent() {
let mut bare = GameState::default();
bare.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
let bare_fps = bare.fps();
let mut boosted = GameState::default();
boosted
.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
boosted.attach_modifier("index_finger", perm_add_percent(0.10));
let boosted_fps = boosted.fps();
assert!(bare_fps > 0.0);
assert!((boosted_fps - bare_fps * 1.10).abs() < 1e-9);
}
#[test]
fn migrate_runtime_rebuilds_aggregate_after_serde_skip() {
let mut s = GameState::default();
s.fingerers_state.insert(
"index_finger".into(),
FingererState {
count: 1,
modifiers: vec![perm_add_percent(0.25)],
aggregate: FingererAggregate::default(), },
);
let m = s.migrate_runtime();
let agg = m.fingerer_aggregate("index_finger");
assert!((agg.add_percent - 0.25).abs() < 1e-9);
}
use crate::game::green_coin::{GREEN_COIN_LIFE_TICKS, GreenCoin};
fn fake_green_coin() -> GreenCoin {
GreenCoin {
frac_x: 0.5,
frac_y: 0.5,
life_ticks: GREEN_COIN_LIFE_TICKS,
}
}
#[test]
fn catch_green_coin_increments_grand_total_and_per_variant_counter() {
let mut s = GameState {
green_coin: Some(fake_green_coin()),
..Default::default()
};
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
assert!(s.catch_green_coin());
assert_eq!(s.golden_caught, 1, "rollup increments");
assert_eq!(s.green_coin_caught, 1, "per-variant increments");
assert_eq!(s.lucky_caught, 0);
assert_eq!(s.frenzy_caught, 0);
assert_eq!(s.buff_caught, 0);
}
#[test]
fn catch_green_coin_attaches_permanent_modifier() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(3));
s.green_coin = Some(fake_green_coin());
let caught = s.catch_green_coin();
assert!(caught);
assert!(s.green_coin.is_none());
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 1);
let m = &st.modifiers[0];
assert!(matches!(m.source, ModifierSource::GreenCoin));
assert!(matches!(m.duration, ModifierDuration::Permanent));
assert!(matches!(
m.effects[0],
ModifierEffect::AddPercent(v) if (v - 0.10).abs() < 1e-9
));
assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
}
#[test]
fn catch_green_coin_with_no_owned_lands_on_index_finger() {
let mut s = GameState {
green_coin: Some(fake_green_coin()),
..Default::default()
};
let caught = s.catch_green_coin();
assert!(caught);
assert!(s.green_coin.is_none());
let st = s
.fingerers_state
.get(FINGERERS[0].id)
.expect("modifier landed on Index Finger");
assert_eq!(st.modifiers.len(), 1);
assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
}
#[test]
fn attach_modifier_random_visible_can_pick_unowned_when_lifetime_unlocks_it() {
let mut s = GameState {
lifetime_cuques: 60.0,
..Default::default()
};
let m = perm_add_percent(0.10);
let chosen = s.attach_modifier_random_visible(m);
let id = chosen.expect("at least one visible fingerer always exists");
let visible_ids: Vec<&str> = FINGERERS
.iter()
.enumerate()
.filter(|(idx, f)| {
fingerer::visible(*idx, 0, s.lifetime_cuques) && (*idx == 0 || f.id == "whole_hand")
})
.map(|(_, f)| f.id)
.collect();
assert!(visible_ids.contains(&id.as_str()));
}
#[test]
fn catch_green_coin_returns_false_when_no_coin() {
let mut s = GameState::default();
assert!(!s.catch_green_coin());
}
#[test]
fn tick_green_coin_decrements_lifetime_and_clears_at_zero() {
let mut s = GameState {
green_coin: Some(GreenCoin {
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 2,
}),
..Default::default()
};
s.tick_green_coin();
assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 1);
s.tick_green_coin();
assert_eq!(s.green_coin.as_ref().unwrap().life_ticks, 0);
s.tick_green_coin();
assert!(s.green_coin.is_none());
}
#[test]
fn green_coin_stacks_additively_on_repeat_catches() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
for _ in 0..2 {
s.green_coin = Some(fake_green_coin());
s.catch_green_coin();
}
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 2);
assert!((st.aggregate.add_percent - 0.20).abs() < 1e-9);
}
#[test]
fn prestige_reset_clears_green_coin_state() {
let mut s = GameState {
lifetime_cuques: 1_000_000_000.0,
..Default::default()
};
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
s.goldens_since_green_coin = 7;
s.green_coin = Some(fake_green_coin());
s.prestige_reset();
assert!(s.green_coin.is_none());
assert_eq!(s.goldens_since_green_coin, 0);
}
}