feat(simulation): T-1125/T-1126 coastline invention — two-tier character model, shared invent_primitives, Marine vegetation
T-1125: new atlas/coast_invention.rs — BodyCoastEnvelope (tier 1: erosion maturity derived hydro×atmo, tectonic energy; D-240 stands, no orbital inputs) + coast_character_at (tier 2: latitude, glaciation fjord response, local wetness, seeded 100-400 km heterogeneity field). C1 multi-octave warp ~16-262 km, sub-pixel cap 0.75 px, salted stream isolated from terrain scatter and climate edge-fuzz. district_profile: shared invent_primitives front-half (warped envelope sampling + slope-independent scatter floor + shoreline carving) wired into BOTH derive_district and derive_district_profile — the batch path's scatter-free area-mean aggregate is replaced by an invented centre-point sample of the same continuous field, making silent path divergence structurally impossible. T-1126: VegetationClass::Marine appended at discriminant 6, verdict morphology-derived (open_water from OpenOcean|Lake — no fourth ocean threshold); airless precedence preserved. voxel.rs exhaustive-match arm documented unreachable (WaterBody owns ocean districts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
//! Coastline / terrain-detail invention character model (T-1125, D-227).
|
||||
//!
|
||||
//! The D-227 promise — terrain finer than the heightmap is *invented
|
||||
//! deterministically* — ended at ~500 km until T-1125: district classification
|
||||
//! consumed the raw bilinear envelope, so coasts were pixel-smooth and the
|
||||
//! detail-scatter amplitude was slaved to the coarse heightmap slope (≈0 on
|
||||
//! exactly the low-relief coasts where coastlines live). This module carries
|
||||
//! the *character* half of the fix: **where the invention is jagged, where it
|
||||
//! is smooth, and how strongly it perturbs**, per Jeroen's 2026-07-17 rulings:
|
||||
//!
|
||||
//! - **Crinkle varies.** Never one global roughness constant. Two driver tiers:
|
||||
//! - **Tier 1 — body personality envelope** ([`BodyCoastEnvelope`]): derived
|
||||
//! from the *existing* authored body params only — `planet_class` (via
|
||||
//! `TectonicClass`), `hydrosphere`, `atmosphere`, `body_radius_km` via the
|
||||
//! pixel⇄metre seam. D-240 stands: **no axial tilt, no orbital data**. No
|
||||
//! erosion field exists — erosion-proneness is *derived*: more ocean → wetter,
|
||||
//! rainier → higher erosion → smoother mature coasts; dry / thin-atmosphere →
|
||||
//! sharp young coasts.
|
||||
//! - **Tier 2 — position character** ([`coast_character_at`]): latitude,
|
||||
//! driver-tier `GlaciationGrade` (high-latitude glaciated coasts go fjordy),
|
||||
//! local wetness, and a seeded long-wavelength heterogeneity field
|
||||
//! ([`character_field`], the T-1084 `voxel_mosaic` pattern at 100–400 km
|
||||
//! octaves) so stretches of the *same* coast differ. Longitude participates
|
||||
//! for free: everything is keyed on absolute world-metre coordinates.
|
||||
//!
|
||||
//! ## Determinism & continuity
|
||||
//!
|
||||
//! Pure functions of `(seed, body, position)` (D-227/D-010). The warp and the
|
||||
//! character field are C¹ multi-octave value noise — character never steps on a
|
||||
//! district/region line (the D-243 edge-fuzz discipline), and is identical for
|
||||
//! any window size or derivation path that asks about the same world position.
|
||||
//!
|
||||
//! ## Distinct hash path
|
||||
//!
|
||||
//! The warp stream is salted ([`COAST_WARP_SALT`]) so it is never correlated
|
||||
//! with the terrain detail-scatter or the climate edge-fuzz warp — the same
|
||||
//! isolation convention as `region_profile::climate_edge_warp`.
|
||||
|
||||
use crate::atlas::detail_scatter::value_noise;
|
||||
use crate::atlas::district_profile::{BodyParams, GlaciationGrade, TectonicClass};
|
||||
use crate::seed::splitmix64;
|
||||
|
||||
/// Distinct hash-path salt for the coastline warp stream (see module docs).
|
||||
const COAST_WARP_SALT: u64 = 0xC0A5_71E1_1BAD_5EED;
|
||||
|
||||
/// Salt separating the warp's y-channel from its x-channel.
|
||||
const COAST_WARP_Y_SALT: u64 = 0xD1F7_0CEA_2B0A_D515;
|
||||
|
||||
/// Salt for the tier-2 heterogeneity field (same-coast stretches differ).
|
||||
const CHARACTER_FIELD_SALT: u64 = 0x0C0A_57C4_A24C_7E12;
|
||||
|
||||
/// Coast-warp octave wavelengths in metres (≈16–262 km): capes and gulfs at the
|
||||
/// top, coves and inlets at the bottom. All above the 4–33 km detail-scatter
|
||||
/// band, and all below ~2 heightmap pixels — the warp perturbs the coast, it
|
||||
/// does not rewrite continents (the heightmap stays the truth at its own scale).
|
||||
const WARP_OCTAVE_WAVELENGTHS_M: [f64; 5] = [262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0];
|
||||
|
||||
/// Heterogeneity-field octave wavelengths in metres (≈100–400 km): the scale on
|
||||
/// which one planet's coastline personality drifts from stretch to stretch.
|
||||
const CHARACTER_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0];
|
||||
|
||||
/// Hard cap on the coast-warp displacement, in working-grid pixel units. Keeps
|
||||
/// the invention strictly sub-pixel so planetary-scale reads are unchanged.
|
||||
const WARP_AMPLITUDE_CAP_PX: f64 = 0.75;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1 — body personality envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The body-level coastline/relief personality (T-1125 tier 1) — one per body,
|
||||
/// derived from authored [`BodyParams`] only (D-240 discipline; see module docs).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BodyCoastEnvelope {
|
||||
/// 0–1 — Jeroen's erosion chain: `hydro_wetness × atmo_density`. Erosion
|
||||
/// needs both water and an atmosphere to rain it back down. High = mature,
|
||||
/// smoothed coasts; low = sharp young coasts.
|
||||
pub erosion_maturity: f64,
|
||||
/// 0–1 — from [`TectonicClass`] (planet_class-derived): relief energy.
|
||||
pub tectonic_energy: f64,
|
||||
/// Base octave-falloff roughness (0–1) before tier-2 modulation.
|
||||
pub roughness: f64,
|
||||
/// Base coast-warp amplitude in working-grid pixel units.
|
||||
pub warp_amplitude_px: f64,
|
||||
/// Base slope-independent detail-scatter floor, in `elev_pct` units (0–1).
|
||||
pub scatter_floor: f64,
|
||||
}
|
||||
|
||||
/// Derive the tier-1 envelope. `tectonic` is passed in (the caller hoists
|
||||
/// `derive_tectonic_class`, which needs only `BodyParams`).
|
||||
///
|
||||
/// The `hydrosphere` buckets mirror `derive_moisture_q`'s T-1034 vocabulary
|
||||
/// grouping — keep the two in sync when the DB vocabulary grows.
|
||||
pub fn body_coast_envelope(params: &BodyParams, tectonic: TectonicClass) -> BodyCoastEnvelope {
|
||||
let hydro_wetness: f64 = match params.hydrosphere.as_deref().unwrap_or("none") {
|
||||
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 1.0,
|
||||
"rivers" | "rivers-lakes" | "moderate" => 0.65,
|
||||
"ice" => 0.30,
|
||||
"subsurface_liquid" => 0.20,
|
||||
"subsurface" | "subsurface_ice" => 0.15,
|
||||
"minimal" | "trace" => 0.10,
|
||||
"none" => 0.0,
|
||||
_ => 0.40,
|
||||
};
|
||||
let atmo_density: f64 = match params.atmosphere.as_deref().unwrap_or("none") {
|
||||
"dense" => 1.0,
|
||||
"standard" | "breathable" => 0.85,
|
||||
"toxic" => 0.70,
|
||||
"thin" => 0.35,
|
||||
"none" => 0.0,
|
||||
_ => 0.50,
|
||||
};
|
||||
let erosion_maturity = hydro_wetness * atmo_density;
|
||||
let tectonic_energy: f64 = match tectonic {
|
||||
TectonicClass::Volcanic => 1.0,
|
||||
TectonicClass::Active | TectonicClass::TidallyForced => 0.70,
|
||||
TectonicClass::Stable => 0.25,
|
||||
};
|
||||
BodyCoastEnvelope {
|
||||
erosion_maturity,
|
||||
tectonic_energy,
|
||||
// Young (uneroded) + tectonic worlds read jagged; mature wet worlds smooth.
|
||||
roughness: (0.20 + 0.50 * tectonic_energy + 0.35 * (1.0 - erosion_maturity))
|
||||
.clamp(0.0, 1.0),
|
||||
// Every body gets embayments; erosion widens/rounds them (rias, estuaries),
|
||||
// tectonic energy adds displacement of its own. (Tuned against the T-1125
|
||||
// zoom ladder: at ~0.2 px effective amplitude the w64 view still read as
|
||||
// wavy stripes — bays need to displace a meaningful fraction of a pixel.)
|
||||
warp_amplitude_px: 0.30 + 0.25 * erosion_maturity + 0.20 * tectonic_energy,
|
||||
// Invented relief floor: young worlds carry more uneroded relief; even
|
||||
// mature worlds keep gentle rolling texture (eroded ≠ billiard-flat).
|
||||
scatter_floor: 0.10 + 0.15 * tectonic_energy + 0.05 * (1.0 - erosion_maturity),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 2 — position character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The resolved invention character at one world position (T-1125 tier 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CoastCharacter {
|
||||
/// Coast-warp displacement amplitude, working-grid pixel units (≤ cap).
|
||||
pub warp_amplitude_px: f64,
|
||||
/// 0–1 octave falloff: high keeps small-wavelength energy (jagged).
|
||||
pub roughness: f64,
|
||||
/// Slope-independent detail-scatter floor (`elev_pct` units).
|
||||
pub scatter_floor: f64,
|
||||
/// 0–1 ridged-folding blend for the scatter (fjord/tectonic sharpness).
|
||||
pub ridge: f64,
|
||||
}
|
||||
|
||||
/// Resolve the invention character at `(wx, wy)` world metres.
|
||||
///
|
||||
/// `driver_glaciation` / `driver_moisture_q` are the **one-step-stale** climate
|
||||
/// drivers computed from the *unwarped* raw-bilinear primitives (the T-1125
|
||||
/// circularity ruling — see the call site in `district_profile`).
|
||||
pub fn coast_character_at(
|
||||
env: &BodyCoastEnvelope,
|
||||
seed: u64,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
lat_deg: f64,
|
||||
driver_glaciation: GlaciationGrade,
|
||||
driver_moisture_q: i32,
|
||||
) -> CoastCharacter {
|
||||
// Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen).
|
||||
let hetero = character_field(seed, wx, wy);
|
||||
let fjord: f64 = match driver_glaciation {
|
||||
GlaciationGrade::None => 0.0,
|
||||
GlaciationGrade::Light => 0.30,
|
||||
GlaciationGrade::Moderate => 0.70,
|
||||
GlaciationGrade::Heavy => 1.0,
|
||||
// Under a permanent cap the fjord carving is there but partly buried.
|
||||
GlaciationGrade::IceCap => 0.90,
|
||||
};
|
||||
let lat_frac = (lat_deg.abs() / 90.0).clamp(0.0, 1.0);
|
||||
// Local wetness echoes the tier-1 erosion chain at position scale: the wet
|
||||
// stretches of a body erode smoother than its dry stretches.
|
||||
let wet_local = (driver_moisture_q.clamp(0, 100) as f64) / 100.0;
|
||||
|
||||
CoastCharacter {
|
||||
warp_amplitude_px: (env.warp_amplitude_px * (0.75 + 0.60 * hetero) * (1.0 + 0.50 * fjord))
|
||||
.min(WARP_AMPLITUDE_CAP_PX),
|
||||
roughness: (env.roughness + 0.35 * fjord + 0.10 * lat_frac + 0.25 * (hetero - 0.5)
|
||||
- 0.15 * wet_local)
|
||||
.clamp(0.05, 1.0),
|
||||
scatter_floor: (env.scatter_floor * (0.70 + 0.60 * hetero) + 0.10 * fjord).clamp(0.0, 0.40),
|
||||
ridge: (0.15 + 0.55 * fjord + 0.35 * env.tectonic_energy).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Long-wavelength heterogeneity field in `[0, 1]` (the T-1084 `voxel_mosaic`
|
||||
/// pattern at coast scale): drives *where along a coast* the character shifts.
|
||||
pub fn character_field(seed: u64, wx: f64, wy: f64) -> f64 {
|
||||
let seed = splitmix64(seed ^ CHARACTER_FIELD_SALT);
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
for (i, &wl) in CHARACTER_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) + 1.0) * 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// The coastline domain-warp displacement in **working-grid pixel units**.
|
||||
///
|
||||
/// Two independent C¹ fBm channels (x/y) on the salted stream, shaped by the
|
||||
/// position character: `roughness` controls octave falloff (mature coasts keep
|
||||
/// only the broad bays; young/fjordy coasts keep the jagged inlets) and `ridge`
|
||||
/// blends ridged folding in (crease-cornered incursions — fjord arms), exactly
|
||||
/// the `detail_scatter::enveloped_fbm` modulation convention.
|
||||
///
|
||||
/// Apply the same offset to *every* envelope field sampled at the position
|
||||
/// (elevation, slope, ocean mask) so the invented terrain moves coherently —
|
||||
/// a warped-in bay carries its sea-level elevation with it.
|
||||
pub fn coast_warp_px(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> (f64, f64) {
|
||||
let sx = splitmix64(seed ^ COAST_WARP_SALT);
|
||||
let sy = splitmix64(sx ^ COAST_WARP_Y_SALT);
|
||||
(
|
||||
warp_fbm(sx, wx, wy, ch) * ch.warp_amplitude_px,
|
||||
warp_fbm(sy, wx, wy, ch) * ch.warp_amplitude_px,
|
||||
)
|
||||
}
|
||||
|
||||
/// Roughness/ridge-shaped fBm in ≈`[-1, 1]` over the coast-warp octave band.
|
||||
fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> f64 {
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
for (i, &wl) in WARP_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
|
||||
let mut n = value_noise(
|
||||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||||
wx,
|
||||
wy,
|
||||
wl,
|
||||
);
|
||||
if ch.ridge > 0.0 {
|
||||
let ridged = 1.0 - 2.0 * n.abs();
|
||||
n = n * (1.0 - ch.ridge) + ridged * ch.ridge;
|
||||
}
|
||||
sum += n * amp;
|
||||
norm += amp;
|
||||
// Falloff: smooth coasts damp high frequencies (0.45 — rounded rias,
|
||||
// never featureless); jagged coasts keep them (0.80). Tuned on the
|
||||
// ladder: at 0.35 base a mature coast lost its 16–65 km headlands
|
||||
// entirely and still read as stripes at w64.
|
||||
amp *= 0.45 + 0.35 * ch.roughness;
|
||||
}
|
||||
sum / norm
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params(hydro: &str, atmo: &str, class: &str) -> BodyParams {
|
||||
BodyParams {
|
||||
hydrosphere: Some(hydro.to_string()),
|
||||
atmosphere: Some(atmo.to_string()),
|
||||
planet_class: Some(class.to_string()),
|
||||
tectonic_activity: None,
|
||||
latitude_deg: 0.0,
|
||||
elevation_km: 0.0,
|
||||
body_radius_km: Some(6000.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_differentiates_wet_from_dry_bodies() {
|
||||
// Jeroen's erosion chain: ocean+breathable erodes smooth; dry+thin stays sharp.
|
||||
let wet = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "temperate"),
|
||||
TectonicClass::Stable,
|
||||
);
|
||||
let dry = body_coast_envelope(¶ms("minimal", "thin", "arid"), TectonicClass::Stable);
|
||||
assert!(wet.erosion_maturity > dry.erosion_maturity);
|
||||
assert!(
|
||||
wet.roughness < dry.roughness,
|
||||
"mature coasts must be smoother"
|
||||
);
|
||||
assert!(
|
||||
wet.warp_amplitude_px > dry.warp_amplitude_px,
|
||||
"erosion widens embayments (rias) — wet worlds warp broader"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_tectonics_raise_roughness_and_relief_floor() {
|
||||
let stable = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "temperate"),
|
||||
TectonicClass::Stable,
|
||||
);
|
||||
let volcanic = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "volcanic"),
|
||||
TectonicClass::Volcanic,
|
||||
);
|
||||
assert!(volcanic.roughness > stable.roughness);
|
||||
assert!(volcanic.scatter_floor > stable.scatter_floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_fjord_raises_roughness_amplitude_and_ridge() {
|
||||
let env = body_coast_envelope(¶ms("ice", "thin", "frozen"), TectonicClass::Stable);
|
||||
let temperate = coast_character_at(&env, 42, 1e6, 2e6, 40.0, GlaciationGrade::None, 40);
|
||||
let fjordy = coast_character_at(&env, 42, 1e6, 2e6, 70.0, GlaciationGrade::Heavy, 40);
|
||||
assert!(fjordy.roughness > temperate.roughness);
|
||||
assert!(fjordy.warp_amplitude_px > temperate.warp_amplitude_px);
|
||||
assert!(fjordy.ridge > temperate.ridge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_heterogeneity_varies_along_a_coast() {
|
||||
// Same body, same latitude, positions ~600 km apart: the seeded field must
|
||||
// move the character so same-coast stretches differ (Jeroen's ruling).
|
||||
let env = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "temperate"),
|
||||
TectonicClass::Stable,
|
||||
);
|
||||
let chars: Vec<f64> = (0..8)
|
||||
.map(|i| {
|
||||
coast_character_at(
|
||||
&env,
|
||||
7,
|
||||
i as f64 * 600_000.0,
|
||||
500_000.0,
|
||||
30.0,
|
||||
GlaciationGrade::None,
|
||||
50,
|
||||
)
|
||||
.warp_amplitude_px
|
||||
})
|
||||
.collect();
|
||||
let min = chars.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let max = chars.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!(
|
||||
max - min > 0.02,
|
||||
"character must drift along the coast (got range {})",
|
||||
max - min
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_is_deterministic_bounded_and_continuous() {
|
||||
let env = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "temperate"),
|
||||
TectonicClass::Stable,
|
||||
);
|
||||
let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55);
|
||||
let a = coast_warp_px(42, 3e6, 1e6, &ch);
|
||||
let b = coast_warp_px(42, 3e6, 1e6, &ch);
|
||||
assert_eq!(a, b, "warp must be deterministic");
|
||||
for i in 0..400 {
|
||||
let (dx, dy) = coast_warp_px(42, i as f64 * 9_137.0, i as f64 * -7_211.0, &ch);
|
||||
assert!(dx.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9);
|
||||
assert!(dy.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9);
|
||||
}
|
||||
// C¹ continuity: a 10 m step is a tiny displacement change — the coast
|
||||
// character never steps on a line (D-243 edge-fuzz discipline).
|
||||
let (x0, y0) = coast_warp_px(42, 5e6, 5e6, &ch);
|
||||
let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch);
|
||||
assert!((x0 - x1).abs() < 0.01 && (y0 - y1).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_stream_uncorrelated_with_scatter_stream() {
|
||||
// Distinct hash path: the warp at a position must not track the terrain
|
||||
// detail-scatter at the same position (salted stream isolation).
|
||||
let env = body_coast_envelope(
|
||||
¶ms("ocean", "breathable", "temperate"),
|
||||
TectonicClass::Stable,
|
||||
);
|
||||
let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60);
|
||||
let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch);
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5);
|
||||
assert_ne!(wdx, scatter);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
|
||||
|
||||
/// Smooth (C¹) value-noise sample in `[-1, 1]` at world `(wx, wy)` for one
|
||||
/// wavelength. Smoothstep interpolation keeps lattice cell boundaries crease-free.
|
||||
fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 {
|
||||
/// `pub(crate)`: also the noise substrate of the T-1125 coastline invention
|
||||
/// ([`crate::atlas::coast_invention`]), which salts its own seed stream.
|
||||
pub(crate) fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 {
|
||||
let fx = wx / wavelength_m;
|
||||
let fy = wy / wavelength_m;
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::coast_invention;
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos};
|
||||
@@ -117,6 +118,13 @@ pub enum VegetationClass {
|
||||
/// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones.
|
||||
/// Takes precedence over Forest when adjacent to a perennial river.
|
||||
RiparianThicket = 5,
|
||||
/// Open water (T-1126): the district's morphology verdict is OpenOcean/Lake —
|
||||
/// no land vegetation. Set from the already-derived `MorphologyZone`, never
|
||||
/// from a water threshold of its own (vegetation and morphology can never
|
||||
/// disagree by construction). NOTE: the `Ord` position is NON-SEMANTIC —
|
||||
/// the D-239 §8 Forest>Scrub>Barren density-ladder reading does not extend
|
||||
/// to `Marine`; the discriminant is append-only serialisation order.
|
||||
Marine = 6,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -377,17 +385,31 @@ pub fn derive_glaciation_grade_from_climate(
|
||||
/// `near_perennial_water` signals whether this district is within the riparian
|
||||
/// band (i.e. the district or an adjacent district carries a perennial waterway).
|
||||
/// When true the riparian sub-variant is returned: `RiparianThicket` over
|
||||
/// Forest, `RiparianScrub` over Scrub/Barren.
|
||||
/// Forest, `RiparianScrub` over Scrub/Barren. It is a RIVER-proximity signal —
|
||||
/// unrelated to `open_water`, which is the ocean/lake verdict.
|
||||
///
|
||||
/// `open_water` (T-1126) is the **morphology-derived** water verdict — `true`
|
||||
/// when the district's already-derived `MorphologyZone` is OpenOcean/Lake.
|
||||
/// Callers pass the verdict in; this function must NEVER re-derive it from an
|
||||
/// ocean-fraction threshold of its own (no fourth threshold — vegetation and
|
||||
/// morphology can never disagree by construction). Airless takes precedence:
|
||||
/// an airless body has no climate/vegetation branch at all (D-239 §2), so its
|
||||
/// seas read `Absent`, not `Marine`.
|
||||
pub fn derive_vegetation(
|
||||
temperature_c: Option<f32>,
|
||||
moisture_q: i32,
|
||||
elev_q: i32,
|
||||
near_perennial_water: bool,
|
||||
open_water: bool,
|
||||
) -> VegetationClass {
|
||||
// Airless: entire climate/vegetation branch absent.
|
||||
let Some(temp_c) = temperature_c else {
|
||||
return VegetationClass::Absent;
|
||||
};
|
||||
// Open water (T-1126): the morphology verdict, passed in — see doc above.
|
||||
if open_water {
|
||||
return VegetationClass::Marine;
|
||||
}
|
||||
let temp_i = temp_c as i32;
|
||||
|
||||
// Extreme cold → Barren regardless of water proximity: below ~−50 °C mean
|
||||
@@ -1032,6 +1054,140 @@ pub fn derive_moisture_q(
|
||||
(ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-1125 — invented primitives (shared by both derivation paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The invented `(slope_q, elev_q, ocean_fraction_q)` triple (T-1125, D-227).
|
||||
struct InventedPrimitives {
|
||||
slope_q: i32,
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
}
|
||||
|
||||
/// Compose the invented district primitives at one position — the shared front
|
||||
/// half of BOTH derivation paths ([`derive_district`] on-demand and
|
||||
/// [`derive_district_profile`] batch), so the D-227 invention can never diverge
|
||||
/// between them again (the pre-T-1125 failure: the batch path was scatter-free).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw
|
||||
/// *unwarped* bilinear envelope feeds a provisional moisture/temperature →
|
||||
/// [`derive_glaciation_grade_from_climate`] — the coast character reads
|
||||
/// these stale drivers; it never reads the warped values it produces. The
|
||||
/// raw inputs are the coarse ~40–160 km/pixel envelope anyway, so one step
|
||||
/// of staleness is far below the signal's own resolution.
|
||||
/// 2. **Character:** tier-1 body envelope ([`coast_invention::body_coast_envelope`];
|
||||
/// `derive_tectonic_class` hoisted here — it needs only `BodyParams`) +
|
||||
/// tier-2 position character ([`coast_invention::coast_character_at`]).
|
||||
/// 3. **Invented coastline:** every envelope field (elevation, slope, ocean
|
||||
/// mask) is sampled at the SAME warped position, so terrain moves coherently
|
||||
/// — a warped-in bay carries its sea-level elevation with it.
|
||||
/// 4. **Slope-independent scatter:** the character's `scatter_floor` breaks the
|
||||
/// old amplitude-slaved-to-coarse-slope collapse (invention ≈ 0 exactly on
|
||||
/// low-relief coasts); `ridge` carries fjord/tectonic sharpness.
|
||||
///
|
||||
/// `body_params` must already carry the district's `latitude_deg`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn invent_primitives(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
climate: &ClimateConstants,
|
||||
ta: &TerrainAnalysis,
|
||||
px: f64,
|
||||
py: f64,
|
||||
world_x_m: f64,
|
||||
world_y_m: f64,
|
||||
region_baseline_c: Option<f32>,
|
||||
) -> InventedPrimitives {
|
||||
// ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ─────
|
||||
let raw_elev_q =
|
||||
((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100);
|
||||
let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0).round()
|
||||
as i32)
|
||||
.clamp(0, 100);
|
||||
let driver_params = BodyParams {
|
||||
elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
|
||||
..body_params.clone()
|
||||
};
|
||||
let driver_temp = match region_baseline_c {
|
||||
Some(b) => derive_district_temperature_c(Some(b), &driver_params, climate, 0.0),
|
||||
None => derive_temperature_c(&driver_params, climate, seed.seed()),
|
||||
};
|
||||
let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate);
|
||||
let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture);
|
||||
|
||||
// ── 2. Character: body personality envelope + position modulation. ──────
|
||||
let tectonic = derive_tectonic_class(body_params);
|
||||
let envelope = coast_invention::body_coast_envelope(body_params, tectonic);
|
||||
let ch = coast_invention::coast_character_at(
|
||||
&envelope,
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
body_params.latitude_deg,
|
||||
driver_glaciation,
|
||||
driver_moisture,
|
||||
);
|
||||
|
||||
// ── 3. Invented coastline: warp the whole envelope sampling. ────────────
|
||||
let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch);
|
||||
let (spx, spy) = (px + wdx, py + wdy);
|
||||
let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64;
|
||||
let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, spx, spy) as f64;
|
||||
let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64;
|
||||
|
||||
// ── 4. Slope-independent scatter (floor + ridge character). ─────────────
|
||||
let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0);
|
||||
let env_amp = (local_slope + ch.scatter_floor).clamp(0.0, 1.0);
|
||||
let ruggedness = (0.6 * local_slope + 0.5 * ch.ridge).clamp(0.0, 1.0);
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
env_amp,
|
||||
ruggedness,
|
||||
);
|
||||
|
||||
// Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut
|
||||
// steep — fjord walls and cliff coasts. The gentle scatter floor alone can
|
||||
// never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40,
|
||||
// CliffCoast at ≥ 55), so where the invented position is coastal
|
||||
// (`shoreline` peaks at the ocean-fraction midpoint) and the character is
|
||||
// ridged (fjord/tectonic), the normalized scatter contributes real slope —
|
||||
// patchy by construction (it reuses the scatter noise), so a glaciated
|
||||
// coast grows fjord SEGMENTS, not a uniform fjord stripe.
|
||||
let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0);
|
||||
let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0);
|
||||
|
||||
InventedPrimitives {
|
||||
elev_q: (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100),
|
||||
slope_q: (((local_slope + ruggedness * scatter.abs() + carve) * 100.0).round() as i32)
|
||||
.clamp(0, 100),
|
||||
ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fractional working-grid position → absolute world metres — the inverse of
|
||||
/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam), used by
|
||||
/// the batch path so both derivation paths key the invention noise fields on
|
||||
/// the same world-metre convention. Radius-less bodies fall back to the
|
||||
/// 1-working-pixel = 1-district convention `derive_district` uses.
|
||||
fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>) -> (f64, f64) {
|
||||
match radius_km {
|
||||
Some(r) if r > 0.0 => {
|
||||
let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0);
|
||||
let lat_frac = if h > 1 {
|
||||
py / (h - 1) as f64 - 0.5
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(wx, lat_frac * (std::f64::consts::PI * r * 1000.0))
|
||||
}
|
||||
_ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public derivation function
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1075,43 +1231,27 @@ pub fn derive_district_profile(
|
||||
let h = ta.h;
|
||||
let gcpr = grid_cells_per_district.max(1);
|
||||
|
||||
// Compute aggregate terrain statistics over the cells in this district.
|
||||
// All arithmetic is integer or quantized-integer (D-010).
|
||||
let mut slope_sum: i64 = 0;
|
||||
let mut elev_sum: i64 = 0;
|
||||
let mut ocean_count: i64 = 0;
|
||||
let mut cell_count: i64 = 0;
|
||||
|
||||
// T-1125: the batch path runs the SAME invention as the on-demand path — an
|
||||
// invented centre-point sample of the continuous field (warped coastline +
|
||||
// slope-independent scatter via `invent_primitives`) instead of the former
|
||||
// scatter-free cell-aggregate mean. One invention path, shared with
|
||||
// [`derive_district`], so the two can never silently diverge again; the
|
||||
// pseudo-grid samples the same truth the 2 km carrier does. (The former mean
|
||||
// smoothed away exactly the variance the believability contrast metrics
|
||||
// measure — and carried no invention at all.)
|
||||
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
|
||||
let row_end = row_start.saturating_add(gcpr).min(h);
|
||||
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
|
||||
let col_end = col_start.saturating_add(gcpr).min(w);
|
||||
|
||||
for r in row_start..row_end {
|
||||
for c in col_start..col_end {
|
||||
let i = r * w + c;
|
||||
// Quantize slope to 0–100: slope_deg is ~[0, 45°]; divide by 45 × 100.
|
||||
// This is a float-to-int boundary inside the aggregation; downstream
|
||||
// decisions use `slope_q` (integer).
|
||||
slope_sum += ((ta.slope_deg[i] / 45.0).clamp(0.0, 1.0) * 100.0) as i64;
|
||||
elev_sum += (ta.elev_pct[i] * 100.0) as i64;
|
||||
ocean_count += ta.ocean_mask[i] as i64;
|
||||
cell_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let (slope_q, elev_q, ocean_fraction_q) = if cell_count > 0 {
|
||||
let sq = (slope_sum / cell_count) as i32;
|
||||
let eq = (elev_sum / cell_count) as i32;
|
||||
let oq = (ocean_count * 100 / cell_count) as i32;
|
||||
(sq, eq, oq)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0;
|
||||
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
|
||||
let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km);
|
||||
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district,
|
||||
// then pass it through build_district_profile so the two-phase derivation path runs.
|
||||
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
@@ -1122,13 +1262,25 @@ pub fn derive_district_profile(
|
||||
Some(region_cache),
|
||||
);
|
||||
|
||||
let prims = invent_primitives(
|
||||
seed,
|
||||
body_params,
|
||||
climate,
|
||||
ta,
|
||||
px,
|
||||
py,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
body_params,
|
||||
climate,
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
prims.slope_q,
|
||||
prims.elev_q,
|
||||
prims.ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
basin_direction,
|
||||
)
|
||||
@@ -1200,11 +1352,8 @@ fn build_district_profile(
|
||||
let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q);
|
||||
let river_threshold = derive_river_threshold(tectonic_class, precipitation_class);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
|
||||
// (requires perennial waterway map from L2+); default to false for now.
|
||||
// L2 ChunkContext will override per-tile once drainage data is threaded through.
|
||||
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false);
|
||||
|
||||
// Morphology precedes vegetation (T-1126): the vegetation call consumes the
|
||||
// morphology water verdict, so the two can never disagree by construction.
|
||||
let morphology_zone = derive_morphology_zone(
|
||||
tectonic_class,
|
||||
glaciation_grade,
|
||||
@@ -1214,6 +1363,16 @@ fn build_district_profile(
|
||||
moisture_q,
|
||||
);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
|
||||
// (requires perennial waterway map from L2+); default to false for now.
|
||||
// L2 ChunkContext will override per-tile once drainage data is threaded through.
|
||||
// open_water = the morphology verdict (T-1126) — never a threshold of its own.
|
||||
let open_water = matches!(
|
||||
morphology_zone,
|
||||
MorphologyZone::OpenOcean | MorphologyZone::Lake
|
||||
);
|
||||
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water);
|
||||
|
||||
DistrictProfile {
|
||||
morphology_zone,
|
||||
tectonic_class,
|
||||
@@ -1291,32 +1450,6 @@ pub fn derive_district(
|
||||
}
|
||||
};
|
||||
|
||||
// Bilinear-interpolated Layer-1 envelope at the district position.
|
||||
let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64;
|
||||
let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64;
|
||||
let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64;
|
||||
|
||||
// Envelope + adaptive ruggedness from the local heightmap slope (steep
|
||||
// heightmap ⇒ relief headroom ⇒ rugged). Both 0–1.
|
||||
let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0);
|
||||
let envelope = local_slope;
|
||||
let ruggedness = local_slope;
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
envelope,
|
||||
ruggedness,
|
||||
);
|
||||
|
||||
// Compose the primitives. Scatter perturbs elevation (mid-scale relief); slope
|
||||
// gains a ruggedness-weighted bump so the morphology classifier responds
|
||||
// (adaptive: rugged highs → mountain/pass, flats → plains).
|
||||
let elev_q = (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100);
|
||||
let slope_q =
|
||||
(((local_slope + ruggedness * scatter.abs()) * 100.0).round() as i32).clamp(0, 100);
|
||||
let ocean_fraction_q = ((ocean_frac * 100.0).round() as i32).clamp(0, 100);
|
||||
|
||||
let params = BodyParams {
|
||||
latitude_deg: lat_deg,
|
||||
..body_params.clone()
|
||||
@@ -1326,6 +1459,8 @@ pub fn derive_district(
|
||||
// this district. No pre-built cache here — the on-demand path derives the four
|
||||
// surrounding region baselines directly. Pure, deterministic, cheap.
|
||||
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
@@ -1336,6 +1471,21 @@ pub fn derive_district(
|
||||
None, // no pre-built cache; derive on-the-fly
|
||||
);
|
||||
|
||||
// T-1125: invented primitives — warped coastline (invented bays/capes) +
|
||||
// slope-independent character-driven scatter. Shared with the batch path
|
||||
// (`derive_district_profile`) via `invent_primitives`.
|
||||
let prims = invent_primitives(
|
||||
seed,
|
||||
¶ms,
|
||||
climate,
|
||||
ta,
|
||||
px,
|
||||
py,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
);
|
||||
|
||||
// derive_district is the on-demand path (arbitrary DistrictPos, no L1 working
|
||||
// grid). basin_direction is an ACCEPTED LIMITATION here: it defaults to North
|
||||
// (a fallback, not a computed value) because the D8 thalweg is only available
|
||||
@@ -1347,9 +1497,9 @@ pub fn derive_district(
|
||||
seed,
|
||||
¶ms,
|
||||
climate,
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
prims.slope_q,
|
||||
prims.elev_q,
|
||||
prims.ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
BasinDirection::default(),
|
||||
)
|
||||
@@ -1647,9 +1797,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_district_flat_envelope_invents_no_relief() {
|
||||
// A perfectly flat heightmap (no slope) → envelope 0 → scatter invents
|
||||
// nothing → elev_q equals the interpolated base (the envelope rule).
|
||||
fn derive_district_flat_envelope_bounded_invention() {
|
||||
// Pre-T-1125 this pinned "flat heightmap → ZERO invented relief" — the
|
||||
// envelope-as-amplitude reading that collapsed the D-227 invention on
|
||||
// exactly the low-relief coasts where coastlines live. T-1125 (Jeroen's
|
||||
// crinkle-varies ruling) replaces the zero with a bounded FLOOR: a flat
|
||||
// world gains gentle invented relief that varies from place to place,
|
||||
// never mountains, and the invention cannot conjure water that is not
|
||||
// in the ocean mask.
|
||||
let (w, h) = (64u32, 32u32);
|
||||
let flat = BodyHeightmap {
|
||||
body_id: "flat".into(),
|
||||
@@ -1661,13 +1816,27 @@ mod tests {
|
||||
let ta = test_ta(&flat);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let d = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate);
|
||||
// Flat land everywhere → slope 0 → no invented relief, no ocean.
|
||||
assert_eq!(
|
||||
d.slope_q, 0,
|
||||
"flat heightmap must yield zero district slope"
|
||||
let a = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate);
|
||||
let b = derive_district(test_seed(), "flat", &p, &ta, (900, 40), &climate);
|
||||
// No ocean can be invented on a fully-landlocked flat world: the coast
|
||||
// warp displaces sampling, it never fabricates mask content.
|
||||
assert_eq!(a.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
assert_eq!(b.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
// Bounded invention: gentle relief only — an authored plain must never
|
||||
// sprout mountain-grade slopes (the envelope rule survives as a ceiling).
|
||||
assert!(a.slope_q <= 25, "invented slope too steep: {}", a.slope_q);
|
||||
assert!(b.slope_q <= 25, "invented slope too steep: {}", b.slope_q);
|
||||
// And the invention must actually vary between distant districts — the
|
||||
// old zero rule left every flat district identical (the T-1125 finding).
|
||||
assert!(
|
||||
a.elev_q != b.elev_q || a.slope_q != b.slope_q,
|
||||
"flat-world districts must differ under the invention floor \
|
||||
(a: elev_q={} slope_q={}, b: elev_q={} slope_q={})",
|
||||
a.elev_q,
|
||||
a.slope_q,
|
||||
b.elev_q,
|
||||
b.slope_q
|
||||
);
|
||||
assert_eq!(d.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2325,7 +2494,7 @@ mod tests {
|
||||
fn vegetation_airless_is_absent() {
|
||||
// D-239 §2: airless body → vegetation branch absent.
|
||||
assert_eq!(
|
||||
derive_vegetation(None, 50, 20, false),
|
||||
derive_vegetation(None, 50, 20, false, false),
|
||||
VegetationClass::Absent
|
||||
);
|
||||
}
|
||||
@@ -2334,7 +2503,7 @@ mod tests {
|
||||
fn vegetation_warm_moist_low_elevation_is_forest() {
|
||||
// Warm + moist + low elevation → Forest.
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 60, 10, false),
|
||||
derive_vegetation(Some(20.0), 60, 10, false, false),
|
||||
VegetationClass::Forest
|
||||
);
|
||||
}
|
||||
@@ -2343,7 +2512,7 @@ mod tests {
|
||||
fn vegetation_high_elevation_is_barren() {
|
||||
// Warm + moist but very high elevation → Barren (above treeline).
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 60, 90, false),
|
||||
derive_vegetation(Some(20.0), 60, 90, false, false),
|
||||
VegetationClass::Barren
|
||||
);
|
||||
}
|
||||
@@ -2356,7 +2525,7 @@ mod tests {
|
||||
let moisture = 60_i32;
|
||||
let mut prev: Option<VegetationClass> = None;
|
||||
for elev_q in (0..=100).step_by(5) {
|
||||
let v = derive_vegetation(temp, moisture, elev_q, false);
|
||||
let v = derive_vegetation(temp, moisture, elev_q, false, false);
|
||||
if let Some(p) = prev {
|
||||
// Absent not reachable here (has atmosphere); skip riparian variants.
|
||||
let is_base = matches!(
|
||||
@@ -2383,14 +2552,14 @@ mod tests {
|
||||
#[test]
|
||||
fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() {
|
||||
// A scrub-zone district near perennial water → RiparianScrub.
|
||||
let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone
|
||||
let v = derive_vegetation(Some(20.0), 60, 85, true, false); // high elev = scrub zone
|
||||
assert_eq!(v, VegetationClass::RiparianScrub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_riparian_upgrades_forest_to_riparian_thicket() {
|
||||
// A forest-zone district near perennial water → RiparianThicket.
|
||||
let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone
|
||||
let v = derive_vegetation(Some(20.0), 60, 10, true, false); // low elev = forest zone
|
||||
assert_eq!(v, VegetationClass::RiparianThicket);
|
||||
}
|
||||
|
||||
@@ -2403,13 +2572,37 @@ mod tests {
|
||||
assert_eq!(VegetationClass::Forest as u8, 3);
|
||||
assert_eq!(VegetationClass::RiparianScrub as u8, 4);
|
||||
assert_eq!(VegetationClass::RiparianThicket as u8, 5);
|
||||
// T-1126: Marine appended (open-water verdict). Ord position is
|
||||
// NON-SEMANTIC — the density ladder does not extend to Marine.
|
||||
assert_eq!(VegetationClass::Marine as u8, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_open_water_is_marine_and_airless_wins() {
|
||||
// T-1126: the morphology water verdict → Marine…
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(15.0), 80, 5, false, true),
|
||||
VegetationClass::Marine
|
||||
);
|
||||
// …but airless takes precedence: no climate/vegetation branch at all
|
||||
// (D-239 §2) — an airless sea is Absent, not Marine.
|
||||
assert_eq!(
|
||||
derive_vegetation(None, 80, 5, false, true),
|
||||
VegetationClass::Absent
|
||||
);
|
||||
// The riparian flag is river-proximity, unrelated to open water: a
|
||||
// riparian river district on land is NOT Marine.
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(15.0), 80, 5, true, false),
|
||||
VegetationClass::RiparianThicket
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_hyper_arid_is_barren_not_absent() {
|
||||
// With atmosphere but zero moisture → Barren (not Absent).
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 0, 10, false),
|
||||
derive_vegetation(Some(20.0), 0, 10, false, false),
|
||||
VegetationClass::Barren
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod body_world_state;
|
||||
pub mod cascade;
|
||||
pub mod chunk_context;
|
||||
pub mod city_context_reader;
|
||||
pub mod coast_invention;
|
||||
pub mod detail_scatter;
|
||||
pub mod district_mix;
|
||||
pub mod district_profile;
|
||||
|
||||
@@ -160,6 +160,11 @@ impl Vegetation {
|
||||
VegetationClass::Forest => Vegetation::Forest,
|
||||
VegetationClass::RiparianScrub => Vegetation::Thicket,
|
||||
VegetationClass::RiparianThicket => Vegetation::Thicket,
|
||||
// T-1126: open-water districts route to the WaterBody generator
|
||||
// upstream (D-239/T-1082) and never reach the land-vegetation
|
||||
// baseline; the arm exists for exhaustiveness and maps to bare
|
||||
// ground exactly like Absent if one ever slips through.
|
||||
VegetationClass::Marine => Vegetation::Barren,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user