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 super::coord::TreeCoord;
18use super::naming::make_title;
19use super::noise::{fbm_2d, value_noise_2d};
20use super::primitive::{Op, Primitive, Target};
21use super::rng::SplitMix64;
22use super::seed::TREE_SEED;
23
24/// Size of one "lot" cell in the infinite tree grid, in terminal cells.
25/// 36 wide × 10 tall comfortably holds a Notable box (16×5) with breathing
26/// room for edge routing.
27pub const LOT_W: i32 = 36;
28pub const LOT_H: i32 = 10;
29
30/// Probability that a given lot is *empty* (no node). Creates organic gaps
31/// in the procedural tree, mimicking PoE's sparse regions. Bumped from
32/// 0.20 to 0.35 after the orthogonal-edge-always-connects rule: with
33/// every adjacent pair guaranteed to be linked, a lower gap rate made
34/// the tree feel like a dense lattice instead of a branching graph.
35/// More gaps + always-connected adjacency reads as a sparser, more
36/// tree-like topology.
37const GAP_PROBABILITY: f64 = 0.35;
38
39/// Origin lot bias: lots near (0, 0) are more likely to be populated AND
40/// more likely to carry small/notable nodes (vs. keystones), so the player's
41/// starting neighborhood is dense and approachable. Within `BIAS_RADIUS`
42/// lots of origin, gappiness is reduced and rarity is capped.
43const BIAS_RADIUS: i32 = 4;
44
45/// Visual classification, computed from the rolled primitive stack. Drives
46/// box dimensions, edge weight, and biome tinting in the renderer.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum Rarity {
49    Small,
50    Notable,
51    Keystone,
52}
53
54impl Rarity {
55    pub fn box_dims(self) -> (u16, u16) {
56        match self {
57            Rarity::Small => (14, 3),
58            Rarity::Notable => (18, 5),
59            Rarity::Keystone => (24, 7),
60        }
61    }
62}
63
64/// A single tree node, regenerated from `(TREE_SEED, lot)` on demand.
65#[derive(Clone, Debug)]
66pub struct NodeSpec {
67    pub lot: TreeCoord,
68    /// Absolute position+size of the rendered box, in terminal cells, in
69    /// "canvas" coordinates. The renderer translates these into screen
70    /// coords by subtracting the pan offset.
71    pub box_x: i32,
72    pub box_y: i32,
73    pub box_w: u16,
74    pub box_h: u16,
75    pub rarity: Rarity,
76    pub primitives: Vec<Primitive>,
77    pub cost: f64,
78    pub title: String,
79    /// Dominant target hint — used by the renderer for biome-color tinting.
80    /// Picked as the target of the first primitive in the rolled stack.
81    pub dominant_target: Target,
82    /// `true` only for the (0, 0) lot — the "anchor" cuque-sprite node.
83    /// Anchors carry no primitives, no cost, are auto-owned at startup,
84    /// and the renderer paints `BISCUIT_TINY` instead of a regular box.
85    /// Every other node in the tree branches outward from this anchor.
86    pub is_anchor: bool,
87}
88
89/// Salt constants — keep all of generation's `SplitMix64::from_coords` calls
90/// distinct so they don't share an RNG stream.
91const SALT_NODE: u64 = 0xA1;
92const SALT_EDGE: u64 = 0xED;
93const SALT_GAP: u64 = 0x6A;
94
95/// Pure-local "is this lot allowed to hold a node?" predicate. Doesn't
96/// recurse, doesn't check neighbors — just the gap roll and the
97/// always-populated origin neighborhood. Composing this with the
98/// has-at-least-one-populated-neighbor check (in `node_at`) is how the
99/// procgen suppresses truly-isolated lots.
100fn passes_gap_roll(x: i32, y: i32) -> bool {
101    let is_origin_neighborhood = x.abs() <= 1 && y.abs() <= 1;
102    if is_origin_neighborhood {
103        return true;
104    }
105    let mut gap_rng = SplitMix64::from_coords(TREE_SEED, x, y, SALT_GAP);
106    let near_origin = x.abs() <= BIAS_RADIUS && y.abs() <= BIAS_RADIUS;
107    let gap_p = if near_origin {
108        GAP_PROBABILITY * 0.5
109    } else {
110        GAP_PROBABILITY
111    };
112    !gap_rng.bool_with_prob(gap_p)
113}
114
115/// Build the anchor (origin) `NodeSpec`. Anchors are special:
116/// - no primitives (no effect on the FPS / click / cost calc)
117/// - cost 0
118/// - rendered as `BISCUIT_TINY` instead of a normal box
119/// - auto-owned in `GameState::default` / `migrate_runtime` so the
120///   player's starting frontier is the anchor's king-neighbors
121fn anchor_spec() -> NodeSpec {
122    // Center the 16×8 cuque inside the 36×10 lot at canvas origin.
123    let box_w: u16 = 16;
124    let box_h: u16 = 8;
125    let off_x = ((LOT_W as u16).saturating_sub(box_w) / 2) as i32;
126    let off_y = ((LOT_H as u16).saturating_sub(box_h) / 2) as i32;
127    NodeSpec {
128        lot: TreeCoord::ORIGIN,
129        box_x: off_x,
130        box_y: off_y,
131        box_w,
132        box_h,
133        rarity: Rarity::Small,
134        primitives: Vec::new(),
135        cost: 0.0,
136        title: "The Cuque".to_string(),
137        dominant_target: Target::Fingerer(0),
138        is_anchor: true,
139    }
140}
141
142/// Procgen entry point: returns the node at lot `(x, y)`, or `None` if the
143/// lot is empty.
144pub fn node_at(x: i32, y: i32) -> Option<NodeSpec> {
145    // Origin lot is the anchor — special spec, always present.
146    if x == 0 && y == 0 {
147        return Some(anchor_spec());
148    }
149    if !passes_gap_roll(x, y) {
150        return None;
151    }
152    // Suppress truly-isolated lots — populated lots that have ZERO
153    // populated king-neighbors. Such nodes would be unreachable forever
154    // (no anchor edge, no procgen edge possible) and are confusing
155    // visual noise. With the 80% population rate this only ever fires
156    // on lots with 8 consecutive gap rolls (P ≈ 2.6 × 10⁻⁶), but those
157    // do appear deep in the canvas — and they look like bugs.
158    let has_pop_neighbor = neighbors_of(TreeCoord::new(x, y))
159        .iter()
160        .any(|n| passes_gap_roll(n.x, n.y));
161    if !has_pop_neighbor {
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    let depth_scale = 1.0 + (dist / 12.0).min(20.0); // capped so keystones don't run away
355    let rarity_scale = match rarity {
356        Rarity::Small => 1.0,
357        Rarity::Notable => 2.5,
358        Rarity::Keystone => 5.0,
359    };
360    let s = depth_scale * rarity_scale;
361
362    let bane = match force_sign {
363        Some(true) => false,
364        Some(false) => true,
365        None => {
366            if bane_force {
367                rng.bool_with_prob(0.25)
368            } else {
369                rng.bool_with_prob(0.05)
370            }
371        }
372    };
373
374    match op {
375        Op::AddPercent => {
376            // Small node: 1-5% additive; Keystone boon: up to 40%+.
377            let mag = rng.range_f64(0.01, 0.05) * s;
378            if bane { -mag * 0.7 } else { mag }
379        }
380        Op::MulFactor => {
381            // Multiplicative boons: 1.05× small, 2-5× keystone.
382            let boost = rng.range_f64(0.02, 0.10) * s; // additive 'extra'
383            if bane {
384                // Bane mul: 0.5x ... 0.85x (rarer); deeper = more punishing
385                let nerf = rng.range_f64(0.05, 0.40).min(0.50);
386                (1.0 - nerf).max(0.01)
387            } else {
388                1.0 + boost
389            }
390        }
391        Op::FlatAdd => {
392            let mag = rng.range_f64(0.5, 4.0) * s;
393            if bane { -mag * 0.5 } else { mag }
394        }
395        Op::CostMul => {
396            // < 1 is boon (discount), > 1 is bane (inflation).
397            let scale = rng.range_f64(0.02, 0.10) * (1.0 + (dist / 30.0));
398            if bane {
399                (1.0 + scale).min(2.0)
400            } else {
401                (1.0 - scale).max(0.50)
402            }
403        }
404        Op::SpawnRateMul => {
405            // > 1 is boon (more frequent), < 1 is bane (rarer).
406            let scale = rng.range_f64(0.02, 0.12) * (1.0 + (dist / 30.0));
407            if bane {
408                (1.0 - scale).max(0.30)
409            } else {
410                (1.0 + scale).min(3.0)
411            }
412        }
413        Op::EffectMul => {
414            // > 1 is boon, < 1 is bane.
415            let scale = rng.range_f64(0.02, 0.15) * (1.0 + (dist / 24.0));
416            if bane {
417                (1.0 - scale).max(0.10)
418            } else {
419                (1.0 + scale).min(5.0)
420            }
421        }
422    }
423}
424
425/// Cost scales with Manhattan distance from origin: deeper = pricier.
426/// Flat multiplier on every node's rolled cost — shifts the whole
427/// pricing curve up or down without bending it. Use this when "the
428/// tree feels too cheap" but the SHAPE of the ramp is correct.
429pub const NODE_COST_MULT: f64 = 5.0;
430
431/// Per-lot exponential growth rate. Each step further from the origin
432/// multiplies the node's cost by this factor (manhattan distance). The
433/// shape of the late-game wall lives here — bump it to make deep
434/// nodes balloon faster, lower it for a gentler ramp.
435///
436/// Reference: fingerers grow at 1.15^count (classic Cookie Clicker
437/// 15%-per-buy). The tree grows much steeper because each "lot step"
438/// gives only ONE upgrade, vs many buys per fingerer.
439pub const NODE_COST_GROWTH: f64 = 1.75;
440
441/// Base cost at distance 0 (before rarity and jitter). The origin lot
442/// is auto-owned so this never quotes a real purchase, but it anchors
443/// the ramp: cost(d) = NODE_COST_MULT * NODE_BASE_COST * NODE_COST_GROWTH^d
444///                    * rarity_factor * jitter.
445pub const NODE_BASE_COST: f64 = 50.0;
446
447pub fn roll_cost(x: i32, y: i32, rarity: Rarity) -> f64 {
448    let dist = x.unsigned_abs().saturating_add(y.unsigned_abs()) as f64;
449    let depth_factor = NODE_COST_GROWTH.powf(dist);
450    let rarity_factor = match rarity {
451        Rarity::Small => 1.0,
452        Rarity::Notable => 5.0,
453        Rarity::Keystone => 40.0,
454    };
455    // Tiny per-lot jitter so adjacent same-rarity nodes don't all cost
456    // exactly the same.
457    let mut rng = SplitMix64::from_coords(TREE_SEED, x, y, 0xC0);
458    let jitter = rng.range_f64(0.85, 1.25);
459    // `NODE_COST_GROWTH.powf(dist)` overflows to `f64::INFINITY` around
460    // dist ≈ 1232 (at growth=1.75). Clamp to a safe ceiling so the
461    // info-pane "Cost: ?" path is replaced by a real finite quote — the
462    // player won't ever afford it anyway, but the rendering and the
463    // `affordable_cuques() >= cost` check both behave better with a
464    // concrete number.
465    let raw = NODE_COST_MULT * NODE_BASE_COST * depth_factor * rarity_factor * jitter;
466    raw.min(1e300).floor().max(10.0)
467}
468
469/// True when the lots at `a` and `b` are 8-king neighbors (chebyshev
470/// distance 1). Returns false for the same lot (a node isn't its own
471/// neighbor) and for any pair further away.
472pub fn is_king_neighbor(a: TreeCoord, b: TreeCoord) -> bool {
473    let dx = (a.x - b.x).abs();
474    let dy = (a.y - b.y).abs();
475    dx <= 1 && dy <= 1 && !(dx == 0 && dy == 0)
476}
477
478/// For a populated lot, return its "anchor parent" — the deterministic
479/// king-neighbor that is GUARANTEED to have an edge to this lot.
480///
481/// Selection rules, in order:
482///   1. Prefer an ORTHOGONAL king-neighbor at strictly smaller manhattan
483///      distance from origin. This is what controls the cobweb at
484///      origin: only origin's 4 orthogonal neighbors will anchor to
485///      origin directly, while diagonal neighbors anchor via one of the
486///      orthogonal cousins. Cuts origin's incoming anchor-edge count
487///      from 8 to 4, halving the visual density.
488///   2. Fall back to a DIAGONAL strictly-smaller-manhattan neighbor if
489///      no orthogonal candidate exists (happens when all 4 orth
490///      neighbors are gaps — rare but possible at gap_p=0.35).
491///   3. Last resort: any populated king-neighbor (same/greater
492///      manhattan). Belt-and-suspenders; almost never triggers.
493///
494/// Returns `None` for origin (no parent) and for unpopulated lots.
495pub fn anchor_of(lot: TreeCoord) -> Option<TreeCoord> {
496    if lot == TreeCoord::ORIGIN {
497        return None;
498    }
499    if !passes_gap_roll(lot.x, lot.y) {
500        return None;
501    }
502    let dist = lot.manhattan();
503    let mut candidates: Vec<TreeCoord> = neighbors_of(lot).to_vec();
504    // Sort by (manhattan, x, y) for stable tie-breaking. Uses the
505    // saturating manhattan helper so extreme coords (near i32::MIN /
506    // i32::MAX) don't overflow the abs sum.
507    candidates.sort_by_key(|n| (n.manhattan(), n.x, n.y));
508    let is_orthogonal = |n: &TreeCoord| {
509        let dx = n.x.wrapping_sub(lot.x).unsigned_abs();
510        let dy = n.y.wrapping_sub(lot.y).unsigned_abs();
511        (dx == 0 || dy == 0) && (dx + dy == 1)
512    };
513    // Pass 1: orthogonal + strictly-smaller-manhattan + populated.
514    for n in &candidates {
515        if n.manhattan() >= dist {
516            continue;
517        }
518        if !is_orthogonal(n) {
519            continue;
520        }
521        if passes_gap_roll(n.x, n.y) {
522            return Some(*n);
523        }
524    }
525    // Pass 2: diagonal + strictly-smaller-manhattan + populated. Lets
526    // a lot whose 4 orthogonal neighbors are all gaps still find a
527    // parent toward origin.
528    for n in &candidates {
529        if n.manhattan() >= dist {
530            continue;
531        }
532        if passes_gap_roll(n.x, n.y) {
533            return Some(*n);
534        }
535    }
536    // Pass 3: any populated neighbor. Last resort.
537    for n in &candidates {
538        if passes_gap_roll(n.x, n.y) {
539            return Some(*n);
540        }
541    }
542    None
543}
544
545/// Sample a low-frequency density noise field at the midpoint between
546/// two lots. Returns `[0.0, 1.0]`. Drives the per-edge connection
547/// probability so the tree alternates between dense pockets (most
548/// adjacent pairs connect) and sinuous corridors (most don't). Biome
549/// width ≈ 8 lots so the variation reads clearly when panning.
550fn edge_density(a: TreeCoord, b: TreeCoord) -> f64 {
551    let mx = (a.x as f64 + b.x as f64) * 0.5;
552    let my = (a.y as f64 + b.y as f64) * 0.5;
553    value_noise_2d(TREE_SEED ^ 0xDE, mx * 0.12, my * 0.12)
554}
555
556/// Procgen decision: does an edge exist between two neighboring lots?
557/// Returns false for non-neighbors. Both lots must actually contain a
558/// node — call `node_at` first.
559///
560/// Three sources of edges, OR'd together:
561///   1. **Anchor edges** — every non-origin populated lot has a
562///      guaranteed edge to its `anchor_of` neighbor. Keeps every node
563///      reachable from origin transitively even when the random rolls
564///      come up empty.
565///   2. **Orthogonal procgen edges** — random roll with probability
566///      driven by a low-frequency density-noise field. Probability
567///      ranges from ~50% in "sinuous" regions to ~95% in "dense"
568///      pockets. Adjacent boxes might NOT be connected in sinuous
569///      zones, which is the explorational variety the player wants.
570///   3. **Diagonal procgen edges** — same noise-driven probability
571///      scaled to a lower band (~8% sparse, ~28% dense), plus the
572///      hard suppression when both L-bend corner lots are occupied
573///      (the visual line would otherwise cross an unrelated box).
574pub fn edge_exists(a: TreeCoord, b: TreeCoord) -> bool {
575    if !is_king_neighbor(a, b) {
576        return false;
577    }
578    // Anchor edges always exist — preserve "no orphans" regardless of
579    // the noise-driven probability dropping to zero.
580    if anchor_of(a) == Some(b) || anchor_of(b) == Some(a) {
581        return true;
582    }
583    let dx = (a.x - b.x).abs();
584    let dy = (a.y - b.y).abs();
585    let diagonal = dx == 1 && dy == 1;
586    // Canonicalize: smaller (x, y) first.
587    let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
588        (a, b)
589    } else {
590        (b, a)
591    };
592    if diagonal {
593        // Both L-bend corner lots occupied → suppress to avoid visually
594        // crossing an unrelated box.
595        let mid_h = TreeCoord::new(hi.x, lo.y);
596        let mid_v = TreeCoord::new(lo.x, hi.y);
597        if node_at(mid_h.x, mid_h.y).is_some() && node_at(mid_v.x, mid_v.y).is_some() {
598            return false;
599        }
600    }
601    let density = edge_density(a, b);
602    let p = if diagonal {
603        // Diagonals are rare bonus shortcuts. 2-10% range — most
604        // diagonals only exist as anchor edges (i.e. when a node's
605        // parent happens to be a diagonal neighbor).
606        0.02 + 0.08 * density
607    } else {
608        // Orthogonal: 12-35%. Combined with the anchor-edge
609        // guarantee (every non-origin node has exactly one anchor
610        // edge to its parent toward origin), this gives a clear
611        // "spine of the tree" plus occasional extra branches. Origin's
612        // 8 king-neighbors still all get an anchor edge to the cuque,
613        // but EXTRA edges between siblings are now uncommon — no more
614        // cobweb at origin.
615        0.12 + 0.23 * density
616    };
617    let mut rng = SplitMix64::from_coords(
618        TREE_SEED,
619        lo.x.wrapping_add(hi.x.wrapping_mul(7919)),
620        lo.y.wrapping_add(hi.y.wrapping_mul(6151)),
621        SALT_EDGE,
622    );
623    rng.bool_with_prob(p)
624}
625
626/// For a diagonal edge between `a` and `b`, return the "L-bend corner
627/// lot" the renderer should route through — the corner lot that's
628/// empty. `None` for non-diagonal pairs (renderer chooses by longer-axis
629/// heuristic instead).
630///
631/// Invariant: if `edge_exists(a, b)` is true for a diagonal pair, at
632/// least one of the two corner lots is empty (the other diagonal-suppress
633/// rule above guarantees this), so this returns `Some` for every
634/// existing diagonal edge.
635pub fn diagonal_route_via(a: TreeCoord, b: TreeCoord) -> Option<TreeCoord> {
636    let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
637        (a, b)
638    } else {
639        (b, a)
640    };
641    let dx = (hi.x - lo.x).abs();
642    let dy = (hi.y - lo.y).abs();
643    if dx != 1 || dy != 1 {
644        return None;
645    }
646    let mid_h = TreeCoord::new(hi.x, lo.y);
647    let mid_v = TreeCoord::new(lo.x, hi.y);
648    if node_at(mid_h.x, mid_h.y).is_none() {
649        return Some(mid_h);
650    }
651    if node_at(mid_v.x, mid_v.y).is_none() {
652        return Some(mid_v);
653    }
654    None
655}
656
657/// The 8 neighboring lots of `c`.
658pub fn neighbors_of(c: TreeCoord) -> [TreeCoord; 8] {
659    [
660        TreeCoord::new(c.x - 1, c.y - 1),
661        TreeCoord::new(c.x, c.y - 1),
662        TreeCoord::new(c.x + 1, c.y - 1),
663        TreeCoord::new(c.x - 1, c.y),
664        TreeCoord::new(c.x + 1, c.y),
665        TreeCoord::new(c.x - 1, c.y + 1),
666        TreeCoord::new(c.x, c.y + 1),
667        TreeCoord::new(c.x + 1, c.y + 1),
668    ]
669}
670
671/// Helper: all `TreeCoord`s that are king-neighbors of `c` AND have a node.
672pub fn neighbors_with_nodes(c: TreeCoord) -> Vec<TreeCoord> {
673    neighbors_of(c)
674        .into_iter()
675        .filter(|n| node_at(n.x, n.y).is_some())
676        .collect()
677}
678
679/// Cells along the rendered edge between two nodes, in canonical
680/// lo→hi lot order (lex on `(x, y)`). Returns an empty Vec if either
681/// endpoint has no node.
682///
683/// Canonical direction is load-bearing: `bresenham_path(A, B)` and
684/// `bresenham_path(B, A)` produce DIFFERENT cell sequences for the same
685/// endpoints, so we sort the inputs here. Pre-buy and post-buy
686/// rendering, plus the unlock-anim's completion check, all call this
687/// function with the same pair of lots — without canonicalization the
688/// wave would energize a different staircase shape from the grey base
689/// line, and you'd see the path geometry flip the moment the wave
690/// started.
691///
692/// The case-split mirrors `ui::tree::draw_edge`:
693///   - boxes vertically aligned within ±2 columns → straight vertical run
694///     at the midpoint x.
695///   - boxes horizontally aligned within ±2 rows → straight horizontal
696///     run at the midpoint y.
697///   - otherwise → Bresenham staircase between the two centers.
698pub fn edge_path_cells(a: TreeCoord, b: TreeCoord) -> Vec<(i32, i32)> {
699    let (a, b) = if (a.x, a.y) <= (b.x, b.y) {
700        (a, b)
701    } else {
702        (b, a)
703    };
704    let Some(an) = node_at(a.x, a.y) else {
705        return Vec::new();
706    };
707    let Some(bn) = node_at(b.x, b.y) else {
708        return Vec::new();
709    };
710    let acx = an.box_x + (an.box_w as i32) / 2;
711    let acy = an.box_y + (an.box_h as i32) / 2;
712    let bcx = bn.box_x + (bn.box_w as i32) / 2;
713    let bcy = bn.box_y + (bn.box_h as i32) / 2;
714
715    if (acx - bcx).abs() <= 2 {
716        let mid_x = (acx + bcx) / 2;
717        let step: i32 = if acy <= bcy { 1 } else { -1 };
718        let mut path = Vec::new();
719        let mut y = acy;
720        loop {
721            path.push((mid_x, y));
722            if y == bcy {
723                break;
724            }
725            y += step;
726        }
727        return path;
728    }
729    if (acy - bcy).abs() <= 2 {
730        let mid_y = (acy + bcy) / 2;
731        let step: i32 = if acx <= bcx { 1 } else { -1 };
732        let mut path = Vec::new();
733        let mut x = acx;
734        loop {
735            path.push((x, mid_y));
736            if x == bcx {
737                break;
738            }
739            x += step;
740        }
741        return path;
742    }
743    bresenham_path(acx, acy, bcx, bcy)
744}
745
746/// Count cells at the START of `path` whose canvas-grid coords lie
747/// inside the rect `(box_x, box_y, box_w, box_h)`. Used by the edge
748/// renderer + the unlock-anim's completion check to skip the "inside
749/// the source box" prefix when seeding the wavefront. Iteration stops
750/// at the first cell that's outside the rect — interior gaps don't
751/// inflate the count.
752pub fn count_leading_in_rect(
753    path: &[(i32, i32)],
754    box_x: i32,
755    box_y: i32,
756    box_w: u16,
757    box_h: u16,
758) -> usize {
759    let mut count = 0;
760    for &(cx, cy) in path {
761        if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
762            count += 1;
763        } else {
764            break;
765        }
766    }
767    count
768}
769
770/// Mirror of `count_leading_in_rect` but counting from the END of
771/// the path inward.
772pub fn count_trailing_in_rect(
773    path: &[(i32, i32)],
774    box_x: i32,
775    box_y: i32,
776    box_w: u16,
777    box_h: u16,
778) -> usize {
779    let mut count = 0;
780    for &(cx, cy) in path.iter().rev() {
781        if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
782            count += 1;
783        } else {
784            break;
785        }
786    }
787    count
788}
789
790/// Bresenham-style staircase between (ax, ay) and (bx, by) in
791/// canvas-grid coords. Single-axis steps only, interleaved by
792/// cross-multiplied normalized progress so the staircase doesn't
793/// degenerate into a one-big-L jolt. Returns cells in start→end order.
794pub fn bresenham_path(ax: i32, ay: i32, bx: i32, by: i32) -> Vec<(i32, i32)> {
795    let mut path = Vec::with_capacity(((bx - ax).abs() + (by - ay).abs() + 1) as usize);
796    let mut x = ax;
797    let mut y = ay;
798    let dx = (bx - ax).abs();
799    let dy = (by - ay).abs();
800    let sx: i32 = (bx - ax).signum();
801    let sy: i32 = (by - ay).signum();
802    path.push((x, y));
803    let mut xs: i64 = 0;
804    let mut ys: i64 = 0;
805    while x != bx || y != by {
806        let step_x_now = if x == bx {
807            false
808        } else if y == by {
809            true
810        } else {
811            (xs + 1) * (dy as i64) <= (ys + 1) * (dx as i64)
812        };
813        if step_x_now {
814            x += sx;
815            xs += 1;
816        } else {
817            y += sy;
818            ys += 1;
819        }
820        path.push((x, y));
821    }
822    path
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::game::fingerer::FINGERERS;
829
830    #[test]
831    fn origin_always_has_a_node() {
832        assert!(
833            node_at(0, 0).is_some(),
834            "origin lot must always be populated"
835        );
836    }
837
838    #[test]
839    fn origin_is_index_finger_small() {
840        let n = node_at(0, 0).unwrap();
841        assert_eq!(n.rarity, Rarity::Small);
842        assert!(
843            matches!(n.dominant_target, Target::Fingerer(0)),
844            "origin lot should target Index Finger so the first buy is useful"
845        );
846    }
847
848    #[test]
849    fn generation_is_deterministic() {
850        // Use a lot guaranteed to be populated regardless of GAP_PROBABILITY
851        // tuning — origin neighborhood (|x|<=1 AND |y|<=1) is always
852        // populated. (3, -2) is now a gap with GAP_PROBABILITY = 0.35.)
853        let a = node_at(1, 1).unwrap();
854        let b = node_at(1, 1).unwrap();
855        assert_eq!(a.lot, b.lot);
856        assert_eq!(a.rarity, b.rarity);
857        assert_eq!(a.box_x, b.box_x);
858        assert_eq!(a.box_y, b.box_y);
859        assert_eq!(a.cost, b.cost);
860        assert_eq!(a.title, b.title);
861        assert_eq!(a.primitives.len(), b.primitives.len());
862        for (pa, pb) in a.primitives.iter().zip(b.primitives.iter()) {
863            assert_eq!(pa.op, pb.op);
864            assert_eq!(pa.target, pb.target);
865            assert_eq!(pa.magnitude, pb.magnitude);
866        }
867    }
868
869    #[test]
870    fn box_fits_within_lot_bounds() {
871        // Sample a broad area; every generated box must stay inside its
872        // lot (no overlapping into neighbor lots, no crossing the canvas
873        // grid lines we'd later use for edge routing).
874        for x in -10..=10 {
875            for y in -10..=10 {
876                if let Some(n) = node_at(x, y) {
877                    let lot_min_x = x * LOT_W;
878                    let lot_max_x = (x + 1) * LOT_W;
879                    let lot_min_y = y * LOT_H;
880                    let lot_max_y = (y + 1) * LOT_H;
881                    assert!(
882                        n.box_x >= lot_min_x,
883                        "box {} below lot min {}",
884                        n.box_x,
885                        lot_min_x
886                    );
887                    assert!(
888                        (n.box_x + n.box_w as i32) <= lot_max_x,
889                        "box at ({x},{y}) overflows lot right edge"
890                    );
891                    assert!(n.box_y >= lot_min_y);
892                    assert!((n.box_y + n.box_h as i32) <= lot_max_y);
893                }
894            }
895        }
896    }
897
898    #[test]
899    fn gaps_exist_outside_origin_neighborhood() {
900        // Count populated vs empty lots in a far-out region.
901        let mut pop = 0;
902        let mut empty = 0;
903        for x in 10..=20 {
904            for y in 10..=20 {
905                if node_at(x, y).is_some() {
906                    pop += 1;
907                } else {
908                    empty += 1;
909                }
910            }
911        }
912        assert!(empty > 0, "tree must have gaps");
913        assert!(pop > 0, "tree must also have nodes");
914    }
915
916    #[test]
917    fn edges_are_deterministic_and_symmetric() {
918        let a = TreeCoord::new(3, 4);
919        let b = TreeCoord::new(4, 5);
920        assert_eq!(edge_exists(a, b), edge_exists(b, a));
921        assert_eq!(edge_exists(a, b), edge_exists(a, b));
922    }
923
924    #[test]
925    fn non_neighbors_never_have_edges() {
926        let a = TreeCoord::new(0, 0);
927        let b = TreeCoord::new(5, 5);
928        assert!(!edge_exists(a, b));
929    }
930
931    #[test]
932    fn keystones_have_at_least_one_bane() {
933        // Walk a large region; for every keystone, assert composition.
934        for x in -30..=30 {
935            for y in -30..=30 {
936                if let Some(n) = node_at(x, y) {
937                    if n.rarity == Rarity::Keystone {
938                        assert!(
939                            n.primitives.iter().any(|p| p.is_bane()),
940                            "keystone at ({x},{y}) has no bane primitive: {:?}",
941                            n.primitives
942                        );
943                        assert!(
944                            n.primitives.iter().any(|p| !p.is_bane()),
945                            "keystone at ({x},{y}) has no boon primitive"
946                        );
947                    }
948                }
949            }
950        }
951    }
952
953    #[test]
954    fn cost_grows_with_distance() {
955        let near = roll_cost(0, 0, Rarity::Small);
956        let far = roll_cost(20, 20, Rarity::Small);
957        assert!(far > near * 100.0, "far ({far}) should be >> near ({near})");
958    }
959
960    /// Diagnostic table — not a real assertion, prints the live ramp at
961    /// the current `NODE_COST_MULT` / `NODE_COST_GROWTH` values. Gated
962    /// behind `#[ignore]` so it doesn't run in normal CI; invoke
963    /// explicitly when tuning balance:
964    ///
965    ///   `cargo test --release dump_cost_table -- --ignored --nocapture`
966    #[test]
967    #[ignore]
968    fn dump_cost_table() {
969        eprintln!(
970            "NODE_COST_MULT={NODE_COST_MULT}  NODE_COST_GROWTH={NODE_COST_GROWTH}  base={NODE_BASE_COST}"
971        );
972        eprintln!(
973            "{:>4}  {:>14}  {:>14}  {:>14}",
974            "dist", "Small", "Notable", "Keystone"
975        );
976        for &d in &[1, 2, 3, 5, 7, 10, 12, 15, 18, 20, 25, 30, 35, 40, 50i32] {
977            // Use (d, 0) so manhattan == d. Average over a small sample to
978            // smooth out jitter, since jitter is seeded from (x, y).
979            let small = roll_cost(d, 0, Rarity::Small);
980            let notable = roll_cost(d, 0, Rarity::Notable);
981            let keystone = roll_cost(d, 0, Rarity::Keystone);
982            eprintln!(
983                "{:>4}  {:>14}  {:>14}  {:>14}",
984                d,
985                format_cost(small),
986                format_cost(notable),
987                format_cost(keystone)
988            );
989        }
990    }
991
992    fn format_cost(c: f64) -> String {
993        if c >= 1e15 {
994            format!("{:.2} quad", c / 1e15)
995        } else if c >= 1e12 {
996            format!("{:.2} T", c / 1e12)
997        } else if c >= 1e9 {
998            format!("{:.2} B", c / 1e9)
999        } else if c >= 1e6 {
1000            format!("{:.2} M", c / 1e6)
1001        } else if c >= 1e3 {
1002            format!("{:.2} k", c / 1e3)
1003        } else {
1004            format!("{c:.0}")
1005        }
1006    }
1007
1008    #[test]
1009    fn box_dims_match_rarity() {
1010        assert_eq!(Rarity::Small.box_dims(), (14, 3));
1011        assert_eq!(Rarity::Notable.box_dims(), (18, 5));
1012        assert_eq!(Rarity::Keystone.box_dims(), (24, 7));
1013    }
1014
1015    #[test]
1016    fn fingerer_target_index_in_range() {
1017        // Any generated Fingerer(idx) must be a valid catalog index.
1018        for x in -10..=10 {
1019            for y in -10..=10 {
1020                if let Some(n) = node_at(x, y) {
1021                    for p in &n.primitives {
1022                        if let Target::Fingerer(i) = p.target {
1023                            assert!(
1024                                (i as usize) < FINGERERS.len(),
1025                                "fingerer target idx {i} out of range"
1026                            );
1027                        }
1028                    }
1029                }
1030            }
1031        }
1032    }
1033
1034    #[test]
1035    fn no_zero_magnitude_primitives() {
1036        // Procgen should never produce a useless 0% / ×1.0 primitive.
1037        for x in -10..=10 {
1038            for y in -10..=10 {
1039                if let Some(n) = node_at(x, y) {
1040                    for p in &n.primitives {
1041                        assert!(p.magnitude != 0.0, "zero-magnitude primitive at ({x},{y})");
1042                        if matches!(
1043                            p.op,
1044                            Op::MulFactor | Op::CostMul | Op::SpawnRateMul | Op::EffectMul
1045                        ) {
1046                            assert!(
1047                                (p.magnitude - 1.0).abs() > 1e-9,
1048                                "identity-magnitude mul-style primitive at ({x},{y})"
1049                            );
1050                        }
1051                    }
1052                }
1053            }
1054        }
1055    }
1056}