Skip to main content

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}