use super::coord::TreeCoord;
use super::naming::make_title;
use super::noise::{fbm_2d, value_noise_2d};
use super::primitive::{Op, Primitive, Target};
use super::rng::SplitMix64;
use super::seed::TREE_SEED;
pub const LOT_W: i32 = 36;
pub const LOT_H: i32 = 10;
const GAP_PROBABILITY: f64 = 0.35;
const BIAS_RADIUS: i32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Rarity {
Small,
Notable,
Keystone,
}
impl Rarity {
pub fn box_dims(self) -> (u16, u16) {
match self {
Rarity::Small => (14, 3),
Rarity::Notable => (18, 5),
Rarity::Keystone => (24, 7),
}
}
}
#[derive(Clone, Debug)]
pub struct NodeSpec {
pub lot: TreeCoord,
pub box_x: i32,
pub box_y: i32,
pub box_w: u16,
pub box_h: u16,
pub rarity: Rarity,
pub primitives: Vec<Primitive>,
pub cost: f64,
pub title: String,
pub dominant_target: Target,
pub is_anchor: bool,
}
const SALT_NODE: u64 = 0xA1;
const SALT_EDGE: u64 = 0xED;
const SALT_GAP: u64 = 0x6A;
fn passes_gap_roll(x: i32, y: i32) -> bool {
let is_origin_neighborhood = x.abs() <= 1 && y.abs() <= 1;
if is_origin_neighborhood {
return true;
}
let mut gap_rng = SplitMix64::from_coords(TREE_SEED, x, y, SALT_GAP);
let near_origin = x.abs() <= BIAS_RADIUS && y.abs() <= BIAS_RADIUS;
let gap_p = if near_origin {
GAP_PROBABILITY * 0.5
} else {
GAP_PROBABILITY
};
!gap_rng.bool_with_prob(gap_p)
}
fn anchor_spec() -> NodeSpec {
let box_w: u16 = 16;
let box_h: u16 = 8;
let off_x = ((LOT_W as u16).saturating_sub(box_w) / 2) as i32;
let off_y = ((LOT_H as u16).saturating_sub(box_h) / 2) as i32;
NodeSpec {
lot: TreeCoord::ORIGIN,
box_x: off_x,
box_y: off_y,
box_w,
box_h,
rarity: Rarity::Small,
primitives: Vec::new(),
cost: 0.0,
title: "The Cuque".to_string(),
dominant_target: Target::Fingerer(0),
is_anchor: true,
}
}
pub fn node_at(x: i32, y: i32) -> Option<NodeSpec> {
if x == 0 && y == 0 {
return Some(anchor_spec());
}
if !passes_gap_roll(x, y) {
return None;
}
let has_pop_neighbor = neighbors_of(TreeCoord::new(x, y))
.iter()
.any(|n| passes_gap_roll(n.x, n.y));
if !has_pop_neighbor {
return None;
}
let mut rng = SplitMix64::from_coords(TREE_SEED, x, y, SALT_NODE);
let rarity = pick_rarity(x, y, &mut rng);
let (box_w, box_h) = rarity.box_dims();
let max_off_x = (LOT_W as u16).saturating_sub(box_w + 2).max(1);
let max_off_y = (LOT_H as u16).saturating_sub(box_h + 1).max(1);
let off_x = rng.range_usize(max_off_x as usize) as i32 + 1;
let off_y = rng.range_usize(max_off_y as usize) as i32 + 1;
let box_x = x.saturating_mul(LOT_W).saturating_add(off_x);
let box_y = y.saturating_mul(LOT_H).saturating_add(off_y);
let count = match rarity {
Rarity::Small => 1,
Rarity::Notable => 2 + rng.range_usize(2), Rarity::Keystone => 2 + rng.range_usize(3), };
let primitives = roll_primitives(x, y, rarity, count, &mut rng);
let dominant_target = primitives[0].target;
let cost = roll_cost(x, y, rarity);
let mut name_rng = SplitMix64::from_coords(TREE_SEED, x, y, 0xDA);
let title = make_title(&mut name_rng, &primitives);
Some(NodeSpec {
lot: TreeCoord::new(x, y),
box_x,
box_y,
box_w,
box_h,
rarity,
primitives,
cost,
title,
dominant_target,
is_anchor: false,
})
}
fn pick_rarity(x: i32, y: i32, rng: &mut SplitMix64) -> Rarity {
if x.abs() <= 1 && y.abs() <= 1 {
return Rarity::Small;
}
let density = fbm_2d(TREE_SEED ^ 0x7E, x as f64 * 0.18, y as f64 * 0.18);
let hotspot = value_noise_2d(TREE_SEED ^ 0xBD, x as f64 * 0.81, y as f64 * 0.81);
if hotspot > 0.96 {
return Rarity::Keystone;
}
if density > 0.65 && rng.next_f64() < 0.35 {
return Rarity::Notable;
}
if hotspot > 0.85 && rng.next_f64() < 0.25 {
return Rarity::Notable;
}
Rarity::Small
}
fn roll_primitives(
x: i32,
y: i32,
rarity: Rarity,
count: usize,
rng: &mut SplitMix64,
) -> Vec<Primitive> {
let mut out = Vec::with_capacity(count);
let bane_force = matches!(rarity, Rarity::Keystone);
for i in 0..count {
let target = pick_target_biased(x, y, rng);
let op = pick_op(target, rng);
let force_sign = match rarity {
Rarity::Small | Rarity::Notable => Some(true), Rarity::Keystone => {
if i == 0 {
Some(true) } else if i == 1 || (i == count - 1 && !out.iter().any(|p: &Primitive| p.is_bane()))
{
Some(false) } else {
None
}
}
};
let magnitude = roll_magnitude(op, rarity, force_sign, bane_force, x, y, rng);
out.push(Primitive {
op,
target,
magnitude,
});
}
out
}
fn pick_target_biased(x: i32, y: i32, rng: &mut SplitMix64) -> Target {
let n_targets = Target::target_count();
let bias = value_noise_2d(TREE_SEED ^ 0xB10, x as f64 * 0.13, y as f64 * 0.13);
let center = (bias * n_targets as f64) as usize % n_targets;
let radius = if x.unsigned_abs().saturating_add(y.unsigned_abs()) < 4 {
1.5
} else {
4.0
};
let idx = if rng.bool_with_prob(0.60) {
let mag = (rng.range_f64(-1.0, 1.0)) * radius;
let signed = center as f64 + mag;
let wrapped = signed.rem_euclid(n_targets as f64) as usize;
wrapped.min(n_targets - 1)
} else {
rng.range_usize(n_targets)
};
let mut t = Target::from_index(idx);
if x == 0 && y == 0 {
t = Target::Fingerer(0);
}
t
}
fn pick_op(target: Target, rng: &mut SplitMix64) -> Op {
let ops: &[Op] = match target {
Target::Fingerer(_) => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd, Op::CostMul],
Target::AllFingerers => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd],
Target::Click => &[Op::AddPercent, Op::MulFactor, Op::FlatAdd],
Target::PowerupSpawn(_) => &[Op::SpawnRateMul],
Target::PowerupReward(_) => &[Op::EffectMul],
Target::PowerupDuration(_) => &[Op::EffectMul],
Target::Prestige => &[Op::AddPercent, Op::MulFactor],
Target::GreenCoinStrength => &[Op::EffectMul],
};
ops[rng.range_usize(ops.len())]
}
fn roll_magnitude(
op: Op,
rarity: Rarity,
force_sign: Option<bool>, bane_force: bool, x: i32,
y: i32,
rng: &mut SplitMix64,
) -> f64 {
let dist = x.unsigned_abs().saturating_add(y.unsigned_abs()) as f64;
let depth_scale = 1.0 + (dist / 12.0).min(20.0); let rarity_scale = match rarity {
Rarity::Small => 1.0,
Rarity::Notable => 2.5,
Rarity::Keystone => 5.0,
};
let s = depth_scale * rarity_scale;
let bane = match force_sign {
Some(true) => false,
Some(false) => true,
None => {
if bane_force {
rng.bool_with_prob(0.25)
} else {
rng.bool_with_prob(0.05)
}
}
};
match op {
Op::AddPercent => {
let mag = rng.range_f64(0.01, 0.05) * s;
if bane { -mag * 0.7 } else { mag }
}
Op::MulFactor => {
let boost = rng.range_f64(0.02, 0.10) * s; if bane {
let nerf = rng.range_f64(0.05, 0.40).min(0.50);
(1.0 - nerf).max(0.01)
} else {
1.0 + boost
}
}
Op::FlatAdd => {
let mag = rng.range_f64(0.5, 4.0) * s;
if bane { -mag * 0.5 } else { mag }
}
Op::CostMul => {
let scale = rng.range_f64(0.02, 0.10) * (1.0 + (dist / 30.0));
if bane {
(1.0 + scale).min(2.0)
} else {
(1.0 - scale).max(0.50)
}
}
Op::SpawnRateMul => {
let scale = rng.range_f64(0.02, 0.12) * (1.0 + (dist / 30.0));
if bane {
(1.0 - scale).max(0.30)
} else {
(1.0 + scale).min(3.0)
}
}
Op::EffectMul => {
let scale = rng.range_f64(0.02, 0.15) * (1.0 + (dist / 24.0));
if bane {
(1.0 - scale).max(0.10)
} else {
(1.0 + scale).min(5.0)
}
}
}
}
pub const NODE_COST_MULT: f64 = 5.0;
pub const NODE_COST_GROWTH: f64 = 1.75;
pub const NODE_BASE_COST: f64 = 50.0;
pub fn roll_cost(x: i32, y: i32, rarity: Rarity) -> f64 {
let dist = x.unsigned_abs().saturating_add(y.unsigned_abs()) as f64;
let depth_factor = NODE_COST_GROWTH.powf(dist);
let rarity_factor = match rarity {
Rarity::Small => 1.0,
Rarity::Notable => 5.0,
Rarity::Keystone => 40.0,
};
let mut rng = SplitMix64::from_coords(TREE_SEED, x, y, 0xC0);
let jitter = rng.range_f64(0.85, 1.25);
let raw = NODE_COST_MULT * NODE_BASE_COST * depth_factor * rarity_factor * jitter;
raw.min(1e300).floor().max(10.0)
}
pub fn is_king_neighbor(a: TreeCoord, b: TreeCoord) -> bool {
let dx = (a.x - b.x).abs();
let dy = (a.y - b.y).abs();
dx <= 1 && dy <= 1 && !(dx == 0 && dy == 0)
}
pub fn anchor_of(lot: TreeCoord) -> Option<TreeCoord> {
if lot == TreeCoord::ORIGIN {
return None;
}
if !passes_gap_roll(lot.x, lot.y) {
return None;
}
let dist = lot.manhattan();
let mut candidates: Vec<TreeCoord> = neighbors_of(lot).to_vec();
candidates.sort_by_key(|n| (n.manhattan(), n.x, n.y));
let is_orthogonal = |n: &TreeCoord| {
let dx = n.x.wrapping_sub(lot.x).unsigned_abs();
let dy = n.y.wrapping_sub(lot.y).unsigned_abs();
(dx == 0 || dy == 0) && (dx + dy == 1)
};
for n in &candidates {
if n.manhattan() >= dist {
continue;
}
if !is_orthogonal(n) {
continue;
}
if passes_gap_roll(n.x, n.y) {
return Some(*n);
}
}
for n in &candidates {
if n.manhattan() >= dist {
continue;
}
if passes_gap_roll(n.x, n.y) {
return Some(*n);
}
}
for n in &candidates {
if passes_gap_roll(n.x, n.y) {
return Some(*n);
}
}
None
}
fn edge_density(a: TreeCoord, b: TreeCoord) -> f64 {
let mx = (a.x as f64 + b.x as f64) * 0.5;
let my = (a.y as f64 + b.y as f64) * 0.5;
value_noise_2d(TREE_SEED ^ 0xDE, mx * 0.12, my * 0.12)
}
pub fn edge_exists(a: TreeCoord, b: TreeCoord) -> bool {
if !is_king_neighbor(a, b) {
return false;
}
if anchor_of(a) == Some(b) || anchor_of(b) == Some(a) {
return true;
}
let dx = (a.x - b.x).abs();
let dy = (a.y - b.y).abs();
let diagonal = dx == 1 && dy == 1;
let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
(a, b)
} else {
(b, a)
};
if diagonal {
let mid_h = TreeCoord::new(hi.x, lo.y);
let mid_v = TreeCoord::new(lo.x, hi.y);
if node_at(mid_h.x, mid_h.y).is_some() && node_at(mid_v.x, mid_v.y).is_some() {
return false;
}
}
let density = edge_density(a, b);
let p = if diagonal {
0.02 + 0.08 * density
} else {
0.12 + 0.23 * density
};
let mut rng = SplitMix64::from_coords(
TREE_SEED,
lo.x.wrapping_add(hi.x.wrapping_mul(7919)),
lo.y.wrapping_add(hi.y.wrapping_mul(6151)),
SALT_EDGE,
);
rng.bool_with_prob(p)
}
pub fn diagonal_route_via(a: TreeCoord, b: TreeCoord) -> Option<TreeCoord> {
let (lo, hi) = if (a.x, a.y) < (b.x, b.y) {
(a, b)
} else {
(b, a)
};
let dx = (hi.x - lo.x).abs();
let dy = (hi.y - lo.y).abs();
if dx != 1 || dy != 1 {
return None;
}
let mid_h = TreeCoord::new(hi.x, lo.y);
let mid_v = TreeCoord::new(lo.x, hi.y);
if node_at(mid_h.x, mid_h.y).is_none() {
return Some(mid_h);
}
if node_at(mid_v.x, mid_v.y).is_none() {
return Some(mid_v);
}
None
}
pub fn neighbors_of(c: TreeCoord) -> [TreeCoord; 8] {
[
TreeCoord::new(c.x - 1, c.y - 1),
TreeCoord::new(c.x, c.y - 1),
TreeCoord::new(c.x + 1, c.y - 1),
TreeCoord::new(c.x - 1, c.y),
TreeCoord::new(c.x + 1, c.y),
TreeCoord::new(c.x - 1, c.y + 1),
TreeCoord::new(c.x, c.y + 1),
TreeCoord::new(c.x + 1, c.y + 1),
]
}
pub fn neighbors_with_nodes(c: TreeCoord) -> Vec<TreeCoord> {
neighbors_of(c)
.into_iter()
.filter(|n| node_at(n.x, n.y).is_some())
.collect()
}
pub fn edge_path_cells(a: TreeCoord, b: TreeCoord) -> Vec<(i32, i32)> {
let (a, b) = if (a.x, a.y) <= (b.x, b.y) {
(a, b)
} else {
(b, a)
};
let Some(an) = node_at(a.x, a.y) else {
return Vec::new();
};
let Some(bn) = node_at(b.x, b.y) else {
return Vec::new();
};
let acx = an.box_x + (an.box_w as i32) / 2;
let acy = an.box_y + (an.box_h as i32) / 2;
let bcx = bn.box_x + (bn.box_w as i32) / 2;
let bcy = bn.box_y + (bn.box_h as i32) / 2;
if (acx - bcx).abs() <= 2 {
let mid_x = (acx + bcx) / 2;
let step: i32 = if acy <= bcy { 1 } else { -1 };
let mut path = Vec::new();
let mut y = acy;
loop {
path.push((mid_x, y));
if y == bcy {
break;
}
y += step;
}
return path;
}
if (acy - bcy).abs() <= 2 {
let mid_y = (acy + bcy) / 2;
let step: i32 = if acx <= bcx { 1 } else { -1 };
let mut path = Vec::new();
let mut x = acx;
loop {
path.push((x, mid_y));
if x == bcx {
break;
}
x += step;
}
return path;
}
bresenham_path(acx, acy, bcx, bcy)
}
pub fn count_leading_in_rect(
path: &[(i32, i32)],
box_x: i32,
box_y: i32,
box_w: u16,
box_h: u16,
) -> usize {
let mut count = 0;
for &(cx, cy) in path {
if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
count += 1;
} else {
break;
}
}
count
}
pub fn count_trailing_in_rect(
path: &[(i32, i32)],
box_x: i32,
box_y: i32,
box_w: u16,
box_h: u16,
) -> usize {
let mut count = 0;
for &(cx, cy) in path.iter().rev() {
if cx >= box_x && cx < box_x + box_w as i32 && cy >= box_y && cy < box_y + box_h as i32 {
count += 1;
} else {
break;
}
}
count
}
pub fn bresenham_path(ax: i32, ay: i32, bx: i32, by: i32) -> Vec<(i32, i32)> {
let mut path = Vec::with_capacity(((bx - ax).abs() + (by - ay).abs() + 1) as usize);
let mut x = ax;
let mut y = ay;
let dx = (bx - ax).abs();
let dy = (by - ay).abs();
let sx: i32 = (bx - ax).signum();
let sy: i32 = (by - ay).signum();
path.push((x, y));
let mut xs: i64 = 0;
let mut ys: i64 = 0;
while x != bx || y != by {
let step_x_now = if x == bx {
false
} else if y == by {
true
} else {
(xs + 1) * (dy as i64) <= (ys + 1) * (dx as i64)
};
if step_x_now {
x += sx;
xs += 1;
} else {
y += sy;
ys += 1;
}
path.push((x, y));
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::fingerer::FINGERERS;
#[test]
fn origin_always_has_a_node() {
assert!(
node_at(0, 0).is_some(),
"origin lot must always be populated"
);
}
#[test]
fn origin_is_index_finger_small() {
let n = node_at(0, 0).unwrap();
assert_eq!(n.rarity, Rarity::Small);
assert!(
matches!(n.dominant_target, Target::Fingerer(0)),
"origin lot should target Index Finger so the first buy is useful"
);
}
#[test]
fn generation_is_deterministic() {
let a = node_at(1, 1).unwrap();
let b = node_at(1, 1).unwrap();
assert_eq!(a.lot, b.lot);
assert_eq!(a.rarity, b.rarity);
assert_eq!(a.box_x, b.box_x);
assert_eq!(a.box_y, b.box_y);
assert_eq!(a.cost, b.cost);
assert_eq!(a.title, b.title);
assert_eq!(a.primitives.len(), b.primitives.len());
for (pa, pb) in a.primitives.iter().zip(b.primitives.iter()) {
assert_eq!(pa.op, pb.op);
assert_eq!(pa.target, pb.target);
assert_eq!(pa.magnitude, pb.magnitude);
}
}
#[test]
fn box_fits_within_lot_bounds() {
for x in -10..=10 {
for y in -10..=10 {
if let Some(n) = node_at(x, y) {
let lot_min_x = x * LOT_W;
let lot_max_x = (x + 1) * LOT_W;
let lot_min_y = y * LOT_H;
let lot_max_y = (y + 1) * LOT_H;
assert!(
n.box_x >= lot_min_x,
"box {} below lot min {}",
n.box_x,
lot_min_x
);
assert!(
(n.box_x + n.box_w as i32) <= lot_max_x,
"box at ({x},{y}) overflows lot right edge"
);
assert!(n.box_y >= lot_min_y);
assert!((n.box_y + n.box_h as i32) <= lot_max_y);
}
}
}
}
#[test]
fn gaps_exist_outside_origin_neighborhood() {
let mut pop = 0;
let mut empty = 0;
for x in 10..=20 {
for y in 10..=20 {
if node_at(x, y).is_some() {
pop += 1;
} else {
empty += 1;
}
}
}
assert!(empty > 0, "tree must have gaps");
assert!(pop > 0, "tree must also have nodes");
}
#[test]
fn edges_are_deterministic_and_symmetric() {
let a = TreeCoord::new(3, 4);
let b = TreeCoord::new(4, 5);
assert_eq!(edge_exists(a, b), edge_exists(b, a));
assert_eq!(edge_exists(a, b), edge_exists(a, b));
}
#[test]
fn non_neighbors_never_have_edges() {
let a = TreeCoord::new(0, 0);
let b = TreeCoord::new(5, 5);
assert!(!edge_exists(a, b));
}
#[test]
fn keystones_have_at_least_one_bane() {
for x in -30..=30 {
for y in -30..=30 {
if let Some(n) = node_at(x, y) {
if n.rarity == Rarity::Keystone {
assert!(
n.primitives.iter().any(|p| p.is_bane()),
"keystone at ({x},{y}) has no bane primitive: {:?}",
n.primitives
);
assert!(
n.primitives.iter().any(|p| !p.is_bane()),
"keystone at ({x},{y}) has no boon primitive"
);
}
}
}
}
}
#[test]
fn cost_grows_with_distance() {
let near = roll_cost(0, 0, Rarity::Small);
let far = roll_cost(20, 20, Rarity::Small);
assert!(far > near * 100.0, "far ({far}) should be >> near ({near})");
}
#[test]
#[ignore]
fn dump_cost_table() {
eprintln!(
"NODE_COST_MULT={NODE_COST_MULT} NODE_COST_GROWTH={NODE_COST_GROWTH} base={NODE_BASE_COST}"
);
eprintln!(
"{:>4} {:>14} {:>14} {:>14}",
"dist", "Small", "Notable", "Keystone"
);
for &d in &[1, 2, 3, 5, 7, 10, 12, 15, 18, 20, 25, 30, 35, 40, 50i32] {
let small = roll_cost(d, 0, Rarity::Small);
let notable = roll_cost(d, 0, Rarity::Notable);
let keystone = roll_cost(d, 0, Rarity::Keystone);
eprintln!(
"{:>4} {:>14} {:>14} {:>14}",
d,
format_cost(small),
format_cost(notable),
format_cost(keystone)
);
}
}
fn format_cost(c: f64) -> String {
if c >= 1e15 {
format!("{:.2} quad", c / 1e15)
} else if c >= 1e12 {
format!("{:.2} T", c / 1e12)
} else if c >= 1e9 {
format!("{:.2} B", c / 1e9)
} else if c >= 1e6 {
format!("{:.2} M", c / 1e6)
} else if c >= 1e3 {
format!("{:.2} k", c / 1e3)
} else {
format!("{c:.0}")
}
}
#[test]
fn box_dims_match_rarity() {
assert_eq!(Rarity::Small.box_dims(), (14, 3));
assert_eq!(Rarity::Notable.box_dims(), (18, 5));
assert_eq!(Rarity::Keystone.box_dims(), (24, 7));
}
#[test]
fn fingerer_target_index_in_range() {
for x in -10..=10 {
for y in -10..=10 {
if let Some(n) = node_at(x, y) {
for p in &n.primitives {
if let Target::Fingerer(i) = p.target {
assert!(
(i as usize) < FINGERERS.len(),
"fingerer target idx {i} out of range"
);
}
}
}
}
}
}
#[test]
fn no_zero_magnitude_primitives() {
for x in -10..=10 {
for y in -10..=10 {
if let Some(n) = node_at(x, y) {
for p in &n.primitives {
assert!(p.magnitude != 0.0, "zero-magnitude primitive at ({x},{y})");
if matches!(
p.op,
Op::MulFactor | Op::CostMul | Op::SpawnRateMul | Op::EffectMul
) {
assert!(
(p.magnitude - 1.0).abs() > 1e-9,
"identity-magnitude mul-style primitive at ({x},{y})"
);
}
}
}
}
}
}
}