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