Skip to main content

cuqueclicker_lib/game/tree/
aggregate.rs

1//! Parallel-Aggregate cache for tree contributions.
2//!
3//! `bought: HashSet<TreeCoord>` on `UpgradeTreeState` is the source of truth.
4//! This `TreeAggregate` is the **derived cache** read by the FPS / click /
5//! powerup hot paths. Rebuilt on load and incrementally updated on buy /
6//! refund. Reads are O(1) regardless of how many nodes the player owns —
7//! the per-tick FPS calc never iterates the bought set.
8
9use std::collections::HashSet;
10
11use crate::bignum::Mag;
12use crate::game::fingerer::FINGERERS;
13use crate::game::powerup::N_KINDS;
14use crate::game::tree::coord::TreeCoord;
15use crate::game::tree::node::{NodeSpec, node_at};
16use crate::game::tree::primitive::{Op, Primitive, Target};
17
18/// Per-fingerer contribution from the tree. Mirrors `FingererAggregate`
19/// (additive percent sums, mul factor multiplies, flat sums) so the FPS
20/// formula combines tree + modifier contributions symmetrically.
21///
22/// Multiplicative fields live in `Mag` (log-magnitude) because they
23/// compound across hundreds-to-thousands of bought nodes; the original
24/// `f64` storage produced `Infinity` in long-haul play. Additive fields
25/// (`flat_fps`, `add_percent`) stay `f64` — they don't compound and
26/// per-node values stay tiny.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct FingererTreeContrib {
29    pub flat_fps: f64,
30    pub add_percent: f64,
31    pub mul_factor: Mag,
32    /// Multiplicative on the buy cost of this fingerer (`< 1.0` discount,
33    /// `> 1.0` inflation). Used by `GameState::cost`.
34    pub cost_mul: Mag,
35}
36
37impl Default for FingererTreeContrib {
38    fn default() -> Self {
39        Self {
40            flat_fps: 0.0,
41            add_percent: 0.0,
42            mul_factor: Mag::ONE,
43            cost_mul: Mag::ONE,
44        }
45    }
46}
47
48/// All of the tree's contributions, pre-folded into a single struct for
49/// O(1) reads on the hot paths.
50///
51/// All multiplicative fields are `Mag` so multi-thousand-node stacks
52/// can't overflow. The few small-magnitude additive fields stay `f64`
53/// because they don't compound. `powerup_spawn_mul` also stays `f64`
54/// because its consumer (`sim::next_cooldown`) computes
55/// `base / mul_f64` directly and we'd just round-trip through `f64`
56/// anyway.
57#[derive(Clone, Debug)]
58pub struct TreeAggregate {
59    /// Per-fingerer contributions, indexed by `FINGERERS` catalog position.
60    pub per_fingerer: Vec<FingererTreeContrib>,
61    /// Global "all fingerers" contributions — distribute across every
62    /// fingerer's per-tier output. Stack on top of per-fingerer.
63    pub all_fingerers_flat: f64,
64    pub all_fingerers_add: f64,
65    pub all_fingerers_mul: Mag,
66    /// Click contributions.
67    pub click_add: f64,
68    pub click_mul: Mag,
69    pub click_flat: f64,
70    /// Prestige multiplier extensions (applied on top of the base
71    /// prestige formula).
72    pub prestige_add: f64,
73    pub prestige_mul: Mag,
74    /// Per-powerup-kind spawn-rate multiplier. `f64` because the only
75    /// consumer (`sim::next_cooldown`) needs the raw value as a divisor
76    /// and the per-node cap (×1.005) keeps any realistic stack inside
77    /// `f64` range.
78    pub powerup_spawn_mul: [f64; N_KINDS],
79    /// Per-powerup-kind reward multiplier. `Mag` because this stage was
80    /// the headline overflow path: `powerup_reward_mul[Buff]` scales the
81    /// 7× MulFactor that gets persisted on a fingerer every Buff catch,
82    /// and 10k+ deep tree stacks would otherwise blow past `f64`.
83    pub powerup_reward_mul: [Mag; N_KINDS],
84    pub powerup_duration_mul: [Mag; N_KINDS],
85    /// Green Coin AddPercent strength multiplier (the base +10% becomes
86    /// +10% * green_coin_strength_mul on catch).
87    pub green_coin_strength_mul: Mag,
88}
89
90impl Default for TreeAggregate {
91    fn default() -> Self {
92        Self {
93            per_fingerer: vec![FingererTreeContrib::default(); FINGERERS.len()],
94            all_fingerers_flat: 0.0,
95            all_fingerers_add: 0.0,
96            all_fingerers_mul: Mag::ONE,
97            click_add: 0.0,
98            click_mul: Mag::ONE,
99            click_flat: 0.0,
100            prestige_add: 0.0,
101            prestige_mul: Mag::ONE,
102            powerup_spawn_mul: [1.0; N_KINDS],
103            powerup_reward_mul: [Mag::ONE; N_KINDS],
104            powerup_duration_mul: [Mag::ONE; N_KINDS],
105            green_coin_strength_mul: Mag::ONE,
106        }
107    }
108}
109
110impl TreeAggregate {
111    /// Resize `per_fingerer` to match the live `FINGERERS` length and
112    /// reset every field to identity. Cheap; do this in `migrate_runtime`
113    /// before walking `bought`.
114    pub fn reset(&mut self) {
115        if self.per_fingerer.len() != FINGERERS.len() {
116            self.per_fingerer = vec![FingererTreeContrib::default(); FINGERERS.len()];
117        } else {
118            for c in self.per_fingerer.iter_mut() {
119                *c = FingererTreeContrib::default();
120            }
121        }
122        self.all_fingerers_flat = 0.0;
123        self.all_fingerers_add = 0.0;
124        self.all_fingerers_mul = Mag::ONE;
125        self.click_add = 0.0;
126        self.click_mul = Mag::ONE;
127        self.click_flat = 0.0;
128        self.prestige_add = 0.0;
129        self.prestige_mul = Mag::ONE;
130        self.powerup_spawn_mul = [1.0; N_KINDS];
131        self.powerup_reward_mul = [Mag::ONE; N_KINDS];
132        self.powerup_duration_mul = [Mag::ONE; N_KINDS];
133        self.green_coin_strength_mul = Mag::ONE;
134    }
135
136    /// Rebuild the aggregate from scratch by regenerating each owned node
137    /// from its lot coord and folding its primitives in. Called by
138    /// `migrate_runtime` on load and by `prestige_reset` after clearing
139    /// `bought`.
140    pub fn rebuild_from_bought(&mut self, bought: &HashSet<TreeCoord>) {
141        self.reset();
142        for &lot in bought {
143            if let Some(node) = node_at(lot.x, lot.y) {
144                for &p in &node.primitives {
145                    fold_primitive(self, p, true);
146                }
147            }
148        }
149    }
150
151    /// Fold a single node's primitive stack into the aggregate. Called
152    /// when the player buys a node — incremental update, O(primitives in
153    /// node) ≈ 1-4.
154    pub fn fold_in_node(&mut self, node: &NodeSpec) {
155        for &p in &node.primitives {
156            fold_primitive(self, p, true);
157        }
158    }
159
160    /// Inverse of `fold_in_node`: subtract a node's contribution. Called
161    /// on refund.
162    pub fn fold_out_node(&mut self, node: &NodeSpec) {
163        for &p in &node.primitives {
164            fold_primitive(self, p, false);
165        }
166    }
167
168    /// Convenience: get the per-fingerer contrib for a catalog index,
169    /// folded with the global `all_fingerers_*` contributions.
170    pub fn effective_for_fingerer(&self, idx: usize) -> FingererTreeContrib {
171        let base = self.per_fingerer.get(idx).copied().unwrap_or_default();
172        FingererTreeContrib {
173            flat_fps: base.flat_fps + self.all_fingerers_flat,
174            add_percent: base.add_percent + self.all_fingerers_add,
175            mul_factor: base.mul_factor.mul(self.all_fingerers_mul),
176            cost_mul: base.cost_mul,
177        }
178    }
179}
180
181/// Apply (`add == true`) or undo (`add == false`) a single primitive's
182/// contribution. Multiplicative primitives compose via `Mag` so the
183/// running product stays representable no matter how many nodes the
184/// player buys.
185fn fold_primitive(agg: &mut TreeAggregate, p: Primitive, add: bool) {
186    let sign = if add { 1.0 } else { -1.0 };
187    let mag = Mag::from_f64(p.magnitude);
188    let apply_mul = |slot: &mut Mag, m: Mag| {
189        if add {
190            *slot = slot.mul(m);
191        } else if !m.is_zero() {
192            *slot = slot.div(m);
193        }
194    };
195    match (p.op, p.target) {
196        // --- Per-fingerer ---
197        (Op::AddPercent, Target::Fingerer(i)) => {
198            if let Some(c) = agg.per_fingerer.get_mut(i as usize) {
199                c.add_percent += sign * p.magnitude;
200            }
201        }
202        (Op::MulFactor, Target::Fingerer(i)) => {
203            if let Some(c) = agg.per_fingerer.get_mut(i as usize) {
204                apply_mul(&mut c.mul_factor, mag);
205            }
206        }
207        (Op::FlatAdd, Target::Fingerer(i)) => {
208            if let Some(c) = agg.per_fingerer.get_mut(i as usize) {
209                c.flat_fps += sign * p.magnitude;
210            }
211        }
212        (Op::CostMul, Target::Fingerer(i)) => {
213            if let Some(c) = agg.per_fingerer.get_mut(i as usize) {
214                apply_mul(&mut c.cost_mul, mag);
215            }
216        }
217        // --- All fingerers ---
218        (Op::AddPercent, Target::AllFingerers) => agg.all_fingerers_add += sign * p.magnitude,
219        (Op::MulFactor, Target::AllFingerers) => apply_mul(&mut agg.all_fingerers_mul, mag),
220        (Op::FlatAdd, Target::AllFingerers) => agg.all_fingerers_flat += sign * p.magnitude,
221        // --- Click ---
222        (Op::AddPercent, Target::Click) => agg.click_add += sign * p.magnitude,
223        (Op::MulFactor, Target::Click) => apply_mul(&mut agg.click_mul, mag),
224        (Op::FlatAdd, Target::Click) => agg.click_flat += sign * p.magnitude,
225        // --- Prestige ---
226        (Op::AddPercent, Target::Prestige) => agg.prestige_add += sign * p.magnitude,
227        (Op::MulFactor, Target::Prestige) => apply_mul(&mut agg.prestige_mul, mag),
228        // --- Powerup spawn / reward / duration ---
229        (Op::SpawnRateMul, Target::PowerupSpawn(k)) => {
230            let i = k as usize;
231            if add {
232                agg.powerup_spawn_mul[i] *= p.magnitude;
233            } else if p.magnitude != 0.0 {
234                agg.powerup_spawn_mul[i] /= p.magnitude;
235            }
236        }
237        (Op::EffectMul, Target::PowerupReward(k)) => {
238            apply_mul(&mut agg.powerup_reward_mul[k as usize], mag);
239        }
240        (Op::EffectMul, Target::PowerupDuration(k)) => {
241            apply_mul(&mut agg.powerup_duration_mul[k as usize], mag);
242        }
243        // --- Green Coin strength ---
244        (Op::EffectMul, Target::GreenCoinStrength) => {
245            apply_mul(&mut agg.green_coin_strength_mul, mag);
246        }
247        // Op/Target combinations the procgen never produces — fail loud
248        // in dev so a future generation bug or new enum variant can't
249        // silently charge the player cuques for an effect that doesn't
250        // fold into the aggregate. Release builds still no-op (the
251        // primitive has no effect) instead of panicking the run.
252        (op, target) => {
253            debug_assert!(
254                false,
255                "unhandled tree primitive: op={op:?} target={target:?} — \
256                 add a fold arm in aggregate.rs::fold_primitive or remove \
257                 this (op, target) pairing from procgen pick_op"
258            );
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn p(op: Op, target: Target, mag: f64) -> Primitive {
268        Primitive {
269            op,
270            target,
271            magnitude: mag,
272        }
273    }
274
275    #[test]
276    fn default_is_identity() {
277        let a = TreeAggregate::default();
278        for c in &a.per_fingerer {
279            assert_eq!(c.flat_fps, 0.0);
280            assert_eq!(c.add_percent, 0.0);
281            assert_eq!(c.mul_factor, Mag::ONE);
282            assert_eq!(c.cost_mul, Mag::ONE);
283        }
284        assert_eq!(a.all_fingerers_mul, Mag::ONE);
285        assert_eq!(a.click_mul, Mag::ONE);
286        assert_eq!(a.prestige_mul, Mag::ONE);
287        for v in a.powerup_spawn_mul {
288            assert_eq!(v, 1.0);
289        }
290        for v in a.powerup_reward_mul {
291            assert_eq!(v, Mag::ONE);
292        }
293    }
294
295    #[test]
296    fn fold_in_then_out_returns_to_default() {
297        let mut a = TreeAggregate::default();
298        let prims = vec![
299            p(Op::AddPercent, Target::Fingerer(0), 0.10),
300            p(Op::MulFactor, Target::Click, 2.0),
301            p(Op::EffectMul, Target::GreenCoinStrength, 1.5),
302        ];
303        for &pp in &prims {
304            fold_primitive(&mut a, pp, true);
305        }
306        for &pp in &prims {
307            fold_primitive(&mut a, pp, false);
308        }
309        assert!((a.per_fingerer[0].add_percent).abs() < 1e-12);
310        assert!((a.click_mul.to_f64() - 1.0).abs() < 1e-12);
311        assert!((a.green_coin_strength_mul.to_f64() - 1.0).abs() < 1e-12);
312    }
313
314    #[test]
315    fn add_percent_stacks_additively() {
316        let mut a = TreeAggregate::default();
317        fold_primitive(&mut a, p(Op::AddPercent, Target::Fingerer(0), 0.10), true);
318        fold_primitive(&mut a, p(Op::AddPercent, Target::Fingerer(0), 0.15), true);
319        assert!((a.per_fingerer[0].add_percent - 0.25).abs() < 1e-12);
320    }
321
322    #[test]
323    fn mul_factor_stacks_multiplicatively() {
324        let mut a = TreeAggregate::default();
325        fold_primitive(&mut a, p(Op::MulFactor, Target::Click, 2.0), true);
326        fold_primitive(&mut a, p(Op::MulFactor, Target::Click, 3.0), true);
327        assert!((a.click_mul.to_f64() - 6.0).abs() < 1e-9);
328    }
329
330    #[test]
331    fn mul_factor_stack_is_truly_unbounded() {
332        // The whole point of switching to `Mag`: a stack of 100k nodes
333        // each contributing ×1.005 used to overflow `f64` to `Infinity`.
334        // Now it stays representable and the log10 is exact.
335        let mut a = TreeAggregate::default();
336        for _ in 0..100_000 {
337            fold_primitive(&mut a, p(Op::MulFactor, Target::Click, 1.005), true);
338        }
339        let expected_log10 = (1.005_f64).log10() * 100_000.0;
340        assert!((a.click_mul.log10 - expected_log10).abs() < 1e-6);
341    }
342
343    #[test]
344    fn effective_for_fingerer_folds_global() {
345        let mut a = TreeAggregate::default();
346        fold_primitive(&mut a, p(Op::AddPercent, Target::Fingerer(0), 0.10), true);
347        fold_primitive(&mut a, p(Op::AddPercent, Target::AllFingerers, 0.05), true);
348        let eff = a.effective_for_fingerer(0);
349        assert!((eff.add_percent - 0.15).abs() < 1e-12);
350    }
351
352    #[test]
353    fn rebuild_from_empty_bought_is_identity() {
354        let mut a = TreeAggregate::default();
355        // Pre-pollute then rebuild from empty.
356        fold_primitive(&mut a, p(Op::MulFactor, Target::Click, 5.0), true);
357        a.rebuild_from_bought(&HashSet::new());
358        assert_eq!(a.click_mul, Mag::ONE);
359    }
360
361    #[test]
362    fn rebuild_with_only_anchor_is_identity() {
363        let mut bought = HashSet::new();
364        bought.insert(TreeCoord::ORIGIN);
365        let mut a = TreeAggregate::default();
366        a.rebuild_from_bought(&bought);
367        for c in &a.per_fingerer {
368            assert_eq!(c.add_percent, 0.0);
369            assert_eq!(c.flat_fps, 0.0);
370            assert_eq!(c.mul_factor, Mag::ONE);
371            assert_eq!(c.cost_mul, Mag::ONE);
372        }
373        assert_eq!(a.click_mul, Mag::ONE);
374        assert_eq!(a.all_fingerers_mul, Mag::ONE);
375        assert_eq!(a.prestige_mul, Mag::ONE);
376    }
377}