From fef422bf018b7109b653678ae8e3c260871b44ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 08:21:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20T-1125/T-1126=20coastline?= =?UTF-8?q?=20invention=20=E2=80=94=20two-tier=20character=20model,=20shar?= =?UTF-8?q?ed=20invent=5Fprimitives,=20Marine=20vegetation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/atlas/coast_invention.rs | 388 +++++++++++++++++++++++++++ server/src/atlas/detail_scatter.rs | 4 +- server/src/atlas/district_profile.rs | 359 +++++++++++++++++++------ server/src/atlas/mod.rs | 1 + server/src/atlas/voxel.rs | 5 + 5 files changed, 673 insertions(+), 84 deletions(-) create mode 100644 server/src/atlas/coast_invention.rs diff --git a/server/src/atlas/coast_invention.rs b/server/src/atlas/coast_invention.rs new file mode 100644 index 000000000..006bc2d87 --- /dev/null +++ b/server/src/atlas/coast_invention.rs @@ -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 = (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); + } +} diff --git a/server/src/atlas/detail_scatter.rs b/server/src/atlas/detail_scatter.rs index 207ccffb0..54d9280f2 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -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()); diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 2ab9b3045..fe9887738 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -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, 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, +) -> 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) { + 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 = 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 ); } diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index b75751b90..7b3e206cd 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -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; diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 36da38804..3240d7ecf 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -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, } } }