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
43/// Stable identifier for the *kind* of buff or debuff a modifier represents.
44/// Used for de-duping in the UI ("3× Green Coin" instead of three identical
45/// chips), for save serialization, and for future per-source rules.
46///
47/// **The string returned by [`Self::id`] is a load-bearing primary key** —
48/// renaming it silently invalidates every player's saved progress on that
49/// source. Treat it like a fingerer or upgrade id: cosmetic names live in
50/// `i18n.rs`; the id stays stable forever.
51#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
52pub enum ModifierSource {
53 /// Green Coin — rare powerup that attaches a permanent +10% AddPercent
54 /// to a random owned fingerer.
55 GreenCoin,
56 /// Purple Coin — the existing Buff Golden, recast as a per-fingerer
57 /// modifier with a finite duration. Phase 3 of the Green Coin PR
58 /// absorbs `Buff::FingererBoost` into this source.
59 PurpleCoin,
60}
61
62impl ModifierSource {
63 pub fn id(self) -> &'static str {
64 match self {
65 Self::GreenCoin => "green_coin",
66 Self::PurpleCoin => "purple_coin",
67 }
68 }
69}
70
71/// One contribution from a modifier. A single [`Modifier`] may carry
72/// multiple effects — e.g. a future debuff could combine
73/// `[FlatFps(-10.0), MulFactor(0.5)]`.
74#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
75pub enum ModifierEffect {
76 /// Flat additive contribution to the fingerer's per-tier output.
77 /// Pre-multiplier: added to `base * count` before percent or mul.
78 FlatFps(f64),
79 /// Additive percent. `0.10` = +10%. Sums across all modifiers on the
80 /// fingerer (two +10% Green Coins = +20%).
81 AddPercent(f64),
82 /// Multiplicative factor. Multiplies across all modifiers on the
83 /// fingerer (two x2 = x4). Applied after [`Self::AddPercent`].
84 MulFactor(f64),
85}
86
87/// Lifetime of a modifier. Permanent modifiers stick for the rest of the
88/// run (cleared only by `prestige_reset`). Timed modifiers decrement once
89/// per `state.tick()` and drop when they hit zero.
90#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
91pub enum ModifierDuration {
92 Permanent,
93 /// Remaining ticks. Decremented in the per-tick modifier walk; the
94 /// modifier is removed (and the aggregate rebuilt) on the tick it
95 /// would step from `Ticks(0)`.
96 Ticks(u32),
97}
98
99/// A single buff or debuff attached to a fingerer. Composable: a fingerer
100/// may carry an unbounded number of these. See module docs for the
101/// stacking rules and aggregate-cache contract.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct Modifier {
104 pub source: ModifierSource,
105 /// Zero or more contributions. An empty Vec is valid (a sourced marker
106 /// with no numeric effect — e.g. a flag-style modifier read by future
107 /// UI without changing FPS).
108 pub effects: Vec<ModifierEffect>,
109 pub duration: ModifierDuration,
110 /// `total_play_ticks` value at the time this modifier was attached.
111 /// Drives "X ago" labels and tie-breaking. Recorded against the
112 /// monotonic play counter (not wall-clock) so it survives quit/restart
113 /// without becoming wrong.
114 #[serde(default)]
115 pub created_at_tick: u64,
116}
117
118impl Modifier {
119 /// Plateau-at-1.0 until the last `FADE_TICKS` of the duration, then
120 /// smoothstep-decay to 0. Mirrors `Buff::strength` so border / HUD
121 /// pulse code can blend timed modifiers into the same activity sum
122 /// without a special case. Permanent modifiers always read 1.0
123 /// (they don't fade).
124 pub fn strength(&self) -> f32 {
125 const FADE_TICKS: f32 = 30.0; // ~1.5s at 20Hz
126 match self.duration {
127 ModifierDuration::Permanent => 1.0,
128 ModifierDuration::Ticks(n) => {
129 let remaining = n as f32;
130 if remaining >= FADE_TICKS {
131 1.0
132 } else {
133 let t = (remaining / FADE_TICKS).clamp(0.0, 1.0);
134 t * t * (3.0 - 2.0 * t)
135 }
136 }
137 }
138 }
139}
140
141/// Pre-computed sum/product of every effect across every modifier on a
142/// fingerer. Read on every FPS calc — the tick path rebuilds this when
143/// modifiers are added, removed, or expire, so reads are O(1).
144///
145/// `Default` is the **identity**: zero flat, zero add-percent, x1 multiplier.
146/// This is the value reads see when no modifiers are attached.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct FingererAggregate {
149 pub flat_fps: f64,
150 pub add_percent: f64,
151 pub mul_factor: f64,
152}
153
154impl Default for FingererAggregate {
155 fn default() -> Self {
156 Self {
157 flat_fps: 0.0,
158 add_percent: 0.0,
159 mul_factor: 1.0,
160 }
161 }
162}
163
164impl FingererAggregate {
165 /// Walk every effect on every modifier and fold them into a single
166 /// aggregate. Linear in (modifiers × effects); call on add/remove/expire.
167 pub fn rebuild(modifiers: &[Modifier]) -> Self {
168 let mut a = Self::default();
169 for m in modifiers {
170 for e in &m.effects {
171 match *e {
172 ModifierEffect::FlatFps(v) => a.flat_fps += v,
173 ModifierEffect::AddPercent(v) => a.add_percent += v,
174 ModifierEffect::MulFactor(v) => a.mul_factor *= v,
175 }
176 }
177 }
178 a
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn perm(effects: Vec<ModifierEffect>) -> Modifier {
187 Modifier {
188 source: ModifierSource::GreenCoin,
189 effects,
190 duration: ModifierDuration::Permanent,
191 created_at_tick: 0,
192 }
193 }
194
195 #[test]
196 fn empty_aggregate_is_identity() {
197 let a = FingererAggregate::rebuild(&[]);
198 assert_eq!(a.flat_fps, 0.0);
199 assert_eq!(a.add_percent, 0.0);
200 assert_eq!(a.mul_factor, 1.0);
201 }
202
203 #[test]
204 fn add_percent_sums_across_modifiers() {
205 // Two +10% Green Coins → +20%, NOT compounded into +21%.
206 let mods = vec![
207 perm(vec![ModifierEffect::AddPercent(0.10)]),
208 perm(vec![ModifierEffect::AddPercent(0.10)]),
209 ];
210 let a = FingererAggregate::rebuild(&mods);
211 assert!((a.add_percent - 0.20).abs() < 1e-9);
212 }
213
214 #[test]
215 fn mul_factor_multiplies_across_modifiers() {
216 // Two x2 buffs → x4.
217 let mods = vec![
218 perm(vec![ModifierEffect::MulFactor(2.0)]),
219 perm(vec![ModifierEffect::MulFactor(2.0)]),
220 ];
221 let a = FingererAggregate::rebuild(&mods);
222 assert!((a.mul_factor - 4.0).abs() < 1e-9);
223 }
224
225 #[test]
226 fn flat_fps_sums_across_modifiers() {
227 let mods = vec![
228 perm(vec![ModifierEffect::FlatFps(50.0)]),
229 perm(vec![ModifierEffect::FlatFps(75.0)]),
230 ];
231 let a = FingererAggregate::rebuild(&mods);
232 assert!((a.flat_fps - 125.0).abs() < 1e-9);
233 }
234
235 #[test]
236 fn mixed_effects_on_same_modifier_all_apply() {
237 let mods = vec![perm(vec![
238 ModifierEffect::FlatFps(50.0),
239 ModifierEffect::AddPercent(0.10),
240 ModifierEffect::MulFactor(2.0),
241 ])];
242 let a = FingererAggregate::rebuild(&mods);
243 assert!((a.flat_fps - 50.0).abs() < 1e-9);
244 assert!((a.add_percent - 0.10).abs() < 1e-9);
245 assert!((a.mul_factor - 2.0).abs() < 1e-9);
246 }
247
248 #[test]
249 fn source_ids_are_stable() {
250 // These strings are load-bearing — they appear in serialized saves.
251 // Renaming would orphan player progress.
252 assert_eq!(ModifierSource::GreenCoin.id(), "green_coin");
253 assert_eq!(ModifierSource::PurpleCoin.id(), "purple_coin");
254 }
255}