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::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,
},
FingererBoost {
ticks_remaining: u32,
initial_ticks: u32,
fingerer_id: String,
mult: f64,
},
}
impl Buff {
pub fn ticks_remaining(&self) -> u32 {
match self {
Buff::ClickFrenzy {
ticks_remaining, ..
} => *ticks_remaining,
Buff::FingererBoost {
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);
}
Buff::FingererBoost {
ticks_remaining, ..
} => {
*ticks_remaining = ticks_remaining.saturating_sub(1);
}
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameState {
#[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 fingerers_owned: HashMap<String, u32>,
#[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(skip)]
pub clench_ticks: u32,
#[serde(skip)]
pub particles: Vec<Particle>,
#[serde(skip)]
pub misclick_particles: Vec<MisclickParticle>,
#[serde(skip)]
pub golden: Option<GoldenCuque>,
#[serde(skip)]
pub golden_cooldown: u32,
#[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 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 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;
impl Default for GameState {
fn default() -> Self {
Self {
cuques: 0.0,
total_clicks: 0,
lifetime_cuques: 0.0,
best_fps: 0.0,
golden_caught: 0,
fingerers_owned: HashMap::new(),
achievements_earned: HashSet::new(),
upgrades_earned: HashSet::new(),
prestige: 0,
total_play_ticks: 0,
buffs: Vec::new(),
clench_ticks: 0,
particles: Vec::new(),
misclick_particles: Vec::new(),
golden: None,
golden_cooldown: crate::game::golden::next_cooldown(),
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,
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()],
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(mut self) -> Self {
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.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();
}
if self.golden_cooldown == 0 {
self.golden_cooldown = 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_owned.get(id).copied().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_owned.values().sum()
}
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 {
if 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,
_ => {}
}
}
for b in &self.buffs {
if let Buff::FingererBoost {
fingerer_id, mult, ..
} = b
&& fingerer_id == target.id
{
m *= *mult;
}
}
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) -> f64 {
use crate::game::golden::GoldenVariant;
let Some(golden) = self.golden.take() else {
return 0.0;
};
self.golden_caught += 1;
self.golden_cooldown = crate::game::golden::next_cooldown();
let (reward, label) = match golden.variant {
GoldenVariant::Lucky => {
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 => {
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 => {
let active_ids: Vec<&'static str> = FINGERERS
.iter()
.filter(|f| self.fingerer_count(f.id) > 0)
.map(|f| f.id)
.collect();
let pick = if active_ids.is_empty() {
FINGERERS[0].id
} else {
use rand::RngExt;
active_ids[rand::rng().random_range(0..active_ids.len())]
};
let dur = TICK_HZ * 60;
self.buffs.push(Buff::FingererBoost {
ticks_remaining: dur,
initial_ticks: dur,
fingerer_id: pick.to_string(),
mult: 7.0,
});
(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)| k.fps_per_unit * self.fingerer_count(k.id) as f64 * self.fingerer_mult(i))
.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),
Buff::FingererBoost { .. } => 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_owned.clear();
self.upgrades_earned.clear();
self.buffs.clear();
self.visual_debt = 0.0;
self.particles.clear();
self.misclick_particles.clear();
self.golden = None;
self.clench_ticks = 0;
self.golden_cooldown = crate::game::golden::next_cooldown();
true
}
pub fn tick(&mut self) {
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.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);
}
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) {
if let Some(g) = self.golden.as_mut() {
if g.life_ticks == 0 {
self.golden = None;
self.golden_cooldown = crate::game::golden::next_cooldown();
} else {
g.life_ticks -= 1;
}
} else if self.golden_cooldown > 0 {
self.golden_cooldown -= 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) {
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_owned.entry(f.id.to_string()).or_insert(0) += 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::*;
#[test]
fn migrate_is_idempotent_on_current_shape() {
let state = GameState {
fingerers_owned: [("index_finger".to_string(), 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();
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_owned: [("giga_finger_from_the_future".to_string(), 42)]
.into_iter()
.collect(),
..GameState::default()
};
let m = state.migrate();
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_owned: [("index_finger".to_string(), 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();
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();
s.cuques = 0.0;
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();
s.cuques = 0.0;
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();
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();
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);
}
}