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
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}