cuqueclicker_lib/game/tree/primitive.rs
1//! Engine primitives — the fixed vocabulary of effects the tree can produce.
2//!
3//! Each tree node is a `Vec<Primitive>` where each primitive is a triple of
4//! `(Op, Target, magnitude)`. Op + Target are code-defined enums (the engine
5//! needs to dispatch on them); the **composition, count, target, magnitude,
6//! and sign** of each primitive at a given lot are 100% procgen.
7//!
8//! Adding a new primitive variant: append to `Op` or `Target`, add a fold
9//! case in `aggregate.rs::TreeAggregate::fold_in`, and add a render case in
10//! `naming.rs`. There's no other table to update.
11
12use crate::game::fingerer::FINGERERS;
13use crate::game::powerup::PowerupKind;
14
15/// What math the primitive performs. Sign of `magnitude` decides boon vs
16/// bane: a `MulFactor` with magnitude `< 1.0` is a debuff, `> 1.0` a buff.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum Op {
19 /// Add to the additive percent on the target (target's `add_percent`
20 /// gains `magnitude`). `0.10` = +10%.
21 AddPercent,
22 /// Multiply the multiplicative factor on the target. `2.0` = ×2,
23 /// `0.5` = ÷2.
24 MulFactor,
25 /// Add to the flat FPS contribution on the target.
26 FlatAdd,
27 /// Multiply the COST of buying a fingerer (`< 1.0` is a discount,
28 /// `> 1.0` is inflation). Only meaningful when target is a fingerer.
29 CostMul,
30 /// Scale the inter-arrival cooldown of a powerup kind. `< 1.0` =
31 /// spawns more often, `> 1.0` = rarer. Only meaningful when target is
32 /// a PowerupSpawn(kind).
33 SpawnRateMul,
34 /// Scale the reward / duration of a powerup-related effect. Used with
35 /// `Target::PowerupReward(kind)` and `Target::PowerupDuration(kind)`.
36 EffectMul,
37}
38
39/// What the primitive operates on. The full set of game-touchable axes —
40/// any effect we want the tree to express has to map to one of these.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub enum Target {
43 /// A specific fingerer by catalog index. `idx` is `0..FINGERERS.len()`.
44 /// Stable: `idx` is the position in the `FINGERERS` array. The catalog
45 /// id at that index is the load-bearing string; `idx` is just a
46 /// compact encoding for the procgen-derived target.
47 Fingerer(u8),
48 /// All fingerers — the effect distributes across every fingerer's
49 /// per-tier output.
50 AllFingerers,
51 /// Manual click power (`click_power()`).
52 Click,
53 /// Powerup spawn rate for the given kind (used with `Op::SpawnRateMul`).
54 PowerupSpawn(PowerupKind),
55 /// Powerup reward for the given kind (used with `Op::EffectMul`).
56 /// Currently meaningful for Lucky (flat cuques) and Buff (mul factor).
57 PowerupReward(PowerupKind),
58 /// Powerup buff duration for the given kind (used with `Op::EffectMul`).
59 /// Currently meaningful for Frenzy and Buff.
60 PowerupDuration(PowerupKind),
61 /// Prestige multiplier (current formula is 1 + 0.01 * prestige_count).
62 Prestige,
63 /// Green Coin AddPercent strength (the +10% becomes +10% * magnitude).
64 GreenCoinStrength,
65}
66
67impl Target {
68 /// Stable index used by the procgen to pick a target uniformly from the
69 /// available axes. Distinct values are returned for distinct targets.
70 pub fn from_index(idx: usize) -> Self {
71 let nf = FINGERERS.len();
72 // Layout:
73 // [0..nf) -> Fingerer(i)
74 // [nf] -> AllFingerers
75 // [nf+1] -> Click
76 // [nf+2 ..nf+6) -> PowerupSpawn(L/F/B/G)
77 // [nf+6 ..nf+10) -> PowerupReward(L/F/B/G)
78 // [nf+10..nf+14) -> PowerupDuration(L/F/B/G)
79 // [nf+14] -> Prestige
80 // [nf+15] -> GreenCoinStrength
81 let kinds = PowerupKind::ALL;
82 if idx < nf {
83 return Target::Fingerer(idx as u8);
84 }
85 let i = idx - nf;
86 if i == 0 {
87 return Target::AllFingerers;
88 }
89 if i == 1 {
90 return Target::Click;
91 }
92 if (2..6).contains(&i) {
93 return Target::PowerupSpawn(kinds[i - 2]);
94 }
95 if (6..10).contains(&i) {
96 return Target::PowerupReward(kinds[i - 6]);
97 }
98 if (10..14).contains(&i) {
99 return Target::PowerupDuration(kinds[i - 10]);
100 }
101 if i == 14 {
102 return Target::Prestige;
103 }
104 // Last bucket, anything beyond falls into GreenCoinStrength.
105 Target::GreenCoinStrength
106 }
107
108 /// Total number of distinct targets the procgen can produce, given the
109 /// current FINGERERS catalog length. Procgen uses
110 /// `rng.range_usize(target_count())` to pick.
111 pub fn target_count() -> usize {
112 FINGERERS.len() + 16
113 }
114}
115
116/// A single tree-node effect contribution.
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct Primitive {
119 pub op: Op,
120 pub target: Target,
121 pub magnitude: f64,
122}
123
124impl Primitive {
125 /// True when the primitive's sign (relative to its op's "neutral" value)
126 /// represents a debuff/bane. Used to classify nodes as keystones.
127 pub fn is_bane(self) -> bool {
128 match self.op {
129 Op::AddPercent | Op::FlatAdd => self.magnitude < 0.0,
130 // For multiplicative ops a magnitude < 1.0 is a nerf (incl 0).
131 Op::MulFactor | Op::EffectMul | Op::SpawnRateMul => self.magnitude < 1.0,
132 // CostMul: < 1.0 is a *boon* (cheaper); > 1.0 is a bane.
133 Op::CostMul => self.magnitude > 1.0,
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn target_from_index_covers_full_range() {
144 let n = Target::target_count();
145 let mut seen_fingerer = 0;
146 let mut seen_all = 0;
147 let mut seen_click = 0;
148 let mut seen_prestige = 0;
149 for i in 0..n {
150 match Target::from_index(i) {
151 Target::Fingerer(_) => seen_fingerer += 1,
152 Target::AllFingerers => seen_all += 1,
153 Target::Click => seen_click += 1,
154 Target::Prestige => seen_prestige += 1,
155 _ => {}
156 }
157 }
158 assert_eq!(seen_fingerer, FINGERERS.len());
159 assert_eq!(seen_all, 1);
160 assert_eq!(seen_click, 1);
161 assert_eq!(seen_prestige, 1);
162 }
163
164 #[test]
165 fn is_bane_sign_semantics() {
166 // AddPercent: negative is bane.
167 assert!(
168 !Primitive {
169 op: Op::AddPercent,
170 target: Target::Click,
171 magnitude: 0.10
172 }
173 .is_bane()
174 );
175 assert!(
176 Primitive {
177 op: Op::AddPercent,
178 target: Target::Click,
179 magnitude: -0.05
180 }
181 .is_bane()
182 );
183
184 // MulFactor: <1 is bane.
185 assert!(
186 !Primitive {
187 op: Op::MulFactor,
188 target: Target::Click,
189 magnitude: 2.0
190 }
191 .is_bane()
192 );
193 assert!(
194 Primitive {
195 op: Op::MulFactor,
196 target: Target::Click,
197 magnitude: 0.5
198 }
199 .is_bane()
200 );
201
202 // CostMul: >1 is bane (more expensive).
203 assert!(
204 !Primitive {
205 op: Op::CostMul,
206 target: Target::Fingerer(0),
207 magnitude: 0.8
208 }
209 .is_bane()
210 );
211 assert!(
212 Primitive {
213 op: Op::CostMul,
214 target: Target::Fingerer(0),
215 magnitude: 1.5
216 }
217 .is_bane()
218 );
219
220 // SpawnRateMul: <1 is bane (slower spawns); >1 is boon (faster).
221 assert!(
222 !Primitive {
223 op: Op::SpawnRateMul,
224 target: Target::PowerupSpawn(PowerupKind::Lucky),
225 magnitude: 1.10
226 }
227 .is_bane()
228 );
229 assert!(
230 Primitive {
231 op: Op::SpawnRateMul,
232 target: Target::PowerupSpawn(PowerupKind::Lucky),
233 magnitude: 0.91
234 }
235 .is_bane()
236 );
237 }
238}