use std::collections::{HashMap, HashSet};
use rand::RngExt;
use ratatui::layout::Rect;
use serde::{Deserialize, Serialize};
use crate::bignum::Mag;
use crate::game::achievement::ACHIEVEMENTS;
use crate::game::fingerer::{self, FINGERERS};
use crate::game::modifier::{
FingererAggregate, Modifier, ModifierDuration, ModifierEffect, ModifierSource,
};
use crate::game::powerup::{self, N_KINDS, Powerup, PowerupKind};
use crate::game::tree::aggregate::TreeAggregate;
use crate::game::tree::coord::TreeCoord;
use crate::game::tree::node::{self, NodeSpec};
use crate::game::tree::state::UpgradeTreeState;
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; pub const EDGE_UNLOCK_CELLS_PER_TICK: u32 = 2;
#[derive(Clone, Copy, Debug)]
pub struct EdgeUnlockAnim {
pub from: TreeCoord,
pub to: TreeCoord,
pub ticks: u32,
pub gates_destination: bool,
}
impl EdgeUnlockAnim {
pub fn visible_advance(&self) -> usize {
self.ticks.saturating_mul(EDGE_UNLOCK_CELLS_PER_TICK) as usize
}
}
use node::{count_leading_in_rect, count_trailing_in_rect};
const PARTICLE_FRAC_RISE: f32 = 0.006;
const GOLDEN_REWARD_SECONDS: f64 = 60.0;
const GOLDEN_REWARD_FLAT: f64 = 10.0;
const FRENZY_FPS_SECONDS_PER_CLICK: f64 = 5.0;
const FRENZY_FLAT_PER_CLICK: 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: Mag,
#[serde(default)]
pub total_clicks: u64,
#[serde(default)]
pub lifetime_cuques: Mag,
#[serde(default)]
pub best_fps: Mag,
#[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 prestige: u64,
#[serde(default)]
pub total_play_ticks: u64,
#[serde(default)]
pub buffs: Vec<Buff>,
#[serde(default)]
pub tree: UpgradeTreeState,
#[serde(skip)]
pub tree_aggregate: TreeAggregate,
#[serde(skip)]
pub tree_buy_flash: HashMap<TreeCoord, u32>,
#[serde(skip)]
pub tree_unlock_flash: HashMap<TreeCoord, u32>,
#[serde(skip)]
pub tree_refund_flash: HashMap<TreeCoord, u32>,
#[serde(skip)]
pub tree_edge_anims: Vec<EdgeUnlockAnim>,
#[serde(skip)]
pub clench_ticks: u32,
#[serde(skip)]
pub particles: Vec<Particle>,
#[serde(skip)]
pub misclick_particles: Vec<MisclickParticle>,
#[serde(skip)]
pub powerups: Vec<Powerup>,
#[serde(skip)]
pub next_spawn_id: u64,
#[serde(skip)]
pub powerup_cooldowns: [u32; N_KINDS],
#[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: Mag,
#[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 fingerer_unaffordable_flash: Vec<u32>,
#[serde(skip)]
pub fingerer_unlock_flash: Vec<u32>,
#[serde(skip)]
pub fingerer_green_coin_flash: Vec<u32>,
#[serde(skip)]
pub prev_fingerer_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: Mag,
#[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; pub const GREEN_COIN_ADD_PERCENT: f64 = 0.10;
pub const TREE_REFUND_FRACTION: f64 = 0.70;
fn default_save_version() -> u32 {
crate::save::CURRENT_VERSION
}
impl Default for GameState {
fn default() -> Self {
Self {
version: crate::save::CURRENT_VERSION,
cuques: Mag::ZERO,
total_clicks: 0,
lifetime_cuques: Mag::ZERO,
best_fps: Mag::ZERO,
golden_caught: 0,
lucky_caught: 0,
frenzy_caught: 0,
buff_caught: 0,
green_coin_caught: 0,
fingerers_state: HashMap::new(),
achievements_earned: HashSet::new(),
prestige: 0,
total_play_ticks: 0,
buffs: Vec::new(),
tree: {
let mut t = UpgradeTreeState::default();
t.bought.insert(TreeCoord::ORIGIN);
t
},
tree_aggregate: TreeAggregate::default(),
tree_buy_flash: HashMap::new(),
tree_unlock_flash: HashMap::new(),
tree_refund_flash: HashMap::new(),
tree_edge_anims: Vec::new(),
clench_ticks: 0,
particles: Vec::new(),
misclick_particles: Vec::new(),
powerups: Vec::new(),
next_spawn_id: 0,
powerup_cooldowns: {
let mut cds = [0u32; N_KINDS];
for kind in PowerupKind::ALL {
cds[kind as usize] = powerup::next_cooldown(kind);
}
cds
},
session_ticks: 0,
newly_unlocked: Vec::new(),
active_unlock_id: None,
active_unlock_ticks: 0,
visual_debt: Mag::ZERO,
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()],
fingerer_unaffordable_flash: vec![0; fingerer::count()],
fingerer_unlock_flash: vec![0; fingerer::count()],
fingerer_green_coin_flash: vec![0; fingerer::count()],
prev_fingerer_affordable: vec![false; fingerer::count()],
space_pressed_this_tick: false,
ticks_since_last_press: u32::MAX,
space_hold_ticks: 0,
displayed_cuques: Mag::ZERO,
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);
}
self.tree.bought.insert(TreeCoord::ORIGIN);
self.tree_aggregate.rebuild_from_bought(&self.tree.bought);
if self.fingerer_flash_ticks.len() != fingerer::count() {
self.fingerer_flash_ticks = vec![0; fingerer::count()];
}
if self.fingerer_unaffordable_flash.len() != fingerer::count() {
self.fingerer_unaffordable_flash = vec![0; fingerer::count()];
}
if self.fingerer_unlock_flash.len() != fingerer::count() {
self.fingerer_unlock_flash = vec![0; fingerer::count()];
}
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();
}
for kind in PowerupKind::ALL {
let i = kind as usize;
if self.powerup_cooldowns[i] == 0 {
self.powerup_cooldowns[i] = powerup::next_cooldown(kind);
}
}
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.to_f64())
})
.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_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;
let power_threshold = Mag::from_f64(50.0);
if power >= power_threshold {
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 >= power_threshold || frenzy_active {
ParticleKind::ClickBig
} else {
ParticleKind::Click
};
self.particles.push(Particle {
frac_x,
frac_y,
life: PARTICLE_LIFE,
text: format!("+{}", crate::format::big_mag(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) -> Mag {
let t = &self.tree_aggregate;
let base_scalar = (1.0 + t.click_flat) * (1.0 + t.click_add);
let base = Mag::from_f64(base_scalar.max(0.0)).mul(t.click_mul);
let frenzy_active = self
.buffs
.iter()
.any(|b| matches!(b, Buff::ClickFrenzy { .. }));
if frenzy_active {
let fps = self.fps();
let scaled = fps.mul(Mag::from_f64(FRENZY_FPS_SECONDS_PER_CLICK));
let floor = Mag::from_f64(FRENZY_FLAT_PER_CLICK);
let bonus = if scaled > floor { scaled } else { floor };
base.add(bonus)
} else {
base
}
}
fn add_cuques(&mut self, amount: Mag) {
self.cuques = self.cuques.add(amount);
self.lifetime_cuques = self.lifetime_cuques.add(amount);
}
pub fn dev_add_cuques(&mut self, amount: f64) {
self.add_cuques(Mag::from_f64(amount));
self.cuques_flash_ticks = HUD_FLASH_TICKS;
}
pub fn mint_spawn_id(&mut self) -> u64 {
let id = self.next_spawn_id;
self.next_spawn_id = self.next_spawn_id.wrapping_add(1);
id
}
pub fn catch_powerup(&mut self, spawn_id: u64) -> Mag {
let Some(idx) = self.powerups.iter().position(|p| p.spawn_id == spawn_id) else {
return Mag::ZERO;
};
let p = self.powerups.swap_remove(idx);
self.golden_caught += 1;
let (reward, label) = match p.kind {
PowerupKind::Lucky => {
self.lucky_caught += 1;
let fps = self.fps();
let reward_mul = self
.tree_aggregate
.powerup_reward_mul
.get(PowerupKind::Lucky as usize)
.copied()
.unwrap_or(Mag::ONE);
let secs = Mag::from_f64(GOLDEN_REWARD_SECONDS);
let flat = Mag::from_f64(GOLDEN_REWARD_FLAT);
let scaled = fps.mul(secs);
let big = if scaled > flat { scaled } else { flat };
let r = big.mul(reward_mul);
self.add_cuques(r);
self.lucky_flash_ticks = LUCKY_FLASH_TICKS;
self.cuques_flash_ticks = HUD_FLASH_TICKS;
(r, format!("+{}", crate::format::big_mag(r)))
}
PowerupKind::Frenzy => {
self.frenzy_caught += 1;
let duration_mul = self
.tree_aggregate
.powerup_duration_mul
.get(PowerupKind::Frenzy as usize)
.copied()
.unwrap_or(Mag::ONE);
let raw = (TICK_HZ * 13) as f64 * duration_mul.to_f64();
let dur = raw.round().clamp(0.0, u32::MAX as f64) as u32;
self.buffs.push(Buff::ClickFrenzy {
ticks_remaining: dur,
initial_ticks: dur,
mult: 777.0,
});
(Mag::ZERO, "FRENZY!".into())
}
PowerupKind::Buff => {
self.buff_caught += 1;
let duration_mul = self
.tree_aggregate
.powerup_duration_mul
.get(PowerupKind::Buff as usize)
.copied()
.unwrap_or(Mag::ONE);
let reward_mul = self
.tree_aggregate
.powerup_reward_mul
.get(PowerupKind::Buff as usize)
.copied()
.unwrap_or(Mag::ONE);
let raw_dur = (TICK_HZ * 60) as f64 * duration_mul.to_f64();
let dur = raw_dur.round().clamp(0.0, u32::MAX as f64) as u32;
let mul_factor = Mag::from_f64(7.0).mul(reward_mul);
let m = Modifier {
source: ModifierSource::PurpleCoin,
effects: vec![ModifierEffect::MulFactor(mul_factor)],
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);
}
(Mag::ZERO, "BOOSTED x7!".into())
}
PowerupKind::GreenCoin => {
self.green_coin_caught += 1;
self.green_coin_flash_ticks = GREEN_COIN_FLASH_TICKS;
let strength_mul = self.tree_aggregate.green_coin_strength_mul.to_f64();
let strength = GREEN_COIN_ADD_PERCENT * strength_mul;
let m = Modifier {
source: ModifierSource::GreenCoin,
effects: vec![ModifierEffect::AddPercent(strength)],
duration: ModifierDuration::Permanent,
created_at_tick: self.total_play_ticks,
};
let chosen = self.attach_modifier_random_visible(m);
debug_assert!(
chosen.is_some(),
"Green Coin catch found no visible fingerer — Index Finger should always be visible"
);
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(),
};
(Mag::ZERO, label)
}
};
self.particles.push(Particle {
frac_x: p.frac_x,
frac_y: p.frac_y,
life: PARTICLE_LIFE * 2,
text: label,
kind: ParticleKind::Golden,
drift_x: 0.0,
});
reward
}
pub fn fps(&self) -> Mag {
let mut total = Mag::ZERO;
for (i, k) in FINGERERS.iter().enumerate() {
let count = self.fingerer_count(k.id) as f64;
let mod_agg = self.fingerer_aggregate(k.id);
let tree = self.tree_aggregate.effective_for_fingerer(i);
let flat_total = mod_agg.flat_fps + tree.flat_fps;
let add_total = mod_agg.add_percent + tree.add_percent;
let mul_total = mod_agg.mul_factor.mul(tree.mul_factor);
let pre_scalar = (k.fps_per_unit * count + flat_total) * (1.0 + add_total);
let pre_scalar = pre_scalar.max(0.0);
total = total.add(Mag::from_f64(pre_scalar).mul(mul_total));
}
total.mul(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) -> Mag {
let base = 1.0 + 0.01 * self.prestige as f64;
let t = &self.tree_aggregate;
Mag::from_f64((base + t.prestige_add).max(0.0)).mul(t.prestige_mul)
}
pub fn prestige_earned_total(&self) -> u64 {
if self.lifetime_cuques.log10 < 6.0 {
return 0;
}
if self.lifetime_cuques.log10 < 300.0 {
let lifetime = self.lifetime_cuques.to_f64();
let raw = (lifetime / 1_000_000.0).sqrt();
if !raw.is_finite() || raw < 0.0 {
return 0;
}
return (raw + 1e-9).floor() as u64;
}
let earned_log10 = (self.lifetime_cuques.log10 - 6.0) * 0.5;
Mag {
log10: earned_log10,
}
.floor_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 = Mag::ZERO;
self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
self.fingerers_state.clear();
self.tree.bought.clear();
self.tree.bought.insert(TreeCoord::ORIGIN);
self.tree.cursor = TreeCoord::ORIGIN;
self.tree.last_bought = None;
self.tree_aggregate.reset();
self.tree_buy_flash.clear();
self.tree_unlock_flash.clear();
self.tree_refund_flash.clear();
self.tree_edge_anims.clear();
self.buffs.clear();
self.visual_debt = Mag::ZERO;
self.particles.clear();
self.misclick_particles.clear();
self.powerups.clear();
self.next_spawn_id = 0;
self.clench_ticks = 0;
for kind in PowerupKind::ALL {
self.powerup_cooldowns[kind as usize] = powerup::next_cooldown(kind);
}
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.fingerer_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.fingerer_green_coin_flash.iter_mut() {
*t = t.saturating_sub(1);
}
self.tree_buy_flash.retain(|_, t| {
*t = t.saturating_sub(1);
*t > 0
});
self.tree_unlock_flash.retain(|_, t| {
*t = t.saturating_sub(1);
*t > 0
});
self.tree_refund_flash.retain(|_, t| {
*t = t.saturating_sub(1);
*t > 0
});
let mut just_unlocked: Vec<TreeCoord> = Vec::new();
self.tree_edge_anims
.retain(|a| !node::edge_path_cells(a.from, a.to).is_empty());
for anim in &mut self.tree_edge_anims {
anim.ticks = anim.ticks.saturating_add(1);
}
self.tree_edge_anims.retain(|a| {
let path = node::edge_path_cells(a.from, a.to);
if path.is_empty() {
return false;
}
let from_at_start = (a.from.x, a.from.y) <= (a.to.x, a.to.y);
let Some(from_spec) = node::node_at(a.from.x, a.from.y) else {
return false;
};
let Some(to_spec) = node::node_at(a.to.x, a.to.y) else {
return false;
};
let (source_leading, dest_trailing) = if from_at_start {
(
count_leading_in_rect(
&path,
from_spec.box_x,
from_spec.box_y,
from_spec.box_w,
from_spec.box_h,
),
count_trailing_in_rect(
&path,
to_spec.box_x,
to_spec.box_y,
to_spec.box_w,
to_spec.box_h,
),
)
} else {
(
count_trailing_in_rect(
&path,
from_spec.box_x,
from_spec.box_y,
from_spec.box_w,
from_spec.box_h,
),
count_leading_in_rect(
&path,
to_spec.box_x,
to_spec.box_y,
to_spec.box_w,
to_spec.box_h,
),
)
};
let visible_len = path
.len()
.saturating_sub(source_leading)
.saturating_sub(dest_trailing);
if a.visible_advance() >= visible_len {
if a.gates_destination {
just_unlocked.push(a.to);
}
false
} else {
true
}
});
for to in just_unlocked {
self.tree_unlock_flash.insert(to, UNLOCK_FLASH_TICKS);
}
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.mul(Mag::from_f64(TICK_DT));
self.add_cuques(gained);
self.visual_debt = self.visual_debt.add(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();
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;
}
}
const SNAP_BELOW: f64 = 5.0;
let tween = 0.18_f64;
let cuques_log = self.cuques.log10;
let disp_log = self.displayed_cuques.log10;
if cuques_log > 15.0 || disp_log > 15.0 {
self.displayed_cuques = self.cuques;
} else {
let dc = self.cuques.to_f64() - self.displayed_cuques.to_f64();
if dc.abs() < SNAP_BELOW {
self.displayed_cuques = self.cuques;
} else {
self.displayed_cuques = Mag::from_f64(self.displayed_cuques.to_f64() + dc * tween);
}
}
let fps_f64 = fps.to_f64();
if fps.log10 > 15.0 {
self.displayed_fps = fps_f64;
} else {
let df = fps_f64 - self.displayed_fps;
if df.abs() < SNAP_BELOW {
self.displayed_fps = fps_f64;
} 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_powerups(&mut self) {
self.powerups.retain_mut(|p| {
if p.life_ticks == 0 {
false
} else {
p.life_ticks -= 1;
true
}
});
for cd in self.powerup_cooldowns.iter_mut() {
*cd = cd.saturating_sub(1);
}
}
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) {
if self.visual_debt.log10 < 0.0 {
return;
}
let amount = self.visual_debt;
self.visual_debt = Mag::ZERO;
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_mag(amount)),
kind: ParticleKind::Auto,
drift_x,
});
}
pub fn cost(&self, idx: usize) -> Mag {
let k = &FINGERERS[idx];
let n = self.fingerer_count_idx(idx) as f64;
let log_raw = k.base_cost.log10() + n * k.cost_scale.log10();
let raw = Mag { log10: log_raw };
let cost_mul = self
.tree_aggregate
.per_fingerer
.get(idx)
.map(|c| c.cost_mul)
.unwrap_or(Mag::ONE);
let combined = raw.mul(cost_mul);
if combined.log10 < 18.0 {
Mag::from_f64(combined.to_f64().floor().max(1.0))
} else {
combined
}
}
pub fn affordable_cuques(&self) -> Mag {
let disp_floor = if self.displayed_cuques.log10 < 18.0 {
Mag::from_f64(self.displayed_cuques.to_f64().floor())
} else {
self.displayed_cuques
};
if self.cuques < disp_floor {
self.cuques
} else {
disp_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 = self.cuques.saturating_sub(c);
self.fingerers_state
.entry(f.id.to_string())
.or_default()
.count += 1;
true
} else {
false
}
}
fn flash_purchase_fingerer(&mut self, idx: usize, bought: u32) {
if bought == 0 {
return;
}
let strength = (1.0 + ((bought as f32) / 10.0).sqrt()).clamp(1.0, 3.0);
self.trigger_purchase_flash(strength);
if let Some(slot) = self.fingerer_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;
}
}
pub fn buy(&mut self, idx: usize) -> bool {
if self.buy_one_quiet(idx) {
self.flash_purchase_fingerer(idx, 1);
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_fingerer(idx, bought);
}
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_fingerer(idx, bought);
}
bought
}
pub fn tree_reachable(&self, lot: TreeCoord) -> bool {
if lot == TreeCoord::ORIGIN {
return true;
}
for n in node::neighbors_of(lot) {
if self.tree.bought.contains(&n) && node::edge_exists(lot, n) {
return true;
}
}
false
}
pub fn tree_unlock_pending(&self, lot: TreeCoord) -> bool {
self.tree_edge_anims
.iter()
.any(|a| a.to == lot && a.gates_destination)
}
pub fn can_buy_tree_node(&self, lot: TreeCoord) -> bool {
if self.tree.bought.contains(&lot) {
return false;
}
let Some(node) = node::node_at(lot.x, lot.y) else {
return false;
};
if !self.tree_reachable(lot) {
return false;
}
if self.tree_unlock_pending(lot) {
return false;
}
self.affordable_cuques() >= node.cost
}
pub fn buy_tree_node(&mut self, lot: TreeCoord) -> Option<NodeSpec> {
let node = node::node_at(lot.x, lot.y)?;
if self.tree.bought.contains(&lot) {
return None;
}
if !self.tree_reachable(lot) {
return None;
}
if self.tree_unlock_pending(lot) {
return None;
}
if self.affordable_cuques() < node.cost {
return None;
}
let neighbors = node::neighbors_of(lot);
let was_reachable: [bool; 8] = std::array::from_fn(|i| self.tree_reachable(neighbors[i]));
self.cuques = self.cuques.saturating_sub(node.cost);
self.tree.bought.insert(lot);
self.tree.last_bought = Some(lot);
self.tree_aggregate.fold_in_node(&node);
self.trigger_purchase_flash(1.5);
self.cuques_spend_flash_ticks = HUD_FLASH_TICKS;
self.tree_buy_flash.insert(lot, PURCHASE_FLASH_TICKS);
for (i, n) in neighbors.into_iter().enumerate() {
if self.tree.bought.contains(&n) {
continue;
}
if node::node_at(n.x, n.y).is_none() {
continue;
}
if !node::edge_exists(lot, n) {
continue;
}
self.tree_edge_anims.push(EdgeUnlockAnim {
from: lot,
to: n,
ticks: 0,
gates_destination: !was_reachable[i],
});
}
Some(node)
}
pub fn can_refund_tree_node(&self, lot: TreeCoord) -> bool {
if !self.tree.bought.contains(&lot) {
return false;
}
if lot == TreeCoord::ORIGIN {
return false;
}
if self.tree.bought.len() <= 1 {
return true;
}
let mut seen: HashSet<TreeCoord> = HashSet::new();
let mut stack: Vec<TreeCoord> = vec![TreeCoord::ORIGIN];
seen.insert(TreeCoord::ORIGIN);
while let Some(c) = stack.pop() {
for n in node::neighbors_of(c) {
if n == lot {
continue;
}
if !self.tree.bought.contains(&n) {
continue;
}
if seen.contains(&n) {
continue;
}
if !node::edge_exists(c, n) {
continue;
}
seen.insert(n);
stack.push(n);
}
}
for owned in &self.tree.bought {
if *owned == lot {
continue;
}
if !seen.contains(owned) {
return false;
}
}
true
}
pub fn refund_tree_node(&mut self, lot: TreeCoord) -> Mag {
if !self.can_refund_tree_node(lot) {
return Mag::ZERO;
}
let Some(node) = node::node_at(lot.x, lot.y) else {
self.tree.bought.remove(&lot);
if self.tree.last_bought == Some(lot) {
self.tree.last_bought = None;
}
return Mag::ZERO;
};
self.tree.bought.remove(&lot);
if self.tree.last_bought == Some(lot) {
self.tree.last_bought = None;
}
self.tree_aggregate.fold_out_node(&node);
let refunded = node.cost.mul(Mag::from_f64(TREE_REFUND_FRACTION));
self.cuques = self.cuques.add(refunded);
self.tree_refund_flash.insert(lot, PURCHASE_FLASH_TICKS);
self.cuques_flash_ticks = HUD_FLASH_TICKS;
refunded
}
}
#[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 mut state = GameState {
fingerers_state: [("index_finger".to_string(), fs_with_count(9))]
.into_iter()
.collect(),
achievements_earned: ["first_finger".to_string()].into_iter().collect(),
..GameState::default()
};
state.tree.bought.insert(TreeCoord::ORIGIN);
let m = state.migrate_runtime();
assert_eq!(m.fingerer_count("index_finger"), 9);
assert!(m.tree.bought.contains(&TreeCoord::ORIGIN));
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);
}
#[test]
fn prestige_earned_matches_legacy_sqrt_at_tier_boundaries() {
let cases: &[(f64, u64)] = &[
(0.0, 0),
(999_999.0, 0), (1_000_000.0, 1), (4_000_000.0, 2), (25_000_000.0, 5), (81_000_000.0, 9), (1_000_000_000.0, 31), (10_000_000_000.0, 100), (1e18, 1_000_000), ];
for &(lifetime, expected) in cases {
let s = GameState {
lifetime_cuques: Mag::from_f64(lifetime),
..GameState::default()
};
assert_eq!(s.prestige_earned_total(), expected, "lifetime={lifetime}");
}
}
#[test]
fn save_roundtrip_is_stable_through_json() {
let mut state = GameState {
cuques: Mag::from_f64(1234.5),
total_clicks: 99,
fingerers_state: [("index_finger".to_string(), fs_with_count(7))]
.into_iter()
.collect(),
achievements_earned: ["first_finger".to_string()].into_iter().collect(),
..GameState::default()
};
state.tree.bought.insert(TreeCoord::new(2, -1));
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!((m.cuques.to_f64() - 1234.5).abs() < 1e-9);
assert_eq!(m.total_clicks, 99);
assert_eq!(m.fingerer_count("index_finger"), 7);
assert!(m.tree.bought.contains(&TreeCoord::new(2, -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: Mag::from_f64(1_000_000.0),
displayed_cuques: Mag::from_f64(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: Mag::from_f64(1_000_000.0),
displayed_cuques: Mag::from_f64(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 origin_is_auto_owned_on_default() {
let s = GameState::default();
assert!(s.tree.bought.contains(&TreeCoord::ORIGIN));
let spec = node::node_at(0, 0).expect("anchor always exists");
assert!(spec.is_anchor);
assert!(spec.primitives.is_empty());
assert_eq!(spec.cost, Mag::ZERO);
}
#[test]
fn buy_tree_node_at_origin_is_noop() {
let mut s = GameState {
cuques: Mag::from_f64(1_000_000.0),
displayed_cuques: Mag::from_f64(1_000_000.0),
..Default::default()
};
let pre = s.cuques;
let bought = s.buy_tree_node(TreeCoord::ORIGIN);
assert!(bought.is_none(), "origin already owned — buy returns None");
assert!(s.tree.bought.contains(&TreeCoord::ORIGIN));
assert_eq!(s.cuques, pre, "no cuques spent on a no-op buy");
}
#[test]
fn refund_origin_is_rejected() {
let mut s = GameState {
cuques: Mag::from_f64(1_000_000.0),
displayed_cuques: Mag::from_f64(1_000_000.0),
..Default::default()
};
let pre = s.cuques;
assert_eq!(s.refund_tree_node(TreeCoord::ORIGIN), Mag::ZERO);
assert!(s.tree.bought.contains(&TreeCoord::ORIGIN));
assert_eq!(s.cuques, pre, "no cuques returned on a refund-rejection");
}
#[test]
fn refund_returns_only_a_fraction_of_cost() {
let mut s = GameState {
cuques: Mag::from_f64(1_000_000.0),
displayed_cuques: Mag::from_f64(1_000_000.0),
..Default::default()
};
let pre = s.cuques;
let neighbor = node::neighbors_of(TreeCoord::ORIGIN)
.into_iter()
.find(|n| node::node_at(n.x, n.y).is_some() && node::edge_exists(TreeCoord::ORIGIN, *n))
.expect("at least one reachable neighbor in the procgen");
let n_node = s
.buy_tree_node(neighbor)
.expect("affordable with 1M cuques");
let after_buy = s.cuques;
let expected_after = pre.saturating_sub(n_node.cost);
assert!((after_buy.log10 - expected_after.log10).abs() < 1e-6);
let refunded = s.refund_tree_node(neighbor);
let expected = n_node.cost.mul(Mag::from_f64(TREE_REFUND_FRACTION));
assert!((refunded.log10 - expected.log10).abs() < 1e-6);
let after_refund = s.cuques;
assert!(after_refund > after_buy);
assert!(
after_refund < pre,
"refund must NOT restore full state — exploration tax must show up as a net loss"
);
}
#[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.fingerer_unaffordable_flash.clear();
let m = s.migrate_runtime();
assert_eq!(m.fingerer_flash_ticks.len(), fingerer::count());
assert_eq!(m.fingerer_unaffordable_flash.len(), fingerer::count());
}
#[test]
fn migrate_seeds_displayed_counters() {
let s = GameState {
cuques: Mag::from_f64(5_000.0),
..Default::default()
};
let m = s.migrate_runtime();
assert_eq!(m.displayed_cuques, Mag::from_f64(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(Mag::from_f64(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.to_f64() - 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: Mag::from_f64(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.is_zero());
let expected = bare_fps.mul(Mag::from_f64(1.10));
assert!((boosted_fps.log10 - expected.log10).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::powerup::{Powerup, PowerupKind};
fn fake_powerup(state: &mut GameState, kind: PowerupKind) -> u64 {
let id = state.mint_spawn_id();
state.powerups.push(Powerup {
kind,
spawn_id: id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: kind.lifetime_ticks(),
});
id
}
#[test]
fn catch_green_coin_increments_grand_total_and_per_variant_counter() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.catch_powerup(id);
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));
let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.catch_powerup(id);
assert!(s.powerups.is_empty());
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::default();
let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.catch_powerup(id);
assert!(s.powerups.is_empty());
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: Mag::from_f64(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.to_f64())
&& (*idx == 0 || f.id == "whole_hand")
})
.map(|(_, f)| f.id)
.collect();
assert!(visible_ids.contains(&id.as_str()));
}
#[test]
fn catch_powerup_returns_zero_when_id_unknown() {
let mut s = GameState::default();
assert_eq!(s.catch_powerup(9_999), Mag::ZERO);
}
#[test]
fn tick_powerups_decrements_lifetime_and_drops_at_zero() {
let mut s = GameState::default();
let id = s.mint_spawn_id();
s.powerups.push(Powerup {
kind: PowerupKind::GreenCoin,
spawn_id: id,
frac_x: 0.5,
frac_y: 0.5,
life_ticks: 2,
});
s.tick_powerups();
assert_eq!(s.powerups[0].life_ticks, 1);
s.tick_powerups();
assert_eq!(s.powerups[0].life_ticks, 0);
s.tick_powerups();
assert!(s.powerups.is_empty());
}
#[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 {
let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.catch_powerup(id);
}
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_powerup_state() {
let mut s = GameState {
lifetime_cuques: Mag::from_f64(1_000_000_000.0),
..Default::default()
};
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
let _ = fake_powerup(&mut s, PowerupKind::Lucky);
let _ = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.prestige_reset();
assert!(s.powerups.is_empty());
assert_eq!(s.next_spawn_id, 0);
}
#[test]
fn catch_powerup_only_removes_targeted_id() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
let lucky_id = fake_powerup(&mut s, PowerupKind::Lucky);
let frenzy_id = fake_powerup(&mut s, PowerupKind::Frenzy);
let buff_id = fake_powerup(&mut s, PowerupKind::Buff);
s.catch_powerup(frenzy_id);
let remaining: Vec<u64> = s.powerups.iter().map(|p| p.spawn_id).collect();
assert_eq!(remaining.len(), 2);
assert!(remaining.contains(&lucky_id));
assert!(remaining.contains(&buff_id));
}
#[test]
fn buff_stacks_multiplicatively_on_same_fingerer() {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
for _ in 0..2 {
let id = fake_powerup(&mut s, PowerupKind::Buff);
s.catch_powerup(id);
}
let st = s.fingerers_state.get("index_finger").unwrap();
assert_eq!(st.modifiers.len(), 2);
assert!((st.aggregate.mul_factor.to_f64() - 49.0).abs() < 1e-9);
}
#[test]
fn mint_spawn_id_is_monotonic() {
let mut s = GameState::default();
let a = s.mint_spawn_id();
let b = s.mint_spawn_id();
let c = s.mint_spawn_id();
assert_eq!(a, 0);
assert_eq!(b, 1);
assert_eq!(c, 2);
}
#[test]
fn green_coin_catch_always_has_a_target_on_fresh_state() {
let mut s = GameState::default();
let id = fake_powerup(&mut s, PowerupKind::GreenCoin);
s.catch_powerup(id);
let label = &s.particles.last().expect("catch spawns a particle").text;
assert!(
!label.contains("???"),
"GreenCoin catch produced unreachable '+10% ???' fallback: {label}"
);
assert!(
label.starts_with("+10% "),
"GreenCoin catch label must start with '+10% ', got {label}"
);
}
#[test]
fn frenzy_click_yield_is_bounded_in_early_game() {
let mut s = GameState::default();
let biscuit = r(0, 0, 40, 20);
s.buffs.push(Buff::ClickFrenzy {
ticks_remaining: TICK_HZ * 13,
initial_ticks: TICK_HZ * 13,
mult: 777.0,
});
let mut clicks = 0;
for _ in 0..(TICK_HZ * 13) {
if clicks * 5 < s.total_play_ticks as u32 + 5 {
s.click((20, 10), biscuit);
clicks += 1;
}
s.tick();
}
assert!(
s.cuques < Mag::from_f64(2_000.0),
"early-game Frenzy must not blow past ~1k cuques; got {}",
crate::format::big_mag(s.cuques)
);
assert!(
s.cuques > Mag::from_f64(clicks as f64),
"Frenzy should still meaningfully boost clicks; got {} from {} clicks",
crate::format::big_mag(s.cuques),
clicks
);
}
#[test]
fn frenzy_click_yield_scales_with_fps_late_game() {
let mut s = GameState::default();
s.fingerers_state
.insert("whole_hand".into(), fs_with_count(2000));
let fps = s.fps();
assert!(
fps > Mag::from_f64(100.0),
"test setup expected fps>100, got {} — adjust the count if fingerer base changed",
crate::format::big_mag(fps)
);
s.buffs.push(Buff::ClickFrenzy {
ticks_remaining: TICK_HZ * 13,
initial_ticks: TICK_HZ * 13,
mult: 777.0,
});
let cuques_before = s.cuques;
let biscuit = r(0, 0, 40, 20);
s.click((20, 10), biscuit);
let yield_per_click = s.cuques.saturating_sub(cuques_before);
let expected = fps
.mul(Mag::from_f64(FRENZY_FPS_SECONDS_PER_CLICK))
.add(Mag::ONE);
assert!(
(yield_per_click.log10 - expected.log10).abs() < 0.01,
"expected ~{}/click at fps={}, got {}",
crate::format::big_mag(expected),
crate::format::big_mag(fps),
crate::format::big_mag(yield_per_click)
);
}
#[test]
fn no_frenzy_means_no_bonus() {
let s = GameState::default();
assert_eq!(s.click_power(), Mag::ONE);
}
#[test]
fn catch_powerup_increments_grand_total_for_every_kind() {
for kind in PowerupKind::ALL {
let mut s = GameState::default();
s.fingerers_state
.insert("index_finger".into(), fs_with_count(1));
let id = fake_powerup(&mut s, kind);
let prior = s.golden_caught;
s.catch_powerup(id);
assert_eq!(
s.golden_caught,
prior + 1,
"{kind:?} catch must bump golden_caught"
);
}
}
}