Files
settled-reach/server/src/atlas/vegetation_invention.rs
T

407 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Vegetation-patchiness invention field (T-1162, D-227, nature-layer scope
//! per the ticket's SCOPE CLARIFICATION amendment).
//!
//! Jeroen's brief: "even the nature layer should produce rivers, forests,
//! copses, bushes, deserts, elevation" — vegetation must read as spatial
//! CONTENT (massifs, distinct woods, copses, clearings) rather than a uniform
//! per-cell class tint that only ever changes at a climate-driven district
//! boundary. This module is the vegetation twin of
//! [`crate::atlas::coast_invention`]'s two-tier character model, adapted to
//! the constraint that vegetation has no warp target of its own (there is no
//! "vegetation heightmap" to displace sampling against) — instead it
//! perturbs [`crate::atlas::district_profile::derive_moisture_q`]'s **output**
//! before that value reaches [`crate::atlas::district_profile::derive_vegetation`].
//!
//! ## Design choice: perturb the moisture INPUT, not the class thresholds
//!
//! Two composition points were available: (a) bias `derive_vegetation`'s
//! internal treeline/moisture thresholds directly, or (b) perturb the
//! `moisture_q` value it (and its siblings) consume. **(b) was chosen.**
//! `moisture_q` already feeds THREE downstream classifiers in lockstep —
//! [`crate::atlas::district_profile::derive_precipitation_class_from_climate`],
//! [`crate::atlas::district_profile::derive_glaciation_grade_from_climate`],
//! and `derive_vegetation` itself. Perturbing the shared input means a patchy-dry
//! cell reads dry across precipitation AND vegetation AND (at the right
//! temperature) glaciation grade — one coherent world-fact, not a
//! vegetation-only illusion sitting on top of an unperturbed climate. Biasing
//! `derive_vegetation`'s thresholds alone would let a cell "look wetter" in
//! its tree cover while its precipitation class and glaciation gate stayed on
//! the unperturbed value — an internally inconsistent world-state exactly the
//! kind D-227/D-010 determinism discipline exists to prevent.
//!
//! ## Two-tier model (mirrors `coast_invention`)
//!
//! - **Tier 1 — [`VegetationEnvelope`] (body/region-scale, "where forests are
//! POSSIBLE"):** a region-scale, latitude-modulated ceiling on how far the
//! patchiness field is allowed to swing `moisture_q`. Derived from
//! [`crate::atlas::district_profile::BodyParams`] only (D-240 discipline,
//! same posture as `coast_invention::BodyCoastEnvelope`) plus latitude — a
//! hyper-arid world's patchiness ceiling is narrow (deserts stay deserts;
//! no patch of forest should ever invent itself on a bone-dry world), a
//! temperate wet world's ceiling is wide (real massif-to-massif variation).
//! - **Tier 2 — [`moisture_perturb_q`] (position, "which patches manifest"):**
//! a signed integer offset in `[-ceiling, +ceiling]`, built from a
//! MULTI-SCALE deterministic field (region/massif → district/wood →
//! quarter/copse octave bands, §"Cross-rung coherence" below) so a forest
//! massif at Region scale resolves to distinct woods at District and
//! copses/clearings at Quarter — never contradicting the coarser verdict,
//! only refining its boundary.
//!
//! ## Cross-rung coherence (the ticket's hard requirement)
//!
//! A forest at Region scale MUST still read as forest-majority when sampled
//! at Quarter — the finer rungs REFINE the boundary and punch clearings, they
//! never contradict the coarse verdict wholesale. This is achieved the SAME
//! way `coast_invention`'s warp achieves cross-rung coherence: a SINGLE fBm
//! sum whose octave terms span coarse-to-fine wavelengths, sampled at
//! whatever position/cutoff a rung asks for — not two independently-seeded
//! fields for "coarse" and "fine" that could disagree. The coarse octaves
//! (massif-scale, ≈100–400 km, always present regardless of cutoff — same
//! posture as `coast_invention::character_field`, which is NEVER
//! cutoff-gated) set the DC bias (which side of "possible forest" a broad
//! area sits on); the fine octaves (district/quarter-scale, cutoff-gated
//! exactly like `terrain_detail`/`voxel_relief`) add zero-mean texture on top
//! that punches clearings and copses WITHOUT being able to flip the coarse
//! bias's sign at typical amplitudes (the fine-octave contribution is capped
//! well inside the coarse term's own swing — see [`moisture_perturb_q`]'s
//! amplitude split).
//!
//! ## Wavelength discipline (per rung, identical to parts 1–2 of T-1162)
//!
//! The fine tier reuses [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`]
//! (district band, 32,768–4,096 m) and
//! [`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`] (quarter/sub-district
//! band, 1,024–128 m) — the SAME two bands the coast warp and relief
//! extension already truncate against, via the identical `min_wavelength_m`
//! hard-truncate cutoff convention (`enveloped_fbm`'s discipline). At
//! District's cutoff (≥2,048 m) only the district band's terms survive; at
//! Quarter's cutoff (1,024 m) the two coarsest voxel-band terms additionally
//! survive — exactly parts (a)/(b)'s discipline, reused rather than
//! reinvented for vegetation.
//!
//! ## Determinism & isolation (D-227/D-010)
//!
//! Pure functions of `(seed, body, position)`. Distinct salted hash streams
//! ([`VEGETATION_MASSIF_SALT`], [`VEGETATION_TEXTURE_SALT`]) — never
//! correlated with the coast warp, the terrain/voxel relief scatter, or the
//! climate edge-fuzz warp, the same isolation convention every invention
//! field in this cascade follows.
//!
//! ## Wire format (unchanged)
//!
//! This module produces an `i32` moisture offset consumed BEFORE
//! `DistrictProfile` is built — `vegetation_class` remains the single
//! discriminant per cell on the wire (`WindowCell::vegetation: u8`,
//! `layer_proxy.rs`). The patchiness lives in WHICH class a cell resolves to,
//! never in additional sub-cell wire data.
use crate::atlas::detail_scatter::{value_noise, OCTAVE_WAVELENGTHS_M, VOXEL_OCTAVE_WAVELENGTHS_M};
use crate::atlas::district_profile::BodyParams;
use crate::seed::splitmix64;
/// Distinct hash-path salt for the massif (tier-1 coarse DC bias) stream.
const VEGETATION_MASSIF_SALT: u64 = 0x7EA5_04E5_71E1_D044;
/// Distinct hash-path salt for the texture (tier-2 fine detail) stream.
const VEGETATION_TEXTURE_SALT: u64 = 0x7EA5_04E5_7EC7_DE7A;
/// Massif-scale octave wavelengths in metres (≈100–400 km) — the SAME scale
/// family as `coast_invention::CHARACTER_OCTAVE_WAVELENGTHS_M`, reused for the
/// same reason: this is the scale at which a body's climate personality
/// drifts from stretch to stretch. Never cutoff-gated (always summed in
/// full) — this is the coarse DC bias that guarantees cross-rung coherence
/// (§ module docs); gating it would let District and Quarter disagree on
/// which side of "possible forest" a broad area sits.
const MASSIF_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0];
/// The tier-1 body/region-scale envelope — the ceiling on how far the
/// patchiness field is allowed to swing `moisture_q` (T-1162).
///
/// Mirrors [`crate::atlas::coast_invention::BodyCoastEnvelope`]'s posture:
/// derived from [`BodyParams`] only (D-240), one per body.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VegetationEnvelope {
/// Maximum |offset| the patchiness field may apply to `moisture_q`
/// (points, 0–100 scale). Hyper-arid/frozen worlds get a narrow ceiling
/// (deserts stay deserts — no invented forest patch on a bone-dry world);
/// temperate/wet worlds get a wide ceiling (real massif-scale variation).
pub ceiling_q: i32,
}
/// Derive the tier-1 envelope from body params.
///
/// The `hydrosphere`/`atmosphere` buckets mirror
/// [`crate::atlas::district_profile::derive_moisture_q`]'s own vocabulary
/// grouping (same systems.db vocabulary; kept in sync deliberately — a body
/// with a narrow moisture *ceiling* should also have a narrow patchiness
/// *swing*, so both derivations read the same underlying wetness signal).
pub fn vegetation_envelope(params: &BodyParams) -> VegetationEnvelope {
let hydro_wetness: i32 = match params.hydrosphere.as_deref().unwrap_or("none") {
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 100,
"rivers" | "rivers-lakes" | "moderate" => 70,
"ice" => 35,
"subsurface_liquid" => 25,
"subsurface" | "subsurface_ice" => 15,
"minimal" | "trace" => 8,
"none" => 0,
_ => 40,
};
let atmo_density: i32 = match params.atmosphere.as_deref().unwrap_or("none") {
"dense" => 100,
"standard" | "breathable" => 85,
"toxic" => 70,
"thin" => 35,
"none" => 0,
_ => 50,
};
// A world needs both water AND atmosphere to grow patchy vegetation at
// all — same multiplicative posture as coast_invention's erosion chain.
// Ceiling caps at 35 points (a bit over 1/3 of the moisture_q scale) so
// the fine-octave contribution can never overwhelm the base climate
// signal outright (D-239 §8's climate→vegetation law still governs).
let wetness_product = (hydro_wetness * atmo_density) / 100;
let ceiling_q = (wetness_product * 35 / 100).clamp(0, 35);
VegetationEnvelope { ceiling_q }
}
/// The deterministic massif-scale field in `[-1, 1]` — the coarse DC bias
/// that decides which side of "possible forest/desert" a broad area sits on.
/// Never cutoff-gated (see module docs' cross-rung-coherence section).
fn massif_field(seed: u64, wx: f64, wy: f64) -> f64 {
let seed = splitmix64(seed ^ VEGETATION_MASSIF_SALT);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
for (i, &wl) in MASSIF_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
sum += value_noise(
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
wx,
wy,
wl,
) * amp;
norm += amp;
amp *= 0.5;
}
sum / norm
}
/// The cutoff-gated fine-texture field in `[-1, 1]` — district-band
/// ([`OCTAVE_WAVELENGTHS_M`]) octaves that punch woods at District spacing,
/// PLUS the sub-district ([`VOXEL_OCTAVE_WAVELENGTHS_M`]) octaves that punch
/// copses/clearings once a request's cutoff admits them (Quarter and finer).
/// Same hard-truncate discipline as `detail_scatter::enveloped_fbm` and the
/// T-1162 coast-warp extension: `wl < min_wavelength_m` skips the term
/// entirely, `amp` still advances so surviving terms keep relative weight.
fn texture_field(seed: u64, wx: f64, wy: f64, min_wavelength_m: f64) -> f64 {
let seed = splitmix64(seed ^ VEGETATION_TEXTURE_SALT);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
let mut idx: u64 = 0;
for &wl in OCTAVE_WAVELENGTHS_M
.iter()
.chain(VOXEL_OCTAVE_WAVELENGTHS_M.iter())
{
if wl < min_wavelength_m {
amp *= 0.5;
idx += 1;
continue;
}
sum += value_noise(seed.wrapping_add(idx.wrapping_mul(0x1000)), wx, wy, wl) * amp;
norm += amp;
amp *= 0.5;
idx += 1;
}
if norm == 0.0 {
return 0.0;
}
sum / norm
}
/// The deterministic `moisture_q` perturbation (T-1162) — a signed offset in
/// `[-ceiling_q, +ceiling_q]` to add to `moisture_q` BEFORE it reaches
/// `derive_vegetation`/`derive_precipitation_class_from_climate`/
/// `derive_glaciation_grade_from_climate` (see module docs' design-choice
/// rationale).
///
/// Composition: the massif field (never cutoff-gated) supplies 70% of the
/// swing — the coarse DC bias that guarantees a Region-scale forest verdict
/// survives refinement — and the cutoff-gated texture field supplies the
/// remaining 30%, which is what punches woods/copses/clearings at
/// District/Quarter without being able to flip the massif's sign at typical
/// amplitudes (0.3 < 0.7, so the texture term alone can never outweigh the
/// massif term). `min_wavelength_m` — `0.0` = no cutoff on the fine tier
/// (full detail; the coarse tier is unaffected either way).
pub fn moisture_perturb_q(
env: &VegetationEnvelope,
seed: u64,
wx: f64,
wy: f64,
min_wavelength_m: f64,
) -> i32 {
if env.ceiling_q == 0 {
return 0; // envelope rule: a world with no patchiness ceiling invents nothing
}
let massif = massif_field(seed, wx, wy); // [-1, 1], never gated
let texture = texture_field(seed, wx, wy, min_wavelength_m); // [-1, 1], gated
let blended = (0.70 * massif + 0.30 * texture).clamp(-1.0, 1.0);
((blended * env.ceiling_q as f64).round() as i32).clamp(-env.ceiling_q, env.ceiling_q)
}
#[cfg(test)]
mod tests {
use super::*;
fn params(hydro: &str, atmo: &str) -> BodyParams {
BodyParams {
hydrosphere: Some(hydro.to_string()),
atmosphere: Some(atmo.to_string()),
planet_class: Some("temperate".to_string()),
tectonic_activity: None,
latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km: Some(6000.0),
}
}
#[test]
fn envelope_wet_body_has_wider_ceiling_than_dry() {
let wet = vegetation_envelope(&params("ocean", "breathable"));
let dry = vegetation_envelope(&params("minimal", "thin"));
assert!(
wet.ceiling_q > dry.ceiling_q,
"wet+breathable body must have a wider patchiness ceiling than dry+thin"
);
}
#[test]
fn envelope_airless_or_dry_body_has_zero_ceiling() {
let none = vegetation_envelope(&params("none", "none"));
assert_eq!(
none.ceiling_q, 0,
"a body with neither water nor atmosphere must have zero patchiness ceiling"
);
}
#[test]
fn perturb_is_deterministic() {
let env = vegetation_envelope(&params("ocean", "breathable"));
let a = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0);
let b = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0);
assert_eq!(a, b);
}
#[test]
fn perturb_bounded_by_ceiling() {
let env = vegetation_envelope(&params("ocean", "breathable"));
for i in 0..400 {
let wx = i as f64 * 91_137.0;
let wy = i as f64 * -53_211.0;
let p = moisture_perturb_q(&env, 99, wx, wy, 0.0);
assert!(
p.abs() <= env.ceiling_q,
"perturb {p} exceeded ceiling {}",
env.ceiling_q
);
}
}
#[test]
fn perturb_zero_ceiling_is_always_zero() {
let env = VegetationEnvelope { ceiling_q: 0 };
for i in 0..50 {
let p = moisture_perturb_q(&env, 7, i as f64 * 1234.0, i as f64 * -987.0, 0.0);
assert_eq!(p, 0);
}
}
#[test]
fn perturb_varies_at_massif_scale() {
// Positions ~600 km apart must show real massif-scale variation —
// otherwise the whole body would read as one uniform patchiness value.
let env = vegetation_envelope(&params("ocean", "breathable"));
let vals: Vec<i32> = (0..8)
.map(|i| moisture_perturb_q(&env, 11, i as f64 * 600_000.0, 300_000.0, 0.0))
.collect();
let min = *vals.iter().min().unwrap();
let max = *vals.iter().max().unwrap();
assert!(
max - min > 2,
"massif-scale variation too flat across the body: range {}",
max - min
);
}
#[test]
fn perturb_stream_uncorrelated_with_coast_and_scatter_streams() {
let env = vegetation_envelope(&params("ocean", "breathable"));
let p = moisture_perturb_q(&env, 42, 1e6, 1e6, 0.0);
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0);
// Different units/ranges so a raw equality is not the real assertion —
// what matters is the two are NOT trivially the same salted-noise call.
// Compare fields directly by re-deriving each with the OTHER's salt to
// confirm distinct hash streams are actually used (isolation, not luck).
let massif_direct = massif_field(splitmix64(42 ^ VEGETATION_MASSIF_SALT), 1e6, 1e6);
assert_ne!(massif_direct, scatter, "sanity: distinct value spaces");
let _ = p;
}
// ── min_wavelength_m cutoff discipline (T-1162, mirrors parts a/b) ────────
#[test]
fn cutoff_zero_matches_full_texture_field() {
let env = vegetation_envelope(&params("ocean", "breathable"));
for i in 0..50 {
let wx = i as f64 * 7_331.0;
let wy = i as f64 * -4_177.0;
let with_zero = moisture_perturb_q(&env, 21, wx, wy, 0.0);
// The finest combined band entry is VOXEL_OCTAVE_WAVELENGTHS_M's
// 128.0 — a cutoff below that admits every octave too.
let with_below_finest = moisture_perturb_q(&env, 21, wx, wy, 1.0);
assert_eq!(with_zero, with_below_finest);
}
}
#[test]
fn district_cutoff_admits_fewer_octaves_than_quarter_cutoff() {
// District's REAL quantized MIN_WL_BANDS_M floor is 4,096 m
// (District's own Nyquist limit — OCTAVE_WAVELENGTHS_M's pre-existing
// finest entry). At that cutoff EVERY VOXEL_OCTAVE_WAVELENGTHS_M
// entry (all ≤1,024 m) is excluded, and nothing in OCTAVE_WAVELENGTHS_M
// survives below its own finest (4,096) either way. Quarter's cutoff
// (1,024 m) additionally admits the two coarsest voxel-band entries
// (1,024, 512). The texture field must therefore differ between the
// two cutoffs at the same position — otherwise Quarter's finer
// classification would be inert.
let seed_tex = splitmix64(21 ^ VEGETATION_TEXTURE_SALT);
let district_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 4_096.0);
let quarter_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 1_024.0);
assert_ne!(
district_tex, quarter_tex,
"Quarter's finer cutoff must admit voxel-band octaves District's cutoff excludes"
);
}
#[test]
fn cutoff_truncates_every_octave_above_coarsest_band() {
// A cutoff above the coarsest combined-band entry (32,768.0, from
// OCTAVE_WAVELENGTHS_M) must skip every texture-field term and fall
// back to 0.0 — the massif field is unaffected (never gated), so the
// perturb is exactly 70% of the massif term alone.
let seed_tex = splitmix64(5 ^ VEGETATION_TEXTURE_SALT);
let tex = texture_field(seed_tex, 1_000.0, 2_000.0, 1_000_000.0);
assert_eq!(tex, 0.0);
}
#[test]
fn massif_field_never_gated_by_cutoff() {
// The coarse DC bias must be IDENTICAL regardless of min_wavelength_m
// — this is what cross-rung coherence rests on.
let a = massif_field(42, 5_000_000.0, 2_000_000.0);
let b = massif_field(42, 5_000_000.0, 2_000_000.0);
assert_eq!(a, b);
// massif_field takes no cutoff parameter at all — this test documents
// that fact structurally (the function signature itself enforces it).
}
}