cuqueclicker_lib/game/tree/node.rs
1//! Per-lot node generator.
2//!
3//! `node_at(x, y)` returns either `Some(NodeSpec)` (this lot carries a node)
4//! or `None` (empty lot — procedural gap). The result is a pure function of
5//! `(TREE_SEED, x, y)`: the same input always produces byte-identical output.
6//!
7//! Lot geometry: the canvas is an infinite grid of lots, each
8//! `LOT_W × LOT_H` cells. A lot's node (if any) is a procedurally-sized
9//! rectangular box placed at a procedurally-jittered offset inside the lot.
10//! Boxes never overlap because each lives strictly inside its lot interior.
11//!
12//! Edges: any two lots whose `(x, y)` differ by at most 1 on each axis
13//! ("8-king" adjacency) may be connected. Existence is rolled with
14//! `edge_exists(a, b)` from a position-seeded RNG, so the connectivity
15//! pattern is also stable forever.
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19
20use super::coord::TreeCoord;
21use super::naming::make_title;
22use super::noise::{fbm_2d, value_noise_2d};
23use super::primitive::{Op, Primitive, Target};
24use super::rng::SplitMix64;
25use super::seed::TREE_SEED;
26
27/// Size of one "lot" cell in the infinite tree grid, in terminal cells.
28/// 36 wide × 10 tall comfortably holds a Notable box (16×5) with breathing
29/// room for edge routing.
30pub const LOT_W: i32 = 36;
31pub const LOT_H: i32 = 10;
32
33/// Probability that a given lot is *empty* (no node). Creates organic gaps
34/// in the procedural tree, mimicking PoE's sparse regions. Bumped from
35/// 0.20 to 0.35 after the orthogonal-edge-always-connects rule: with
36/// every adjacent pair guaranteed to be linked, a lower gap rate made
37/// the tree feel like a dense lattice instead of a branching graph.
38/// More gaps + always-connected adjacency reads as a sparser, more
39/// tree-like topology.
40const GAP_PROBABILITY: f64 = 0.35;
41
42/// Origin lot bias: lots near (0, 0) are more likely to be populated AND
43/// more likely to carry small/notable nodes (vs. keystones), so the player's
44/// starting neighborhood is dense and approachable. Within `BIAS_RADIUS`
45/// lots of origin, gappiness is reduced and rarity is capped.
46const BIAS_RADIUS: i32 = 4;
47
48/// Visual classification, computed from the rolled primitive stack. Drives
49/// box dimensions, edge weight, and biome tinting in the renderer.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum Rarity {
52 Small,
53 Notable,
54 Keystone,
55}
56
57impl Rarity {
58 pub fn box_dims(self) -> (u16, u16) {
59 match self {
60 Rarity::Small => (14, 3),
61 Rarity::Notable => (18, 5),
62 Rarity::Keystone => (24, 7),
63 }
64 }
65}
66
67/// A single tree node, regenerated from `(TREE_SEED, lot)` on demand.
68#[derive(Clone, Debug)]
69pub struct NodeSpec {
70 pub lot: TreeCoord,
71 /// Absolute position+size of the rendered box, in terminal cells, in
72 /// "canvas" coordinates. The renderer translates these into screen
73 /// coords by subtracting the pan offset.
74 pub box_x: i32,
75 pub box_y: i32,
76 pub box_w: u16,
77 pub box_h: u16,
78 pub rarity: Rarity,
79 pub primitives: Vec<Primitive>,
80 pub cost: crate::bignum::Mag,
81 pub title: String,
82 /// Dominant target hint — used by the renderer for biome-color tinting.
83 /// Picked as the target of the first primitive in the rolled stack.
84 pub dominant_target: Target,
85 /// `true` only for the (0, 0) lot — the "anchor" cuque-sprite node.
86 /// Anchors carry no primitives, no cost, are auto-owned at startup,
87 /// and the renderer paints `BISCUIT_TINY` instead of a regular box.
88 /// Every other node in the tree branches outward from this anchor.
89 pub is_anchor: bool,
90}
91
92/// Salt constants — keep all of generation's `SplitMix64::from_coords` calls
93/// distinct so they don't share an RNG stream.
94const SALT_NODE: u64 = 0xA1;
95const SALT_EDGE: u64 = 0xED;
96const SALT_GAP: u64 = 0x6A;
97
98/// Pre-suppression predicate: does the local gap roll for this lot say
99/// "populated"? Origin neighborhood (|x|<=1 AND |y|<=1) is forced
100/// populated regardless of the roll. This is the *raw* "could this lot
101/// hold a node" check — orphan suppression on top of it lives in
102/// `reaches_origin`, which is what `node_at` actually gates on.
103fn passes_gap_roll(x: i32, y: i32) -> bool {
104 let is_origin_neighborhood = x.abs() <= 1 && y.abs() <= 1;
105 if is_origin_neighborhood {
106 return true;
107 }
108 let mut gap_rng = SplitMix64::from_coords(TREE_SEED, x, y, SALT_GAP);
109 let near_origin = x.abs() <= BIAS_RADIUS && y.abs() <= BIAS_RADIUS;
110 let gap_p = if near_origin {
111 GAP_PROBABILITY * 0.5
112 } else {
113 GAP_PROBABILITY
114 };
115 !gap_rng.bool_with_prob(gap_p)
116}
117
118/// Build the anchor (origin) `NodeSpec`. Anchors are special:
119/// - no primitives (no effect on the FPS / click / cost calc)
120/// - cost 0
121/// - rendered as `BISCUIT_TINY` instead of a normal box
122/// - auto-owned in `GameState::default` / `migrate_runtime` so the
123/// player's starting frontier is the anchor's king-neighbors
124fn anchor_spec() -> NodeSpec {
125 // Center the 16×8 cuque inside the 36×10 lot at canvas origin.
126 let box_w: u16 = 16;
127 let box_h: u16 = 8;
128 let off_x = ((LOT_W as u16).saturating_sub(box_w) / 2) as i32;
129 let off_y = ((LOT_H as u16).saturating_sub(box_h) / 2) as i32;
130 NodeSpec {
131 lot: TreeCoord::ORIGIN,
132 box_x: off_x,
133 box_y: off_y,
134 box_w,
135 box_h,
136 rarity: Rarity::Small,
137 primitives: Vec::new(),
138 cost: crate::bignum::Mag::ZERO,
139 title: "The Cuque".to_string(),
140 dominant_target: Target::Fingerer(0),
141 is_anchor: true,
142 }
143}
144
145/// Procgen entry point: returns the node at lot `(x, y)`, or `None` if the
146/// lot is empty.
147pub fn node_at(x: i32, y: i32) -> Option<NodeSpec> {
148 // Origin lot is the anchor — special spec, always present.
149 if x == 0 && y == 0 {
150 return Some(anchor_spec());
151 }
152 // Orphan suppression: a lot is a real node only if there is a
153 // *strictly-decreasing-manhattan* chain of populated king-neighbors
154 // back to origin. The plain "passes_gap_roll AND has a populated
155 // neighbor" check used previously allowed isolated pairs at the
156 // tree's outskirts to become each other's parent — visible as nodes
157 // disconnected from the rest of the tree that the player can never
158 // reach. See `reaches_origin` for the predicate; `anchor_of` uses
159 // the same reachability check so anchor edges never point at a
160 // suppressed lot.
161 if !reaches_origin(TreeCoord::new(x, y)) {
162 return None;
163 }
164
165 let mut rng = SplitMix64::from_coords(TREE_SEED, x, y, SALT_NODE);
166
167 // Rarity is driven by a noise field. Two noise samples: one for
168 // "rarity probability" (low frequency, broad zones) and one for "rarity
169 // hot spot" (high frequency, isolated peaks).
170 let rarity = pick_rarity(x, y, &mut rng);
171
172 // Box dimensions follow rarity. Jitter the box's offset inside its lot
173 // so a region of nodes doesn't look gridded.
174 let (box_w, box_h) = rarity.box_dims();
175 let max_off_x = (LOT_W as u16).saturating_sub(box_w + 2).max(1);
176 let max_off_y = (LOT_H as u16).saturating_sub(box_h + 1).max(1);
177 let off_x = rng.range_usize(max_off_x as usize) as i32 + 1;
178 let off_y = rng.range_usize(max_off_y as usize) as i32 + 1;
179
180 // saturating_mul guards against overflow at extreme lots (|x|
181 // larger than `i32::MAX / LOT_W`, ~6e7). Out-of-range box coords
182 // get clamped to i32 edges — the renderer's canvas_to_screen
183 // bounds-checks them off-screen, so nothing visible breaks.
184 let box_x = x.saturating_mul(LOT_W).saturating_add(off_x);
185 let box_y = y.saturating_mul(LOT_H).saturating_add(off_y);
186
187 // Primitive count: small=1, notable=2-3, keystone=2-4. Keystones MUST
188 // have at least one bane (the procgen biases sign accordingly).
189 let count = match rarity {
190 Rarity::Small => 1,
191 Rarity::Notable => 2 + rng.range_usize(2), // 2..=3
192 Rarity::Keystone => 2 + rng.range_usize(3), // 2..=4
193 };
194
195 let primitives = roll_primitives(x, y, rarity, count, &mut rng);
196 let dominant_target = primitives[0].target;
197 let cost = roll_cost(x, y, rarity);
198
199 // Name uses its own RNG stream so naming changes don't shift other
200 // procgen properties at a lot.
201 let mut name_rng = SplitMix64::from_coords(TREE_SEED, x, y, 0xDA);
202 let title = make_title(&mut name_rng, &primitives);
203
204 Some(NodeSpec {
205 lot: TreeCoord::new(x, y),
206 box_x,
207 box_y,
208 box_w,
209 box_h,
210 rarity,
211 primitives,
212 cost,
213 title,
214 dominant_target,
215 is_anchor: false,
216 })
217}
218
219fn pick_rarity(x: i32, y: i32, rng: &mut SplitMix64) -> Rarity {
220 // Origin neighborhood: always Small. Gives the player a gentle ramp.
221 if x.abs() <= 1 && y.abs() <= 1 {
222 return Rarity::Small;
223 }
224 // Biome-density noise: low frequency. High values = "this region has
225 // dense, rare goodies"; low = mostly small nodes.
226 let density = fbm_2d(TREE_SEED ^ 0x7E, x as f64 * 0.18, y as f64 * 0.18);
227 // Rarity hot spot: high frequency. A small fraction of lots roll a
228 // keystone here regardless of density.
229 let hotspot = value_noise_2d(TREE_SEED ^ 0xBD, x as f64 * 0.81, y as f64 * 0.81);
230
231 // Decision: keystones are scarce. Notables are common in dense biomes.
232 if hotspot > 0.96 {
233 return Rarity::Keystone;
234 }
235 if density > 0.65 && rng.next_f64() < 0.35 {
236 return Rarity::Notable;
237 }
238 if hotspot > 0.85 && rng.next_f64() < 0.25 {
239 return Rarity::Notable;
240 }
241 Rarity::Small
242}
243
244fn roll_primitives(
245 x: i32,
246 y: i32,
247 rarity: Rarity,
248 count: usize,
249 rng: &mut SplitMix64,
250) -> Vec<Primitive> {
251 let mut out = Vec::with_capacity(count);
252 let bane_force = matches!(rarity, Rarity::Keystone);
253 for i in 0..count {
254 // Bias target toward the lot's "biome" — sample a noise field over
255 // target indices to give regions a flavor without hard-coding a
256 // biome→target table.
257 let target = pick_target_biased(x, y, rng);
258 let op = pick_op(target, rng);
259 // Sign policy (mirrors PoE's small/notable/keystone convention):
260 // - Small / Notable: ALWAYS boon. Players expect "buying a node
261 // makes me stronger" — a pure-debuff Small was a bug, not a
262 // design feature. Tradeoffs only appear at the Keystone tier.
263 // - Keystone: first primitive is a STRONG boon, then enforce
264 // at least one bane so every keystone is a real tradeoff
265 // (its whole identity is "I gave something up for this").
266 let force_sign = match rarity {
267 Rarity::Small | Rarity::Notable => Some(true), // pure boons
268 Rarity::Keystone => {
269 if i == 0 {
270 Some(true) // strong opening boon
271 } else if i == 1 || (i == count - 1 && !out.iter().any(|p: &Primitive| p.is_bane()))
272 {
273 Some(false) // bane (or last-resort bane if none yet)
274 } else {
275 None
276 }
277 }
278 };
279 let magnitude = roll_magnitude(op, rarity, force_sign, bane_force, x, y, rng);
280 out.push(Primitive {
281 op,
282 target,
283 magnitude,
284 });
285 }
286 out
287}
288
289/// Pick a target weighted by a low-frequency noise field. Each (x, y)
290/// region "prefers" a band of target indices, naturally clustering similar
291/// effects into neighborhoods.
292fn pick_target_biased(x: i32, y: i32, rng: &mut SplitMix64) -> Target {
293 let n_targets = Target::target_count();
294 // Sample a biome noise to bias toward a center index. This gives
295 // contiguous regions of (e.g.) Fingerer(5) effects without an
296 // explicit biome enum.
297 let bias = value_noise_2d(TREE_SEED ^ 0xB10, x as f64 * 0.13, y as f64 * 0.13);
298 let center = (bias * n_targets as f64) as usize % n_targets;
299 // Radius of bias: tighter when in the origin neighborhood (player
300 // starts focused on Index Finger), wider elsewhere.
301 let radius = if x.unsigned_abs().saturating_add(y.unsigned_abs()) < 4 {
302 1.5
303 } else {
304 4.0
305 };
306
307 // 60% of the time pick from a Gaussian-ish window around `center`; 40%
308 // of the time pick uniformly to keep regions from being monolithic.
309 let idx = if rng.bool_with_prob(0.60) {
310 let mag = (rng.range_f64(-1.0, 1.0)) * radius;
311 let signed = center as f64 + mag;
312 let wrapped = signed.rem_euclid(n_targets as f64) as usize;
313 wrapped.min(n_targets - 1)
314 } else {
315 rng.range_usize(n_targets)
316 };
317 let mut t = Target::from_index(idx);
318 // Force `Fingerer(0)` (Index Finger) for origin so first node is
319 // useful to a brand-new player.
320 if x == 0 && y == 0 {
321 t = Target::Fingerer(0);
322 }
323 t
324}
325
326fn pick_op(target: Target, rng: &mut SplitMix64) -> Op {
327 // Op availability depends on target. Each target advertises its valid
328 // ops; we pick uniformly from those.
329 let ops: &[Op] = match target {
330 Target::Fingerer(_) => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd, Op::CostMul],
331 Target::AllFingerers => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd],
332 Target::Click => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd],
333 Target::PowerupSpawn(_) => &[Op::SpawnRateMul],
334 Target::PowerupReward(_) => &[Op::EffectMul],
335 Target::PowerupDuration(_) => &[Op::EffectMul],
336 Target::Prestige => &[Op::AddPercent, Op::MulFactor],
337 Target::GreenCoinStrength => &[Op::EffectMul],
338 };
339 ops[rng.range_usize(ops.len())]
340}
341
342fn roll_magnitude(
343 op: Op,
344 rarity: Rarity,
345 force_sign: Option<bool>, // Some(true)=force boon, Some(false)=force bane
346 bane_force: bool, // overall composition: should at least 1 bane appear?
347 x: i32,
348 y: i32,
349 rng: &mut SplitMix64,
350) -> f64 {
351 // Base "strength multiplier" scales with rarity AND with manhattan
352 // distance from origin: deeper lots roll harder magnitudes.
353 let dist = x.unsigned_abs().saturating_add(y.unsigned_abs()) as f64;
354 // depth_scale cap was 20.0; tightened to 10.0 because every per-node
355 // boon stacks across hundreds of bought nodes in the late game, and
356 // a 20× depth multiplier on top of the per-roll range produced
357 // late-game players whose fps overflowed `f64` (cuques shown as `?`
358 // in the HUD, all next-buy costs `?`). The combination of a smaller
359 // cap here plus the 1000× shrink on the per-roll ranges keeps even
360 // a 1000+ node late-game stack bounded.
361 let depth_scale = 1.0 + (dist / 12.0).min(10.0);
362 let rarity_scale = match rarity {
363 Rarity::Small => 1.0,
364 Rarity::Notable => 2.5,
365 Rarity::Keystone => 5.0,
366 };
367 let s = depth_scale * rarity_scale;
368
369 let bane = match force_sign {
370 Some(true) => false,
371 Some(false) => true,
372 None => {
373 if bane_force {
374 rng.bool_with_prob(0.25)
375 } else {
376 rng.bool_with_prob(0.05)
377 }
378 }
379 };
380
381 // Per-node magnitudes are all ~1000× smaller than the first cut of
382 // the tree (which gave keystone-class buffs like ×1.17 Green Coin
383 // spawn rate; two or three stacked turned spawns into a continuous
384 // rain and FPS into `Infinity` after a single long session). The
385 // shape — boons biased positive, banes biased negative, depth and
386 // rarity scale through `s` — is identical; only the absolute scale
387 // has shrunk. Keystones still feel distinct from Smalls because the
388 // 5× rarity_scale stays, and deep regions still hit harder than
389 // origin via depth_scale; both effects just compound much more
390 // gently when 500+ nodes are owned.
391 match op {
392 Op::AddPercent => {
393 // Per-node boon at d=0 Small: +0.001% to +0.005% additive.
394 // At d=100 Keystone: ~+0.5% per node. Stack of 1000 deep
395 // keystones ≈ +5 total (additive), reasonable late-game.
396 let mag = rng.range_f64(0.00001, 0.00005) * s;
397 if bane { -mag * 0.7 } else { mag }
398 }
399 Op::MulFactor => {
400 // Multiplicative — compounds across the player's bought set.
401 // Knocked down ANOTHER 100× past the 1000× rebalance because
402 // even ×1.005 per node stacked 10k nodes deep was reaching
403 // ×5e21 total. At ×1.00005 per node, 10k nodes is ×1.65,
404 // 100k is ×148 — game stays long.
405 let boost = rng.range_f64(0.0000002, 0.000001) * s;
406 if bane {
407 let nerf = rng.range_f64(0.0000005, 0.000004).min(0.00001);
408 (1.0 - nerf).max(0.9999)
409 } else {
410 (1.0 + boost).min(1.00005)
411 }
412 }
413 Op::FlatAdd => {
414 // Flat FPS additions are linear (no compounding), so the
415 // 1000× shrink stays — these don't suffer from the runaway
416 // problem MulFactor/EffectMul do. Adding more nodes adds
417 // FPS additively, exactly the linear-grind path we want.
418 let mag = rng.range_f64(0.0005, 0.004) * s;
419 if bane { -mag * 0.5 } else { mag }
420 }
421 Op::CostMul => {
422 // Multiplicative on fingerer buy cost. Another 100× tighter
423 // so even a deep stack of discounts can't trivialize the
424 // cost ladder we're explicitly stretching.
425 let scale = rng.range_f64(0.0000002, 0.000001) * (1.0 + (dist / 30.0));
426 if bane {
427 (1.0 + scale).min(1.00005)
428 } else {
429 (1.0 - scale).max(0.99995)
430 }
431 }
432 Op::SpawnRateMul => {
433 // Multiplicative on powerup spawn frequency. The headline
434 // regression that started this thread was a single ×1.17
435 // GreenCoin SpawnRate. After the 1000× shrink the cap was
436 // ×1.005 — still produced ×148 from a 1000-node stack
437 // (constant-spawn territory). Another 100× knocks the cap
438 // to ×1.00005, so 1000 nodes → ×1.05 spawn rate, barely
439 // noticeable. Game wants to be longer; cooldowns stay long.
440 let scale = rng.range_f64(0.0000002, 0.0000012) * (1.0 + (dist / 30.0));
441 if bane {
442 (1.0 - scale).max(0.99995)
443 } else {
444 (1.0 + scale).min(1.00005)
445 }
446 }
447 Op::EffectMul => {
448 // Multiplicative AND two-stage compounding — amplifies the
449 // catch-time mult that itself gets persisted on a fingerer.
450 // This is the path that produced the 311-trillion-x
451 // PurpleCoin in the wild save. Another 100× tightening to
452 // ×1.0001 cap means 10k deep nodes → ~×2.7 catch amplifier,
453 // not ×1e43. Keeps catch-time mults inside small-integer
454 // territory across any realistic session.
455 let scale = rng.range_f64(0.0000002, 0.0000015) * (1.0 + (dist / 24.0));
456 if bane {
457 (1.0 - scale).max(0.9999)
458 } else {
459 (1.0 + scale).min(1.0001)
460 }
461 }
462 }
463}
464
465/// Cost scales with Manhattan distance from origin: deeper = pricier.
466/// Flat multiplier on every node's rolled cost — shifts the whole
467/// pricing curve up or down without bending it. Use this when "the
468/// tree feels too cheap" but the SHAPE of the ramp is correct.
469pub const NODE_COST_MULT: f64 = 5.0;
470
471/// Per-lot exponential growth rate. Each step further from the origin
472/// multiplies the node's cost by this factor (manhattan distance). The
473/// shape of the late-game wall lives here — bump it to make deep
474/// nodes balloon faster, lower it for a gentler ramp.
475///
476/// Reference: fingerers grow at 1.15^count (classic Cookie Clicker
477/// 15%-per-buy). The tree grows much steeper because each "lot step"
478/// gives only ONE upgrade, vs many buys per fingerer.
479///
480/// Bumped 1.75 → 2.5 along with the 1000× shrink in `roll_magnitude`.
481/// Per-node power is now tiny, so the player wants to buy *many*
482/// nodes; a steeper cost ramp keeps the player from sweeping the
483/// entire frontier in a single session and lets the alphabetic-suffix
484/// number range (10^36+ and beyond) become a real late-game milestone
485/// instead of a transient overflow.
486pub const NODE_COST_GROWTH: f64 = 2.5;
487
488/// Base cost at distance 0 (before rarity and jitter). The origin lot
489/// is auto-owned so this never quotes a real purchase, but it anchors
490/// the ramp: cost(d) = NODE_COST_MULT * NODE_BASE_COST * NODE_COST_GROWTH^d
491/// * rarity_factor * jitter.
492pub const NODE_BASE_COST: f64 = 50.0;
493
494pub fn roll_cost(x: i32, y: i32, rarity: Rarity) -> crate::bignum::Mag {
495 use crate::bignum::Mag;
496 let dist = x.unsigned_abs().saturating_add(y.unsigned_abs()) as f64;
497 let rarity_factor: f64 = match rarity {
498 Rarity::Small => 1.0,
499 Rarity::Notable => 5.0,
500 Rarity::Keystone => 40.0,
501 };
502 let mut rng = SplitMix64::from_coords(TREE_SEED, x, y, 0xC0);
503 let jitter = rng.range_f64(0.85, 1.25);
504 // Compute directly in log10 space so very-deep nodes (`dist` in
505 // the thousands) produce a faithful Mag instead of the old
506 // `.min(1e300)`-clamped placeholder. Each multiplicative factor is
507 // log10'd and summed; the result is `10^(sum)`.
508 let log10 = NODE_COST_MULT.log10()
509 + NODE_BASE_COST.log10()
510 + dist * NODE_COST_GROWTH.log10()
511 + rarity_factor.log10()
512 + jitter.log10();
513 // `.max` a small floor so even the cheapest origin-neighborhood node
514 // isn't free: 10 cuques minimum.
515 if log10 < 1.0 {
516 Mag::from_f64(10.0)
517 } else {
518 Mag { log10 }
519 }
520}
521
522/// True when the lots at `a` and `b` are 8-king neighbors (chebyshev
523/// distance 1). Returns false for the same lot (a node isn't its own
524/// neighbor) and for any pair further away.
525pub fn is_king_neighbor(a: TreeCoord, b: TreeCoord) -> bool {
526 let dx = (a.x - b.x).abs();
527 let dy = (a.y - b.y).abs();
528 dx <= 1 && dy <= 1 && !(dx == 0 && dy == 0)
529}
530
531/// For a populated lot, return its "anchor parent" — the deterministic
532/// king-neighbor that is GUARANTEED to have an edge to this lot.
533///
534/// Selection rules, in order:
535/// 1. Prefer an ORTHOGONAL king-neighbor at strictly smaller manhattan
536/// distance from origin that itself reaches origin. This is what
537/// controls the cobweb at origin: only origin's 4 orthogonal
538/// neighbors will anchor to origin directly, while diagonal
539/// neighbors anchor via one of the orthogonal cousins. Cuts
540/// origin's incoming anchor-edge count from 8 to 4.
541/// 2. Fall back to a DIAGONAL strictly-smaller-manhattan neighbor
542/// that reaches origin.
543///
544/// Returns `None` for origin (no parent) and for lots that fail
545/// `reaches_origin`. There is deliberately no "any populated neighbor"
546/// fallback — that used to exist and was the source of orphan islands:
547/// two adjacent populated lots whose only smaller-manhattan neighbors
548/// were all gaps would each point at the other, forming a closed loop
549/// disconnected from the rest of the tree.
550pub fn anchor_of(lot: TreeCoord) -> Option<TreeCoord> {
551 if lot == TreeCoord::ORIGIN {
552 return None;
553 }
554 if !reaches_origin(lot) {
555 return None;
556 }
557 let dist = lot.manhattan();
558 let mut candidates: Vec<TreeCoord> = neighbors_of(lot).to_vec();
559 // Sort by (manhattan, x, y) for stable tie-breaking. Uses the
560 // saturating manhattan helper so extreme coords (near i32::MIN /
561 // i32::MAX) don't overflow the abs sum.
562 candidates.sort_by_key(|n| (n.manhattan(), n.x, n.y));
563 let is_orthogonal = |n: &TreeCoord| {
564 let dx = n.x.wrapping_sub(lot.x).unsigned_abs();
565 let dy = n.y.wrapping_sub(lot.y).unsigned_abs();
566 (dx == 0 || dy == 0) && (dx + dy == 1)
567 };
568 // Pass 1: orthogonal + strictly-smaller-manhattan + reaches-origin.
569 for n in &candidates {
570 if n.manhattan() >= dist {
571 continue;
572 }
573 if !is_orthogonal(n) {
574 continue;
575 }
576 if reaches_origin(*n) {
577 return Some(*n);
578 }
579 }
580 // Pass 2: diagonal + strictly-smaller-manhattan + reaches-origin.
581 // Lets a lot whose 4 orthogonal neighbors all fail (gap or orphaned)
582 // still find a parent toward origin.
583 for n in &candidates {
584 if n.manhattan() >= dist {
585 continue;
586 }
587 if reaches_origin(*n) {
588 return Some(*n);
589 }
590 }
591 None
592}
593
594// Memoized "is this lot connected to origin via a strictly-decreasing-
595// manhattan chain of populated king-neighbors". Pure function of
596// (lot.x, lot.y) — the procgen seed and gap-roll function are both
597// deterministic — so caching results forever is sound. The map only
598// grows as the player explores; in practice it stays in the low
599// thousands of entries (one per lot the renderer ever asks about).
600thread_local! {
601 static REACHES_ORIGIN_MEMO: RefCell<HashMap<TreeCoord, bool>> = RefCell::new(HashMap::new());
602}
603
604/// True iff `lot` admits a path of populated king-neighbors back to
605/// origin where every step strictly *decreases* manhattan distance.
606/// Origin itself returns true; unpopulated lots (gap roll) return false.
607///
608/// The strict-decrease constraint is what makes the procgen orphan-free:
609/// it's impossible to form a cycle among lots if every edge in the
610/// chain reduces a non-negative integer (manhattan distance), so any
611/// successful chain terminates at origin in at most `lot.manhattan()`
612/// steps. Recursion depth is therefore bounded by manhattan distance,
613/// and the thread-local memo collapses overlapping sub-chains across
614/// the many lots the renderer asks about per frame.
615pub fn reaches_origin(lot: TreeCoord) -> bool {
616 if lot == TreeCoord::ORIGIN {
617 return true;
618 }
619 if let Some(v) = REACHES_ORIGIN_MEMO.with(|m| m.borrow().get(&lot).copied()) {
620 return v;
621 }
622 if !passes_gap_roll(lot.x, lot.y) {
623 REACHES_ORIGIN_MEMO.with(|m| m.borrow_mut().insert(lot, false));
624 return false;
625 }
626 let dist = lot.manhattan();
627 let mut result = false;
628 for n in neighbors_of(lot) {
629 if n.manhattan() < dist && reaches_origin(n) {
630 result = true;
631 break;
632 }
633 }
634 REACHES_ORIGIN_MEMO.with(|m| m.borrow_mut().insert(lot, result));
635 result
636}
637
638/// Sample a low-frequency density noise field at the midpoint between
639/// two lots. Returns `[0.0, 1.0]`. Drives the per-edge connection
640/// probability so the tree alternates between dense pockets (most
641/// adjacent pairs connect) and sinuous corridors (most don't). Biome
642/// width ≈ 8 lots so the variation reads clearly when panning.
643fn edge_density(a: TreeCoord, b: TreeCoord) -> f64 {
644 let mx = (a.x as f64 + b.x as f64) * 0.5;
645 let my = (a.y as f64 + b.y as f64) * 0.5;
646 value_noise_2d(TREE_SEED ^ 0xDE, mx * 0.12, my * 0.12)
647}
648
649/// Procgen decision: does an edge exist between two neighboring lots?
650/// Returns false for non-neighbors. Both lots must actually contain a
651/// node — call `node_at` first.
652///
653/// Three sources of edges, OR'd together:
654/// 1. **Anchor edges** — every non-origin populated lot has a
655/// guaranteed edge to its `anchor_of` neighbor. Keeps every node
656/// reachable from origin transitively even when the random rolls
657/// come up empty.
658/// 2. **Orthogonal procgen edges** — random roll with probability
659/// driven by a low-frequency density-noise field. Probability
660/// ranges from ~50% in "sinuous" regions to ~95% in "dense"
661/// pockets. Adjacent boxes might NOT be connected in sinuous
662/// zones, which is the explorational variety the player wants.
663/// 3. **Diagonal procgen edges** — same noise-driven probability
664/// scaled to a lower band (~8% sparse, ~28% dense), plus the
665/// hard suppression when both L-bend corner lots are occupied
666/// (the visual line would otherwise cross an unrelated box).
667pub fn edge_exists(a: TreeCoord, b: TreeCoord) -> bool {
668 if !is_king_neighbor(a, b) {
669 return false;
670 }
671 // Anchor edges always exist — preserve "no orphans" regardless of
672 // the noise-driven probability dropping to zero.
673 if anchor_of(a) == Some(b) || anchor_of(b) == Some(a) {
674 return true;
675 }
676 let dx = (a.x - b.x).abs();
677 let dy = (a.y - b.y).abs();
678 let diagonal = dx == 1 && dy == 1;
679 // Canonicalize: smaller (x, y) first.
680 let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
681 (a, b)
682 } else {
683 (b, a)
684 };
685 if diagonal {
686 // Both L-bend corner lots occupied → suppress to avoid visually
687 // crossing an unrelated box.
688 let mid_h = TreeCoord::new(hi.x, lo.y);
689 let mid_v = TreeCoord::new(lo.x, hi.y);
690 if node_at(mid_h.x, mid_h.y).is_some() && node_at(mid_v.x, mid_v.y).is_some() {
691 return false;
692 }
693 }
694 let density = edge_density(a, b);
695 let p = if diagonal {
696 // Diagonals are rare bonus shortcuts. 2-10% range — most
697 // diagonals only exist as anchor edges (i.e. when a node's
698 // parent happens to be a diagonal neighbor).
699 0.02 + 0.08 * density
700 } else {
701 // Orthogonal: 12-35%. Combined with the anchor-edge
702 // guarantee (every non-origin node has exactly one anchor
703 // edge to its parent toward origin), this gives a clear
704 // "spine of the tree" plus occasional extra branches. Origin's
705 // 8 king-neighbors still all get an anchor edge to the cuque,
706 // but EXTRA edges between siblings are now uncommon — no more
707 // cobweb at origin.
708 0.12 + 0.23 * density
709 };
710 let mut rng = SplitMix64::from_coords(
711 TREE_SEED,
712 lo.x.wrapping_add(hi.x.wrapping_mul(7919)),
713 lo.y.wrapping_add(hi.y.wrapping_mul(6151)),
714 SALT_EDGE,
715 );
716 rng.bool_with_prob(p)
717}
718
719/// For a diagonal edge between `a` and `b`, return the "L-bend corner
720/// lot" the renderer should route through — the corner lot that's
721/// empty. `None` for non-diagonal pairs (renderer chooses by longer-axis
722/// heuristic instead).
723///
724/// Invariant: if `edge_exists(a, b)` is true for a diagonal pair, at
725/// least one of the two corner lots is empty (the other diagonal-suppress
726/// rule above guarantees this), so this returns `Some` for every
727/// existing diagonal edge.
728pub fn diagonal_route_via(a: TreeCoord, b: TreeCoord) -> Option<TreeCoord> {
729 let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
730 (a, b)
731 } else {
732 (b, a)
733 };
734 let dx = (hi.x - lo.x).abs();
735 let dy = (hi.y - lo.y).abs();
736 if dx != 1 || dy != 1 {
737 return None;
738 }
739 let mid_h = TreeCoord::new(hi.x, lo.y);
740 let mid_v = TreeCoord::new(lo.x, hi.y);
741 if node_at(mid_h.x, mid_h.y).is_none() {
742 return Some(mid_h);
743 }
744 if node_at(mid_v.x, mid_v.y).is_none() {
745 return Some(mid_v);
746 }
747 None
748}
749
750/// The 8 neighboring lots of `c`.
751pub fn neighbors_of(c: TreeCoord) -> [TreeCoord; 8] {
752 [
753 TreeCoord::new(c.x - 1, c.y - 1),
754 TreeCoord::new(c.x, c.y - 1),
755 TreeCoord::new(c.x + 1, c.y - 1),
756 TreeCoord::new(c.x - 1, c.y),
757 TreeCoord::new(c.x + 1, c.y),
758 TreeCoord::new(c.x - 1, c.y + 1),
759 TreeCoord::new(c.x, c.y + 1),
760 TreeCoord::new(c.x + 1, c.y + 1),
761 ]
762}
763
764/// Helper: all `TreeCoord`s that are king-neighbors of `c` AND have a node.
765pub fn neighbors_with_nodes(c: TreeCoord) -> Vec<TreeCoord> {
766 neighbors_of(c)
767 .into_iter()
768 .filter(|n| node_at(n.x, n.y).is_some())
769 .collect()
770}
771
772/// Cells along the rendered edge between two nodes, in canonical
773/// lo→hi lot order (lex on `(x, y)`). Returns an empty Vec if either
774/// endpoint has no node.
775///
776/// Canonical direction is load-bearing: `bresenham_path(A, B)` and
777/// `bresenham_path(B, A)` produce DIFFERENT cell sequences for the same
778/// endpoints, so we sort the inputs here. Pre-buy and post-buy
779/// rendering, plus the unlock-anim's completion check, all call this
780/// function with the same pair of lots — without canonicalization the
781/// wave would energize a different staircase shape from the grey base
782/// line, and you'd see the path geometry flip the moment the wave
783/// started.
784///
785/// The case-split mirrors `ui::tree::draw_edge`:
786/// - boxes vertically aligned within ±2 columns → straight vertical run
787/// at the midpoint x.
788/// - boxes horizontally aligned within ±2 rows → straight horizontal
789/// run at the midpoint y.
790/// - otherwise → Bresenham staircase between the two centers.
791pub fn edge_path_cells(a: TreeCoord, b: TreeCoord) -> Vec<(i32, i32)> {
792 let (a, b) = if (a.x, a.y) <= (b.x, b.y) {
793 (a, b)
794 } else {
795 (b, a)
796 };
797 let Some(an) = node_at(a.x, a.y) else {
798 return Vec::new();
799 };
800 let Some(bn) = node_at(b.x, b.y) else {
801 return Vec::new();
802 };
803 let acx = an.box_x + (an.box_w as i32) / 2;
804 let acy = an.box_y + (an.box_h as i32) / 2;
805 let bcx = bn.box_x + (bn.box_w as i32) / 2;
806 let bcy = bn.box_y + (bn.box_h as i32) / 2;
807
808 if (acx - bcx).abs() <= 2 {
809 let mid_x = (acx + bcx) / 2;
810 let step: i32 = if acy <= bcy { 1 } else { -1 };
811 let mut path = Vec::new();
812 let mut y = acy;
813 loop {
814 path.push((mid_x, y));
815 if y == bcy {
816 break;
817 }
818 y += step;
819 }
820 return path;
821 }
822 if (acy - bcy).abs() <= 2 {
823 let mid_y = (acy + bcy) / 2;
824 let step: i32 = if acx <= bcx { 1 } else { -1 };
825 let mut path = Vec::new();
826 let mut x = acx;
827 loop {
828 path.push((x, mid_y));
829 if x == bcx {
830 break;
831 }
832 x += step;
833 }
834 return path;
835 }
836 bresenham_path(acx, acy, bcx, bcy)
837}
838
839/// Count cells at the START of `path` whose canvas-grid coords lie
840/// inside the rect `(box_x, box_y, box_w, box_h)`. Used by the edge
841/// renderer + the unlock-anim's completion check to skip the "inside
842/// the source box" prefix when seeding the wavefront. Iteration stops
843/// at the first cell that's outside the rect — interior gaps don't
844/// inflate the count.
845pub fn count_leading_in_rect(
846 path: &[(i32, i32)],
847 box_x: i32,
848 box_y: i32,
849 box_w: u16,
850 box_h: u16,
851) -> usize {
852 let mut count = 0;
853 for &(cx, cy) in path {
854 if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
855 count += 1;
856 } else {
857 break;
858 }
859 }
860 count
861}
862
863/// Mirror of `count_leading_in_rect` but counting from the END of
864/// the path inward.
865pub fn count_trailing_in_rect(
866 path: &[(i32, i32)],
867 box_x: i32,
868 box_y: i32,
869 box_w: u16,
870 box_h: u16,
871) -> usize {
872 let mut count = 0;
873 for &(cx, cy) in path.iter().rev() {
874 if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
875 count += 1;
876 } else {
877 break;
878 }
879 }
880 count
881}
882
883/// Bresenham-style staircase between (ax, ay) and (bx, by) in
884/// canvas-grid coords. Single-axis steps only, interleaved by
885/// cross-multiplied normalized progress so the staircase doesn't
886/// degenerate into a one-big-L jolt. Returns cells in start→end order.
887pub fn bresenham_path(ax: i32, ay: i32, bx: i32, by: i32) -> Vec<(i32, i32)> {
888 let mut path = Vec::with_capacity(((bx - ax).abs() + (by - ay).abs() + 1) as usize);
889 let mut x = ax;
890 let mut y = ay;
891 let dx = (bx - ax).abs();
892 let dy = (by - ay).abs();
893 let sx: i32 = (bx - ax).signum();
894 let sy: i32 = (by - ay).signum();
895 path.push((x, y));
896 let mut xs: i64 = 0;
897 let mut ys: i64 = 0;
898 while x != bx || y != by {
899 let step_x_now = if x == bx {
900 false
901 } else if y == by {
902 true
903 } else {
904 (xs + 1) * (dy as i64) <= (ys + 1) * (dx as i64)
905 };
906 if step_x_now {
907 x += sx;
908 xs += 1;
909 } else {
910 y += sy;
911 ys += 1;
912 }
913 path.push((x, y));
914 }
915 path
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use crate::game::fingerer::FINGERERS;
922
923 #[test]
924 fn origin_always_has_a_node() {
925 assert!(
926 node_at(0, 0).is_some(),
927 "origin lot must always be populated"
928 );
929 }
930
931 #[test]
932 fn origin_is_index_finger_small() {
933 let n = node_at(0, 0).unwrap();
934 assert_eq!(n.rarity, Rarity::Small);
935 assert!(
936 matches!(n.dominant_target, Target::Fingerer(0)),
937 "origin lot should target Index Finger so the first buy is useful"
938 );
939 }
940
941 #[test]
942 fn generation_is_deterministic() {
943 // Use a lot guaranteed to be populated regardless of GAP_PROBABILITY
944 // tuning — origin neighborhood (|x|<=1 AND |y|<=1) is always
945 // populated. (3, -2) is now a gap with GAP_PROBABILITY = 0.35.)
946 let a = node_at(1, 1).unwrap();
947 let b = node_at(1, 1).unwrap();
948 assert_eq!(a.lot, b.lot);
949 assert_eq!(a.rarity, b.rarity);
950 assert_eq!(a.box_x, b.box_x);
951 assert_eq!(a.box_y, b.box_y);
952 assert_eq!(a.cost, b.cost);
953 assert_eq!(a.title, b.title);
954 assert_eq!(a.primitives.len(), b.primitives.len());
955 for (pa, pb) in a.primitives.iter().zip(b.primitives.iter()) {
956 assert_eq!(pa.op, pb.op);
957 assert_eq!(pa.target, pb.target);
958 assert_eq!(pa.magnitude, pb.magnitude);
959 }
960 }
961
962 #[test]
963 fn box_fits_within_lot_bounds() {
964 // Sample a broad area; every generated box must stay inside its
965 // lot (no overlapping into neighbor lots, no crossing the canvas
966 // grid lines we'd later use for edge routing).
967 for x in -10..=10 {
968 for y in -10..=10 {
969 if let Some(n) = node_at(x, y) {
970 let lot_min_x = x * LOT_W;
971 let lot_max_x = (x + 1) * LOT_W;
972 let lot_min_y = y * LOT_H;
973 let lot_max_y = (y + 1) * LOT_H;
974 assert!(
975 n.box_x >= lot_min_x,
976 "box {} below lot min {}",
977 n.box_x,
978 lot_min_x
979 );
980 assert!(
981 (n.box_x + n.box_w as i32) <= lot_max_x,
982 "box at ({x},{y}) overflows lot right edge"
983 );
984 assert!(n.box_y >= lot_min_y);
985 assert!((n.box_y + n.box_h as i32) <= lot_max_y);
986 }
987 }
988 }
989 }
990
991 #[test]
992 fn gaps_exist_outside_origin_neighborhood() {
993 // Count populated vs empty lots in a far-out region.
994 let mut pop = 0;
995 let mut empty = 0;
996 for x in 10..=20 {
997 for y in 10..=20 {
998 if node_at(x, y).is_some() {
999 pop += 1;
1000 } else {
1001 empty += 1;
1002 }
1003 }
1004 }
1005 assert!(empty > 0, "tree must have gaps");
1006 assert!(pop > 0, "tree must also have nodes");
1007 }
1008
1009 #[test]
1010 fn edges_are_deterministic_and_symmetric() {
1011 let a = TreeCoord::new(3, 4);
1012 let b = TreeCoord::new(4, 5);
1013 assert_eq!(edge_exists(a, b), edge_exists(b, a));
1014 assert_eq!(edge_exists(a, b), edge_exists(a, b));
1015 }
1016
1017 #[test]
1018 fn non_neighbors_never_have_edges() {
1019 let a = TreeCoord::new(0, 0);
1020 let b = TreeCoord::new(5, 5);
1021 assert!(!edge_exists(a, b));
1022 }
1023
1024 #[test]
1025 fn keystones_have_at_least_one_bane() {
1026 // Walk a large region; for every keystone, assert composition.
1027 for x in -30..=30 {
1028 for y in -30..=30 {
1029 if let Some(n) = node_at(x, y)
1030 && n.rarity == Rarity::Keystone
1031 {
1032 assert!(
1033 n.primitives.iter().any(|p| p.is_bane()),
1034 "keystone at ({x},{y}) has no bane primitive: {:?}",
1035 n.primitives
1036 );
1037 assert!(
1038 n.primitives.iter().any(|p| !p.is_bane()),
1039 "keystone at ({x},{y}) has no boon primitive"
1040 );
1041 }
1042 }
1043 }
1044 }
1045
1046 #[test]
1047 fn cost_grows_with_distance() {
1048 let near = roll_cost(0, 0, Rarity::Small);
1049 let far = roll_cost(20, 20, Rarity::Small);
1050 // log10(far) - log10(near) >= 2 means far >= 100x near.
1051 assert!(
1052 far.log10 - near.log10 > 2.0,
1053 "far ({}) should be >> near ({})",
1054 crate::format::big_mag(far),
1055 crate::format::big_mag(near)
1056 );
1057 }
1058
1059 /// Diagnostic table — not a real assertion, prints the live ramp at
1060 /// the current `NODE_COST_MULT` / `NODE_COST_GROWTH` values. Gated
1061 /// behind `#[ignore]` so it doesn't run in normal CI; invoke
1062 /// explicitly when tuning balance:
1063 ///
1064 /// `cargo test --release dump_cost_table -- --ignored --nocapture`
1065 #[test]
1066 #[ignore]
1067 fn dump_cost_table() {
1068 eprintln!(
1069 "NODE_COST_MULT={NODE_COST_MULT} NODE_COST_GROWTH={NODE_COST_GROWTH} base={NODE_BASE_COST}"
1070 );
1071 eprintln!(
1072 "{:>4} {:>14} {:>14} {:>14}",
1073 "dist", "Small", "Notable", "Keystone"
1074 );
1075 for &d in &[1, 2, 3, 5, 7, 10, 12, 15, 18, 20, 25, 30, 35, 40, 50i32] {
1076 // Use (d, 0) so manhattan == d.
1077 let small = roll_cost(d, 0, Rarity::Small);
1078 let notable = roll_cost(d, 0, Rarity::Notable);
1079 let keystone = roll_cost(d, 0, Rarity::Keystone);
1080 eprintln!(
1081 "{:>4} {:>14} {:>14} {:>14}",
1082 d,
1083 crate::format::big_mag(small),
1084 crate::format::big_mag(notable),
1085 crate::format::big_mag(keystone)
1086 );
1087 }
1088 }
1089
1090 #[allow(dead_code)]
1091 fn format_cost(c: f64) -> String {
1092 if c >= 1e15 {
1093 format!("{:.2} quad", c / 1e15)
1094 } else if c >= 1e12 {
1095 format!("{:.2} T", c / 1e12)
1096 } else if c >= 1e9 {
1097 format!("{:.2} B", c / 1e9)
1098 } else if c >= 1e6 {
1099 format!("{:.2} M", c / 1e6)
1100 } else if c >= 1e3 {
1101 format!("{:.2} k", c / 1e3)
1102 } else {
1103 format!("{c:.0}")
1104 }
1105 }
1106
1107 #[test]
1108 fn box_dims_match_rarity() {
1109 assert_eq!(Rarity::Small.box_dims(), (14, 3));
1110 assert_eq!(Rarity::Notable.box_dims(), (18, 5));
1111 assert_eq!(Rarity::Keystone.box_dims(), (24, 7));
1112 }
1113
1114 #[test]
1115 fn fingerer_target_index_in_range() {
1116 // Any generated Fingerer(idx) must be a valid catalog index.
1117 for x in -10..=10 {
1118 for y in -10..=10 {
1119 if let Some(n) = node_at(x, y) {
1120 for p in &n.primitives {
1121 if let Target::Fingerer(i) = p.target {
1122 assert!(
1123 (i as usize) < FINGERERS.len(),
1124 "fingerer target idx {i} out of range"
1125 );
1126 }
1127 }
1128 }
1129 }
1130 }
1131 }
1132
1133 #[test]
1134 fn no_orphan_components_within_visible_radius() {
1135 use std::collections::{HashSet, VecDeque};
1136 // 40 covers a generous "explore for an hour" radius. Before the
1137 // `reaches_origin` orphan check this would surface ~50 orphans;
1138 // the player saw them as isolated 2-node islands in the panned
1139 // canvas with no path back to The Cuque.
1140 let radius: i32 = 40;
1141 let mut populated: HashSet<TreeCoord> = HashSet::new();
1142 for x in -radius..=radius {
1143 for y in -radius..=radius {
1144 if node_at(x, y).is_some() {
1145 populated.insert(TreeCoord::new(x, y));
1146 }
1147 }
1148 }
1149 let mut reached: HashSet<TreeCoord> = HashSet::new();
1150 let mut q: VecDeque<TreeCoord> = VecDeque::new();
1151 q.push_back(TreeCoord::ORIGIN);
1152 reached.insert(TreeCoord::ORIGIN);
1153 while let Some(c) = q.pop_front() {
1154 for n in neighbors_of(c) {
1155 if !populated.contains(&n) {
1156 continue;
1157 }
1158 if reached.contains(&n) {
1159 continue;
1160 }
1161 if edge_exists(c, n) {
1162 reached.insert(n);
1163 q.push_back(n);
1164 }
1165 }
1166 }
1167 let orphans: Vec<_> = populated.difference(&reached).copied().collect();
1168 if !orphans.is_empty() {
1169 for o in orphans.iter().take(20) {
1170 eprintln!("orphan at ({}, {}) manhattan={}", o.x, o.y, o.manhattan());
1171 }
1172 }
1173 assert!(
1174 orphans.is_empty(),
1175 "{} orphan(s) detected in radius {}",
1176 orphans.len(),
1177 radius
1178 );
1179 }
1180
1181 #[test]
1182 fn multiplicative_primitives_stay_under_per_node_caps() {
1183 // Per-node caps are the load-bearing balance knob for keeping the
1184 // game long: every multiplicative op compounds across the bought
1185 // set, so a generous cap turns into runaway numbers. Caps were
1186 // tightened ANOTHER 100× past the initial rebalance after the
1187 // "make the game longer" pass:
1188 // MulFactor / CostMul / SpawnRateMul: ×1.00005
1189 // EffectMul: ×1.0001
1190 // EffectMul gets a slightly looser ceiling because it's
1191 // applied at catch time (once per powerup), not on every fps
1192 // tick like the others.
1193 const MUL_CAP: f64 = 1.00005;
1194 const EFFECT_CAP: f64 = 1.0001;
1195 for x in -60..=60 {
1196 for y in -60..=60 {
1197 if let Some(n) = node_at(x, y) {
1198 for p in &n.primitives {
1199 match p.op {
1200 Op::MulFactor | Op::CostMul | Op::SpawnRateMul => {
1201 assert!(
1202 p.magnitude <= MUL_CAP + 1e-12,
1203 "{:?} at ({x},{y}) is {} (cap {MUL_CAP})",
1204 p.op,
1205 p.magnitude
1206 );
1207 }
1208 Op::EffectMul => {
1209 assert!(
1210 p.magnitude <= EFFECT_CAP + 1e-12,
1211 "EffectMul at ({x},{y}) is {} (cap {EFFECT_CAP})",
1212 p.magnitude
1213 );
1214 }
1215 _ => {}
1216 }
1217 }
1218 }
1219 }
1220 }
1221 }
1222
1223 #[test]
1224 fn no_zero_magnitude_primitives() {
1225 // Procgen should never produce a useless 0% / ×1.0 primitive.
1226 for x in -10..=10 {
1227 for y in -10..=10 {
1228 if let Some(n) = node_at(x, y) {
1229 for p in &n.primitives {
1230 assert!(p.magnitude != 0.0, "zero-magnitude primitive at ({x},{y})");
1231 if matches!(
1232 p.op,
1233 Op::MulFactor | Op::CostMul | Op::SpawnRateMul | Op::EffectMul
1234 ) {
1235 assert!(
1236 (p.magnitude - 1.0).abs() > 1e-9,
1237 "identity-magnitude mul-style primitive at ({x},{y})"
1238 );
1239 }
1240 }
1241 }
1242 }
1243 }
1244 }
1245}