cuqueclicker_lib/game/modifier.rs
1//! Per-fingerer modifier system.
2//!
3//! A [`Modifier`] is a composable buff or debuff attached to a single
4//! fingerer's [`crate::game::state::FingererState`]. Each modifier carries
5//! a stable [`ModifierSource`] id, zero or more [`ModifierEffect`]s, and
6//! an optional duration in ticks. Modifiers stack freely — multiple of
7//! the same source on the same fingerer is fine and additive.
8//!
9//! ## Stacking semantics
10//!
11//! - [`ModifierEffect::FlatFps`] values across modifiers **sum**, applied
12//! before any percent.
13//! - [`ModifierEffect::AddPercent`] values across modifiers **sum**
14//! (two +10% Green Coins = +20%, not +21%).
15//! - [`ModifierEffect::MulFactor`] values across modifiers **multiply**
16//! (two x2 buffs = x4).
17//!
18//! Final fingerer output:
19//!
20//! ```text
21//! ((base * count + flat_fps) * (1 + add_percent) * mul_factor) * upgrades_mult
22//! ```
23//!
24//! ## Aggregate cache
25//!
26//! Hot-path reads (FPS calc, sidebar render) MUST go through
27//! [`FingererAggregate`], never iterate the `Vec<Modifier>` directly. The
28//! aggregate is rebuilt in three situations only:
29//! 1. A modifier is added or removed via the public API.
30//! 2. The per-tick walk drops an expired [`ModifierDuration::Ticks`]
31//! entry whose count just hit zero.
32//! 3. The save loader reconstructs it (the field is `#[serde(skip)]`).
33//!
34//! ## Adding a new buff/debuff source
35//!
36//! Add a variant to [`ModifierSource`] and map it in [`ModifierSource::id`].
37//! No tick-loop changes needed — the existing per-tick walk already
38//! decrements timed modifiers and rebuilds aggregates on expiry. The id
39//! string is load-bearing forever; treat it like a fingerer or upgrade id.
40
41use serde::{Deserialize, Serialize};
42
43use crate::bignum::Mag;
44
45/// Stable identifier for the *kind* of buff or debuff a modifier represents.
46/// Used for de-duping in the UI ("3× Green Coin" instead of three identical
47/// chips), for save serialization, and for future per-source rules.
48///
49/// **The string returned by [`Self::id`] is a load-bearing primary key** —
50/// renaming it silently invalidates every player's saved progress on that
51/// source. Treat it like a fingerer or upgrade id: cosmetic names live in
52/// `i18n.rs`; the id stays stable forever.
53#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
54pub enum ModifierSource {
55 /// Green Coin — rare powerup that attaches a permanent +10% AddPercent
56 /// to a random owned fingerer.
57 GreenCoin,
58 /// Purple Coin — the existing Buff Golden, recast as a per-fingerer
59 /// modifier with a finite duration. Phase 3 of the Green Coin PR
60 /// absorbs `Buff::FingererBoost` into this source.
61 PurpleCoin,
62}
63
64impl ModifierSource {
65 pub fn id(self) -> &'static str {
66 match self {
67 Self::GreenCoin => "green_coin",
68 Self::PurpleCoin => "purple_coin",
69 }
70 }
71}
72
73/// One contribution from a modifier. A single [`Modifier`] may carry
74/// multiple effects — e.g. a future debuff could combine
75/// `[FlatFps(-10.0), MulFactor(0.5)]`.
76///
77/// `MulFactor` carries a [`Mag`] (log-magnitude) so the catch-time
78/// product `7.0 × tree.powerup_reward_mul[Buff]` can grow truly
79/// unboundedly across late-game stacks without overflowing `f64`. The
80/// `Mag` (de)serializer accepts both raw JSON numbers (legacy V4 saves
81/// stored `"MulFactor": 7.0`) and the explicit `{"log10": …}` form (new
82/// huge values), so the change is wire-compatible — no save version
83/// bump.
84#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
85pub enum ModifierEffect {
86 /// Flat additive contribution to the fingerer's per-tier output.
87 /// Pre-multiplier: added to `base * count` before percent or mul.
88 FlatFps(f64),
89 /// Additive percent. `0.10` = +10%. Sums across all modifiers on the
90 /// fingerer (two +10% Green Coins = +20%).
91 AddPercent(f64),
92 /// Multiplicative factor. Multiplies across all modifiers on the
93 /// fingerer (two x2 = x4). Applied after [`Self::AddPercent`].
94 MulFactor(Mag),
95}
96
97/// Lifetime of a modifier. Permanent modifiers stick for the rest of the
98/// run (cleared only by `prestige_reset`). Timed modifiers decrement once
99/// per `state.tick()` and drop when they hit zero.
100#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
101pub enum ModifierDuration {
102 Permanent,
103 /// Remaining ticks. Decremented in the per-tick modifier walk; the
104 /// modifier is removed (and the aggregate rebuilt) on the tick it
105 /// would step from `Ticks(0)`.
106 Ticks(u32),
107}
108
109/// A single buff or debuff attached to a fingerer. Composable: a fingerer
110/// may carry an unbounded number of these. See module docs for the
111/// stacking rules and aggregate-cache contract.
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct Modifier {
114 pub source: ModifierSource,
115 /// Zero or more contributions. An empty Vec is valid (a sourced marker
116 /// with no numeric effect — e.g. a flag-style modifier read by future
117 /// UI without changing FPS).
118 pub effects: Vec<ModifierEffect>,
119 pub duration: ModifierDuration,
120 /// `total_play_ticks` value at the time this modifier was attached.
121 /// Drives "X ago" labels and tie-breaking. Recorded against the
122 /// monotonic play counter (not wall-clock) so it survives quit/restart
123 /// without becoming wrong.
124 #[serde(default)]
125 pub created_at_tick: u64,
126}
127
128impl Modifier {
129 /// Plateau-at-1.0 until the last `FADE_TICKS` of the duration, then
130 /// smoothstep-decay to 0. Mirrors `Buff::strength` so border / HUD
131 /// pulse code can blend timed modifiers into the same activity sum
132 /// without a special case. Permanent modifiers always read 1.0
133 /// (they don't fade).
134 pub fn strength(&self) -> f32 {
135 const FADE_TICKS: f32 = 30.0; // ~1.5s at 20Hz
136 match self.duration {
137 ModifierDuration::Permanent => 1.0,
138 ModifierDuration::Ticks(n) => {
139 let remaining = n as f32;
140 if remaining >= FADE_TICKS {
141 1.0
142 } else {
143 let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
144 t * t * (3.0 - 2.0 * t)
145 }
146 }
147 }
148 }
149}
150
151/// Pre-computed sum/product of every effect across every modifier on a
152/// fingerer. Read on every FPS calc — the tick path rebuilds this when
153/// modifiers are added, removed, or expire, so reads are O(1).
154///
155/// `Default` is the **identity**: zero flat, zero add-percent, x1 multiplier.
156/// This is the value reads see when no modifiers are attached.
157#[derive(Clone, Copy, Debug, PartialEq)]
158pub struct FingererAggregate {
159 pub flat_fps: f64,
160 pub add_percent: f64,
161 pub mul_factor: Mag,
162}
163
164impl Default for FingererAggregate {
165 fn default() -> Self {
166 Self {
167 flat_fps: 0.0,
168 add_percent: 0.0,
169 mul_factor: Mag::ONE,
170 }
171 }
172}
173
174impl FingererAggregate {
175 /// Walk every effect on every modifier and fold them into a single
176 /// aggregate. Linear in (modifiers × effects); call on add/remove/expire.
177 pub fn rebuild(modifiers: &[Modifier]) -> Self {
178 let mut a = Self::default();
179 for m in modifiers {
180 for e in &m.effects {
181 match *e {
182 ModifierEffect::FlatFps(v) => a.flat_fps += v,
183 ModifierEffect::AddPercent(v) => a.add_percent += v,
184 ModifierEffect::MulFactor(v) => a.mul_factor = a.mul_factor.mul(v),
185 }
186 }
187 }
188 a
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn perm(effects: Vec<ModifierEffect>) -> Modifier {
197 Modifier {
198 source: ModifierSource::GreenCoin,
199 effects,
200 duration: ModifierDuration::Permanent,
201 created_at_tick: 0,
202 }
203 }
204
205 fn mul(v: f64) -> ModifierEffect {
206 ModifierEffect::MulFactor(Mag::from_f64(v))
207 }
208
209 #[test]
210 fn empty_aggregate_is_identity() {
211 let a = FingererAggregate::rebuild(&[]);
212 assert_eq!(a.flat_fps, 0.0);
213 assert_eq!(a.add_percent, 0.0);
214 assert_eq!(a.mul_factor, Mag::ONE);
215 }
216
217 #[test]
218 fn add_percent_sums_across_modifiers() {
219 let mods = vec![
220 perm(vec![ModifierEffect::AddPercent(0.10)]),
221 perm(vec![ModifierEffect::AddPercent(0.10)]),
222 ];
223 let a = FingererAggregate::rebuild(&mods);
224 assert!((a.add_percent - 0.20).abs() < 1e-9);
225 }
226
227 #[test]
228 fn mul_factor_multiplies_across_modifiers() {
229 let mods = vec![perm(vec![mul(2.0)]), perm(vec![mul(2.0)])];
230 let a = FingererAggregate::rebuild(&mods);
231 assert!((a.mul_factor.to_f64() - 4.0).abs() < 1e-9);
232 }
233
234 #[test]
235 fn flat_fps_sums_across_modifiers() {
236 let mods = vec![
237 perm(vec![ModifierEffect::FlatFps(50.0)]),
238 perm(vec![ModifierEffect::FlatFps(75.0)]),
239 ];
240 let a = FingererAggregate::rebuild(&mods);
241 assert!((a.flat_fps - 125.0).abs() < 1e-9);
242 }
243
244 #[test]
245 fn mixed_effects_on_same_modifier_all_apply() {
246 let mods = vec![perm(vec![
247 ModifierEffect::FlatFps(50.0),
248 ModifierEffect::AddPercent(0.10),
249 mul(2.0),
250 ])];
251 let a = FingererAggregate::rebuild(&mods);
252 assert!((a.flat_fps - 50.0).abs() < 1e-9);
253 assert!((a.add_percent - 0.10).abs() < 1e-9);
254 assert!((a.mul_factor.to_f64() - 2.0).abs() < 1e-9);
255 }
256
257 #[test]
258 fn mul_factor_stack_survives_billions() {
259 // Stack 10k MulFactor(1.5) modifiers — under the old f64 path
260 // this overflows to Infinity around the 1740th. With Mag it
261 // produces a precise log10 ≈ 1761 (= 10000 * log10(1.5)).
262 let mods: Vec<_> = (0..10_000).map(|_| perm(vec![mul(1.5)])).collect();
263 let a = FingererAggregate::rebuild(&mods);
264 let expected = (1.5_f64).log10() * 10_000.0;
265 assert!((a.mul_factor.log10 - expected).abs() < 1e-6);
266 }
267
268 #[test]
269 fn source_ids_are_stable() {
270 // These strings are load-bearing — they appear in serialized saves.
271 // Renaming would orphan player progress.
272 assert_eq!(ModifierSource::GreenCoin.id(), "green_coin");
273 assert_eq!(ModifierSource::PurpleCoin.id(), "purple_coin");
274 }
275}