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}