Skip to main content

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}