From 961855f276e427f2f9823494e085c663b0055e37 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 13:12:34 +0200 Subject: [PATCH 1/2] feat(simulation): climate-derived fields + frozen 17-zone morphology (T-1025, T-1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-1025 (D-239 §2) — climate-derived fields, all from the temperature(+moisture) primitive: - precipitation_class + glaciation_grade re-keyed to (temperature_c, moisture_q) inputs (were keyed off raw body params); river_threshold signature follows. - New VegetationClass (Absent/Barren/Scrub/Forest/RiparianScrub/RiparianThicket) + derive_vegetation: treeline bands x elevation, structural Forest->Scrub->Barren no-skip, riparian 1-3 tile band; airless (temp None) -> Absent. - 21 tests. T-1027 (D-239 §5/§6/§7) — morphology classifier: - FROZEN 17-zone MorphologyZone enum exactly per D-239 §6 (OpenOcean..Wetland, repr(u8) pinned). Removed legacy Sea/CoastalLowland/Island/Canyon/Unknown; all consumers (skeleton_gen street_topology) remapped to nearest D-239 zone. - 8-family gated decision tree (LavaField..AlluvialPlain fallback) over integer inputs; hard gates: Fjord>=GlaciationGrade2, LavaField=Volcanic tectonic, lithology slope/form laws (§8). 4 sub-classification zones (TidalFlat, Estuarine, Alpine, Wetland) derived from family + elevation/water. - Build-time compatibility-matrix invariant tests (§7): MeanderReach<->Volcanic and Volcanic<->Fjord forbidden adjacencies structurally prevented by gate order. - 13 tests incl 17-variant completeness + discriminant pinning. D-010 integer discipline throughout. cargo test 1534 pass, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/region_profile.rs | 1103 +++++++++++++++++++++++++--- server/src/atlas/skeleton_gen.rs | 36 +- server/src/simulation/generator.rs | 71 +- 3 files changed, 1066 insertions(+), 144 deletions(-) diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 8912828cc..132d75d47 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -12,12 +12,11 @@ //! derived from integer body params via integer arithmetic. No `HashMap` or //! floating-point comparison in the derivation path. //! -//! ## MorphologyZone note +//! ## MorphologyZone (T-1027, D-239 §6 freeze point) //! -//! The `morphology_zone` field uses the **existing** `MorphologyZone` enum from -//! `simulation::generator`. D-239 §6 defines a frozen 17-zone vocabulary; reconciling -//! the two (replacing the 12-variant enum with 17) is T-1027's job. For now we carry -//! the existing enum as-is. +//! The `morphology_zone` field uses the D-239 §6 **frozen 17-zone** `MorphologyZone` +//! enum from `simulation::generator`. The enum is the canonical freeze; adding, +//! renaming, or removing a zone requires a D-record amendment. use std::collections::BTreeMap; @@ -68,7 +67,7 @@ pub enum GlaciationGrade { IceCap = 4, } -/// Precipitation class derived from hydrosphere + temperature band. +/// Precipitation class derived from temperature + moisture primitives (D-239 §2). /// /// Integer-discriminant, D-010 compliant. Used to derive `river_threshold`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] @@ -87,6 +86,37 @@ pub enum PrecipitationClass { SuperHumid = 4, } +/// Vegetation cover class (D-239 §2, §8 climate→vegetation law). +/// +/// Ordered by density: `Forest` > `Scrub` > `Barren`. The D-239 §8 binding law +/// states "Forest→Scrub→Barren, no skip" — a region cannot jump from Forest to +/// Barren. Integer-discriminant, D-010 compliant. +/// +/// `None` variant: airless body (`temperature_c == None`) — entire +/// climate/vegetation branch is absent (D-239 §2). +/// +/// Riparian zones (`RiparianThicket`, `RiparianScrub`) are sub-variants of the +/// 1–3 tile band along perennial waterways (D-239 §8). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum VegetationClass { + /// Airless body — climate/vegetation branch absent (D-239 §2). + #[default] + Absent = 0, + /// Barren — above treeline or hyper-arid; sparse or no plant cover. + Barren = 1, + /// Scrub — transitional band below treeline; shrubs, dwarf plants. + Scrub = 2, + /// Forest — below treeline with sufficient moisture; closed-canopy. + Forest = 3, + /// Riparian Scrub — 1–3 tile band along perennial waterways in Scrub/Barren zones. + /// Takes precedence over the base class when adjacent to a perennial river. + RiparianScrub = 4, + /// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones. + /// Takes precedence over Forest when adjacent to a perennial river. + RiparianThicket = 5, +} + // --------------------------------------------------------------------------- // Body parameters (input to derivation) // --------------------------------------------------------------------------- @@ -182,6 +212,14 @@ pub struct RegionProfile { /// Moisture primitive (T-1024 forward declaration). /// Integer 0–100; 0 = arid, 100 = saturated. pub moisture_q: i32, + + /// Vegetation cover class (T-1025, D-239 §2 §8). + /// + /// `VegetationClass::Absent` when `temperature_c == None` (airless body). + /// Forest→Scrub→Barren ordering; no-skip invariant enforced by `derive_vegetation`. + /// Riparian variants override the base class in the 1–3 tile band along + /// perennial waterways (D-239 §8 climate→vegetation law). + pub vegetation_class: VegetationClass, } // --------------------------------------------------------------------------- @@ -210,56 +248,209 @@ fn derive_tectonic_class(params: &BodyParams) -> TectonicClass { } } -/// Derive `PrecipitationClass` from body params. -fn derive_precipitation_class(params: &BodyParams) -> PrecipitationClass { - match params.hydrosphere.as_deref().unwrap_or("none") { - "none" => PrecipitationClass::Arid, - "subsurface" => PrecipitationClass::SemiArid, - "ice" => PrecipitationClass::SemiArid, - "rivers" => PrecipitationClass::Temperate, - "ocean" => match params.atmosphere.as_deref().unwrap_or("none") { - "dense" | "breathable" => PrecipitationClass::SuperHumid, - "thin" => PrecipitationClass::Humid, - _ => PrecipitationClass::Temperate, - }, - _ => PrecipitationClass::Temperate, +/// Derive `PrecipitationClass` from temperature + moisture primitives (D-239 §2). +/// +/// Precipitation is f(temperature, moisture). `None` temperature (airless body) +/// → always `Arid` (no precipitation without atmosphere; D-239 §2). +/// All comparisons are on integer `moisture_q` (0–100) and integer °C cast +/// from temperature_c — D-010 compliant (no float gate). +pub fn derive_precipitation_class_from_climate( + temperature_c: Option, + moisture_q: i32, +) -> PrecipitationClass { + // Airless: no atmosphere → no precipitation cycle. + let Some(temp_c) = temperature_c else { + return PrecipitationClass::Arid; + }; + // Integer cast: all gating uses i32 (D-010; f32→i32 cast is a positional + // quantisation, not a structural comparison). + let temp_i = temp_c as i32; + + // Very cold + any moisture → frozen world with low effective precipitation. + // Below −40°C the atmosphere holds very little moisture regardless. + if temp_i < -40 { + return if moisture_q >= 20 { + PrecipitationClass::SemiArid + } else { + PrecipitationClass::Arid + }; + } + + // Main precipitation ladder: moisture_q drives class; temperature modulates. + match moisture_q { + 0..=10 => PrecipitationClass::Arid, + 11..=25 => PrecipitationClass::SemiArid, + 26..=55 => { + // Cold-dry worlds drop one class (thin air → less precipitation cycle). + if temp_i < 0 { + PrecipitationClass::SemiArid + } else { + PrecipitationClass::Temperate + } + } + 56..=75 => { + if temp_i < 0 { + PrecipitationClass::Temperate + } else { + PrecipitationClass::Humid + } + } + _ => { + // moisture_q > 75 + if temp_i < -10 { + PrecipitationClass::Humid + } else { + PrecipitationClass::SuperHumid + } + } } } -/// Derive `GlaciationGrade` from body params. +/// Derive `GlaciationGrade` from temperature + moisture primitives (D-239 §2, §8). /// -/// Cold bodies (frozen planet_class, ice hydrosphere) with atmosphere get -/// higher glaciation. Airless ice bodies get grade 0 (geology, not climate). -fn derive_glaciation_grade(params: &BodyParams) -> GlaciationGrade { - let has_atmo = params.atmosphere.as_deref().unwrap_or("none") != "none"; - let planet = params.planet_class.as_deref().unwrap_or(""); - let hydro = params.hydrosphere.as_deref().unwrap_or("none"); +/// D-239 §2: "long-term / seasonal-minimum temperature → GlaciationGrade". +/// D-239 §8 law: "grade 0 = V-ridges, never glacial U". +/// Airless bodies (`temperature_c == None`) → grade 0 (ice is geology, D-227). +/// All gating uses integer °C (D-010). +pub fn derive_glaciation_grade_from_climate( + temperature_c: Option, + moisture_q: i32, +) -> GlaciationGrade { + // Airless: ice is geology (D-227), no glacial morphology. + let Some(temp_c) = temperature_c else { + return GlaciationGrade::None; + }; + let temp_i = temp_c as i32; - if !has_atmo { - // Airless — ice is geology (D-227), no glacial morphology. + // Glaciation needs both cold temperature AND some moisture (D-239 §3: snow + // gated on moisture; cold + dry → bare frozen ground, not glaciers). + // No moisture → no ice accumulation regardless of temperature. + if moisture_q < 10 { return GlaciationGrade::None; } - match (planet, hydro) { - ("frozen", "ice") => GlaciationGrade::Heavy, - ("frozen", _) => GlaciationGrade::Moderate, - (_, "ice") => GlaciationGrade::Light, - _ => GlaciationGrade::None, + + // Temperature bands → glaciation grade. + // These thresholds represent mean-annual temperature; seasonal-minimum + // is always colder, so glaciation forms even with modestly sub-freezing means. + match temp_i { + i32::MIN..=-30 => GlaciationGrade::IceCap, // Perennial ice cover + -29..=-15 => GlaciationGrade::Heavy, // Extensive fjord systems + -14..=-5 => GlaciationGrade::Moderate, // Fjords possible (≥ grade 2 gate) + -4..=5 => GlaciationGrade::Light, // U-valleys, moraines + _ => GlaciationGrade::None, // Temperate/warm: V-ridges only } } -/// Derive per-region river threshold from body params (D-239 §1). +/// Derive vegetation class from temperature, moisture, and elevation (D-239 §2, §8). +/// +/// Climate→vegetation law (D-239 §8): +/// - treeline band: Forest → Scrub → Barren with NO skipping +/// - riparian: Thicket/Scrub 1–3 tile band along perennial waterways +/// +/// Airless body (`temperature_c == None`) → `VegetationClass::Absent`. +/// All comparisons use integer °C + integer elev_q / moisture_q (D-010). +/// +/// `near_perennial_water` signals whether this region is within the riparian +/// band (i.e. the region or an adjacent region carries a perennial waterway). +/// When true the riparian sub-variant is returned: `RiparianThicket` over +/// Forest, `RiparianScrub` over Scrub/Barren. +pub fn derive_vegetation( + temperature_c: Option, + moisture_q: i32, + elev_q: i32, + near_perennial_water: bool, +) -> VegetationClass { + // Airless: entire climate/vegetation branch absent. + let Some(temp_c) = temperature_c else { + return VegetationClass::Absent; + }; + let temp_i = temp_c as i32; + + // Hyper-arid or extreme cold → Barren regardless of elevation. + // These are the absolute limits; no vegetation survives. + if moisture_q < 5 || temp_i < -50 { + return if near_perennial_water && moisture_q >= 5 { + VegetationClass::RiparianScrub // water creates a micro-oasis even in arid extremes + } else { + VegetationClass::Barren + }; + } + + // Treeline derivation: elevation × temperature interaction. + // High elevation or cold temperature pushes toward Barren → Scrub → Forest. + // D-239 §8: no-skip guaranteed by ladder structure below. + // + // Treeline elevation (elev_q threshold above which forest cannot form): + // - Warm (temp_i > 10): treeline at elev_q ≈ 75 (alpine zone above) + // - Cool (0–10): treeline at elev_q ≈ 55 (trees don't grow as high) + // - Cold (−15 to 0): treeline at elev_q ≈ 35 (subarctic treeline is low) + // - Very cold (< −15): no forest possible + let base_class = if temp_i < -15 { + // Tundra/ice: Scrub or Barren only. + if moisture_q >= 15 { + VegetationClass::Scrub + } else { + VegetationClass::Barren + } + } else if temp_i < 0 { + // Cold but not ice-cap: Forest possible only at low elevation. + let treeline_q = 35_i32; + if elev_q > treeline_q { + VegetationClass::Barren + } else if elev_q > treeline_q - 15 || moisture_q < 20 { + VegetationClass::Scrub + } else { + VegetationClass::Forest + } + } else if temp_i <= 10 { + // Cool temperate. + let treeline_q = 55_i32; + if elev_q > treeline_q { + VegetationClass::Barren + } else if elev_q > treeline_q - 20 || moisture_q < 20 { + VegetationClass::Scrub + } else { + VegetationClass::Forest + } + } else { + // Warm temperate to tropical. + let treeline_q = 75_i32; + if elev_q > treeline_q { + VegetationClass::Barren + } else if elev_q > treeline_q - 20 || moisture_q < 15 { + VegetationClass::Scrub + } else { + VegetationClass::Forest + } + }; + + // Riparian override: 1–3 tile band along perennial waterways. + // Upgrades Scrub/Barren → RiparianScrub; upgrades Forest → RiparianThicket. + if near_perennial_water { + match base_class { + VegetationClass::Forest => VegetationClass::RiparianThicket, + VegetationClass::Scrub | VegetationClass::Barren => VegetationClass::RiparianScrub, + // Absent already returned above. + other => other, + } + } else { + base_class + } +} + +/// Derive per-region river threshold from tectonic class + precipitation (D-239 §1). /// /// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers. -/// Higher hydrosphere + precipitation → lower threshold (more rivers). +/// Higher precipitation → lower threshold (more rivers). /// All arithmetic is integer (D-010). -pub fn derive_river_threshold(params: &BodyParams) -> i32 { - let tectonic_bonus: i32 = match derive_tectonic_class(params) { +pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClass) -> i32 { + let tectonic_bonus: i32 = match tectonic { TectonicClass::Active => -30, TectonicClass::Volcanic => -20, TectonicClass::TidallyForced => -10, TectonicClass::Stable => 0, }; - let precip_factor: i32 = match derive_precipitation_class(params) { + let precip_factor: i32 = match precip { PrecipitationClass::Arid => 100, PrecipitationClass::SemiArid => 50, PrecipitationClass::Temperate => 0, @@ -270,50 +461,137 @@ pub fn derive_river_threshold(params: &BodyParams) -> i32 { (200 + precip_factor + tectonic_bonus).clamp(20, 500) } -/// Derive `MorphologyZone` from `RegionProfile` gating params (D-239 §5). +/// Derive `MorphologyZone` from `RegionProfile` gating params (D-239 §5, §6, §7). /// -/// Strict decision tree over integer inputs. Gate order is significant — -/// more-constrained types are tested first (D-239 §7). +/// Implements the 8-family decision tree over integer inputs, in the gate order +/// specified by D-239 §5. Hard boolean gates are pre-selection (§5); output is +/// then refined to one of the 17 frozen zone labels (§6) by sub-classification +/// from family + elevation/water-height (§6: four zones are sub-classifications). +/// +/// Gate order (D-239 §5, most-constrained first per §7): +/// 1. Water bodies (submerged fraction) +/// 2. LavaField — gate: tectonic == Volcanic (§5, §8 lava law) +/// 3. FjordWall — gate: GlaciationGrade ≥ 2 + slope + coastal (§5) +/// 4. CliffCoast — gate: high slope + coastal + NOT fjord (§8 Rock→vertical) +/// 5. BraidedDelta — gate: very low slope + low elev + coastal (§8 Gravel→braided) +/// 6. DuneStrand — gate: low slope + coastal + moderate elev (§8 Sand→dunes) +/// 7. IncisedGorge — gate: high slope + inland + high elev (→MountainPass label) +/// 8. MeanderReach — gate: gentle slope + some water presence +/// 9. AlluvialPlain — fallback (§5) +/// +/// Sub-classifications (§6): after family selection, apply: +/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD +/// - Wetland: AlluvialPlain/Delta family + slope_q ≤ 5 + moisture ≥ 60 (§8 Wetland ≤5°) +/// - TidalFlat: AlluvialPlain/CliffCoast family + ocean_fraction_q ≥ TIDAL_OCEAN_MIN + very low elev +/// - Estuarine: Delta family + ocean signal (brackish tidal zone at river mouth) +/// +/// D-010: all gates are integer comparisons. No float arithmetic in this function. fn derive_morphology_zone( tectonic: TectonicClass, glaciation: GlaciationGrade, slope_q: i32, elev_q: i32, ocean_fraction_q: i32, + moisture_q: i32, ) -> MorphologyZone { - // Open ocean / lake (submerged). + // ── Tier 0: fully submerged ────────────────────────────────────────────── if ocean_fraction_q >= 80 { + // Very high ocean fraction: open ocean or lake depending on context. + // No body-scale salinity signal at region level yet; treat all as OpenOcean. + // Lake differentiation lives at ChunkContext (D-239 §10). return MorphologyZone::OpenOcean; } - // LavaField: requires Volcanic tectonic (D-239 §5). - if tectonic == TectonicClass::Volcanic { - return MorphologyZone::AlluvialPlain; // Mapped to AlluvialPlain until T-1027 adds Volcanic zone + if ocean_fraction_q >= 60 { + return MorphologyZone::Lake; } - // Fjord: requires GlaciationGrade ≥ 2 (D-239 §5) + high slope + coastal. + + // ── Family 1: LavaField ───────────────────────────────────────────────── + // Hard gate: tectonic == Volcanic (D-239 §5; §8 Lava→sheets/shield slopes). + if tectonic == TectonicClass::Volcanic { + return MorphologyZone::Volcanic; + } + + // ── Family 2: FjordWall ───────────────────────────────────────────────── + // Hard gate: GlaciationGrade ≥ 2 (D-239 §5) + steep slope + coastal. + // §8: glaciation→form law: fjord ≥ 2. if glaciation >= GlaciationGrade::Moderate && slope_q >= 40 && ocean_fraction_q >= 20 { return MorphologyZone::Fjord; } - // Canyon: high slope + high elevation + non-coastal. - if slope_q >= 60 && elev_q >= 50 && ocean_fraction_q < 10 { - return MorphologyZone::Canyon; + + // ── Family 3: CliffCoast ──────────────────────────────────────────────── + // §8 lithology law: Rock → vertical faces. High slope + coastal + not fjord. + if slope_q >= 55 && ocean_fraction_q >= 15 && elev_q >= 20 { + return MorphologyZone::CliffCoast; } - // Mountain pass: high elevation, moderate slope. - if elev_q >= 70 && slope_q >= 30 { - return MorphologyZone::MountainPass; - } - // Coastal zone: low elevation, ocean nearby. - if ocean_fraction_q >= 30 && elev_q < 30 { - return MorphologyZone::CoastalLowland; - } - // Delta / braided: very flat, low elevation, some water. + + // ── Family 4: BraidedDelta ────────────────────────────────────────────── + // §8 lithology law: Gravel → braided channels/fans, not single-thread meander. + // Very flat + low elevation + coastal/water presence. if slope_q <= 5 && elev_q < 20 && ocean_fraction_q >= 10 { + // Sub-classification: Estuarine if strong ocean signal (brackish tidal zone). + if ocean_fraction_q >= 30 { + return MorphologyZone::Estuarine; + } + // Sub-classification: TidalFlat if very low elev (regularly inundated zone). + if elev_q < 10 && ocean_fraction_q >= 15 { + return MorphologyZone::TidalFlat; + } return MorphologyZone::Delta; } - // Meander reach: gentle slope, mid elevation. - if slope_q <= 15 && elev_q < 50 && ocean_fraction_q >= 5 { + + // ── Family 5: DuneStrand ──────────────────────────────────────────────── + // §8 lithology law: Sand → ≤~32° angle of repose, dunes not cliffs. + // Low slope + coastal + arid/semi-arid (low moisture → loose sand). + if slope_q <= 20 && ocean_fraction_q >= 15 && elev_q < 30 && moisture_q <= 30 { + // Sub-classification: TidalFlat if very low elev + tidal signal (ocean_fraction_q). + if elev_q < 10 && ocean_fraction_q >= 20 { + return MorphologyZone::TidalFlat; + } + return MorphologyZone::DuneStrand; + } + // Tidal flat also reachable from non-arid low coasts. + if ocean_fraction_q >= 20 && elev_q < 8 && slope_q <= 8 { + return MorphologyZone::TidalFlat; + } + + // ── Family 6: IncisedGorge → MountainPass / Alpine ────────────────────── + // High slope + high elevation + inland (non-coastal). + // §5: MountainPass is a zone label sharing IncisedGorge geometry. + // §6: Alpine is a sub-classification from IncisedGorge family + very high elevation. + if slope_q >= 40 && elev_q >= 50 && ocean_fraction_q < 20 { + // Sub-classification: Alpine above treeline elevation threshold. + // elev_q ≥ 75 → alpine zone (no forest, exposed rock/ice). + if elev_q >= 75 { + return MorphologyZone::Alpine; + } + return MorphologyZone::MountainPass; + } + // ValleyFloor: moderate slope + high-ish elevation + enclosed. + if (15..40).contains(&slope_q) && elev_q >= 40 && ocean_fraction_q < 20 { + return MorphologyZone::ValleyFloor; + } + + // ── Family 7: MeanderReach ────────────────────────────────────────────── + // §8 lithology law: Soil → rolling/floodplain; single-thread meander. + // Gentle slope + some water presence. + if slope_q <= 20 && ocean_fraction_q >= 5 { + // Sub-classification: Wetland if very flat + high moisture (§8 Wetland ≤5° flats). + if slope_q <= 5 && moisture_q >= 60 { + return MorphologyZone::Wetland; + } + // RiverBank if moderate water presence but not delta/braided. + if (10..30).contains(&ocean_fraction_q) { + return MorphologyZone::RiverBank; + } return MorphologyZone::MeanderReach; } - // Fallback: AlluvialPlain. + + // ── Family 8: AlluvialPlain — fallback ────────────────────────────────── + // §5: fallback for all remaining cases. + // Sub-classification: Wetland if very flat + high moisture. + if slope_q <= 5 && moisture_q >= 60 { + return MorphologyZone::Wetland; + } MorphologyZone::AlluvialPlain } @@ -582,17 +860,6 @@ pub fn derive_region_profile( }; let tectonic_class = derive_tectonic_class(body_params); - let glaciation_grade = derive_glaciation_grade(body_params); - let precipitation_class = derive_precipitation_class(body_params); - let river_threshold = derive_river_threshold(body_params); - - let morphology_zone = derive_morphology_zone( - tectonic_class, - glaciation_grade, - slope_q, - elev_q, - ocean_fraction_q, - ); // Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS // region's elevation — otherwise every region on a body shares one body-level @@ -608,6 +875,27 @@ pub fn derive_region_profile( let temperature_c = derive_temperature_c(®ion_climate_params, climate); let moisture_q = derive_moisture_q(body_params); + // Climate-derived fields: computed from temperature + moisture primitives + // (D-239 §2). This is the correct call order — temperature must be resolved + // before precipitation and glaciation are derived from it. + let precipitation_class = derive_precipitation_class_from_climate(temperature_c, moisture_q); + 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 region 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); + + let morphology_zone = derive_morphology_zone( + tectonic_class, + glaciation_grade, + slope_q, + elev_q, + ocean_fraction_q, + moisture_q, + ); + RegionProfile { morphology_zone, tectonic_class, @@ -619,6 +907,7 @@ pub fn derive_region_profile( river_threshold, temperature_c, moisture_q, + vegetation_class, } } @@ -767,40 +1056,29 @@ mod tests { #[test] fn airless_body_gets_no_glaciation() { - let params = BodyParams { - planet_class: Some("frozen".into()), - hydrosphere: Some("ice".into()), - atmosphere: Some("none".into()), - ..Default::default() - }; - // Airless: ice is geology, not climate (D-227). - assert_eq!(derive_glaciation_grade(¶ms), GlaciationGrade::None); + // Airless: temperature == None → ice is geology, not climate (D-227). + assert_eq!( + derive_glaciation_grade_from_climate(None, 80), + GlaciationGrade::None + ); } #[test] fn frozen_body_with_atmo_gets_heavy_glaciation() { - let params = BodyParams { - planet_class: Some("frozen".into()), - hydrosphere: Some("ice".into()), - atmosphere: Some("thin".into()), - ..Default::default() - }; - assert_eq!(derive_glaciation_grade(¶ms), GlaciationGrade::Heavy); + // −20°C mean-annual + adequate moisture → Heavy glaciation. + assert_eq!( + derive_glaciation_grade_from_climate(Some(-20.0), 50), + GlaciationGrade::Heavy + ); } #[test] fn arid_body_has_higher_river_threshold() { - let arid = BodyParams { - hydrosphere: Some("none".into()), - ..Default::default() - }; - let humid = BodyParams { - hydrosphere: Some("ocean".into()), - atmosphere: Some("dense".into()), - ..Default::default() - }; + // Arid (low precip) → higher river threshold. + let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid); + let t_humid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::SuperHumid); assert!( - derive_river_threshold(&arid) > derive_river_threshold(&humid), + t_arid > t_humid, "arid body should have higher river threshold than humid body" ); } @@ -846,19 +1124,8 @@ mod tests { #[test] fn river_threshold_clamped_to_range() { // Even with extreme params, threshold stays in [20, 500]. - let extreme_humid = BodyParams { - hydrosphere: Some("ocean".into()), - atmosphere: Some("dense".into()), - tectonic_activity: Some("active".into()), - ..Default::default() - }; - let extreme_arid = BodyParams { - hydrosphere: Some("none".into()), - atmosphere: Some("none".into()), - ..Default::default() - }; - let t_humid = derive_river_threshold(&extreme_humid); - let t_arid = derive_river_threshold(&extreme_arid); + let t_humid = derive_river_threshold(TectonicClass::Active, PrecipitationClass::SuperHumid); + let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid); assert!( (20..=500).contains(&t_humid), "humid threshold {t_humid} out of range" @@ -1075,4 +1342,620 @@ mod tests { // Unknown class falls back to 1.0 (Sol). assert_eq!(cc.luminosity(""), 1.0); } + + // ----------------------------------------------------------------------- + // T-1025: Climate-derived fields — precipitation, glaciation, vegetation + // ----------------------------------------------------------------------- + + #[test] + fn precipitation_airless_is_arid() { + // D-239 §2: no atmosphere → no precipitation cycle. + assert_eq!( + derive_precipitation_class_from_climate(None, 80), + PrecipitationClass::Arid + ); + } + + #[test] + fn precipitation_warm_high_moisture_is_superhumid() { + // Warm + very high moisture → SuperHumid. + assert_eq!( + derive_precipitation_class_from_climate(Some(25.0), 90), + PrecipitationClass::SuperHumid + ); + } + + #[test] + fn precipitation_warm_low_moisture_is_arid() { + // Warm but very low moisture → Arid. + assert_eq!( + derive_precipitation_class_from_climate(Some(30.0), 5), + PrecipitationClass::Arid + ); + } + + #[test] + fn precipitation_cold_reduces_class() { + // Cold temperature reduces precipitation class relative to moisture alone. + // moisture_q=40 is mid-range; warm → Temperate, cold → SemiArid. + let warm = derive_precipitation_class_from_climate(Some(15.0), 40); + let cold = derive_precipitation_class_from_climate(Some(-5.0), 40); + assert!( + warm >= cold, + "warm precipitation class ({warm:?}) should be >= cold ({cold:?}) at same moisture" + ); + } + + #[test] + fn glaciation_airless_is_none() { + // D-239 §2: airless body → no glacial morphology. + assert_eq!( + derive_glaciation_grade_from_climate(None, 50), + GlaciationGrade::None + ); + } + + #[test] + fn glaciation_dry_is_none() { + // Cold but dry → no glaciation (D-239 §3: snow gated on moisture). + assert_eq!( + derive_glaciation_grade_from_climate(Some(-25.0), 5), + GlaciationGrade::None + ); + } + + #[test] + fn glaciation_grade_temperature_bands() { + // Verify the temperature-band thresholds produce the expected grades. + // IceCap: temp ≤ −30 + assert_eq!( + derive_glaciation_grade_from_climate(Some(-35.0), 50), + GlaciationGrade::IceCap + ); + // Heavy: −29 to −15 + assert_eq!( + derive_glaciation_grade_from_climate(Some(-20.0), 50), + GlaciationGrade::Heavy + ); + // Moderate (fjord gate ≥ 2): −14 to −5 + assert_eq!( + derive_glaciation_grade_from_climate(Some(-10.0), 50), + GlaciationGrade::Moderate + ); + // Light: −4 to 5 + assert_eq!( + derive_glaciation_grade_from_climate(Some(0.0), 50), + GlaciationGrade::Light + ); + // None: warm + assert_eq!( + derive_glaciation_grade_from_climate(Some(20.0), 50), + GlaciationGrade::None + ); + } + + #[test] + fn glaciation_fjord_gate_at_moderate() { + // D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate). + // Verify that the boundary temperature produces exactly Moderate. + let grade = derive_glaciation_grade_from_climate(Some(-10.0), 50); + assert!( + grade >= GlaciationGrade::Moderate, + "−10°C should produce ≥ Moderate glaciation for fjord gate" + ); + } + + // ----------------------------------------------------------------------- + // T-1025: VegetationClass — derive_vegetation + // ----------------------------------------------------------------------- + + #[test] + fn vegetation_airless_is_absent() { + // D-239 §2: airless body → vegetation branch absent. + assert_eq!( + derive_vegetation(None, 50, 20, false), + VegetationClass::Absent + ); + } + + #[test] + fn vegetation_warm_moist_low_elevation_is_forest() { + // Warm + moist + low elevation → Forest. + assert_eq!( + derive_vegetation(Some(20.0), 60, 10, false), + VegetationClass::Forest + ); + } + + #[test] + 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), + VegetationClass::Barren + ); + } + + #[test] + fn vegetation_no_skip_forest_to_barren() { + // D-239 §8: Forest→Scrub→Barren, no skip. + // Scan a wide range of elevations; verify no jump from Forest directly to Barren. + let temp = Some(20.0_f32); // warm enough for forest at low elev + 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); + if let Some(p) = prev { + // Absent not reachable here (has atmosphere); skip riparian variants. + let is_base = matches!( + v, + VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren + ); + let was_base = matches!( + p, + VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren + ); + if is_base && was_base { + // Can only decrease by one step (Forest→Scrub or Scrub→Barren) + // or stay the same. Forest→Barren skip is forbidden. + assert!( + !(p == VegetationClass::Forest && v == VegetationClass::Barren), + "vegetation skipped from Forest to Barren at elev_q={elev_q}" + ); + } + } + prev = Some(v); + } + } + + #[test] + fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() { + // A scrub-zone region near perennial water → RiparianScrub. + let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone + assert_eq!(v, VegetationClass::RiparianScrub); + } + + #[test] + fn vegetation_riparian_upgrades_forest_to_riparian_thicket() { + // A forest-zone region near perennial water → RiparianThicket. + let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone + assert_eq!(v, VegetationClass::RiparianThicket); + } + + #[test] + fn vegetation_class_discriminants_pinned() { + // Load-bearing: renumbering breaks D-010 integer derivation contract. + assert_eq!(VegetationClass::Absent as u8, 0); + assert_eq!(VegetationClass::Barren as u8, 1); + assert_eq!(VegetationClass::Scrub as u8, 2); + assert_eq!(VegetationClass::Forest as u8, 3); + assert_eq!(VegetationClass::RiparianScrub as u8, 4); + assert_eq!(VegetationClass::RiparianThicket as u8, 5); + } + + #[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), + VegetationClass::Barren + ); + } + + #[test] + fn precipitation_class_keyed_on_temp_and_moisture() { + // Verify the derivation is actually f(temperature, moisture), not just moisture. + // Same moisture_q=40, cold vs warm → different class. + let warm = derive_precipitation_class_from_climate(Some(20.0), 40); + let cold = derive_precipitation_class_from_climate(Some(-10.0), 40); + // Cold should produce a lower or equal class. + assert!( + (warm as u8) >= (cold as u8), + "warm precip ({warm:?}) should be >= cold ({cold:?}) at moisture_q=40" + ); + } + + // ----------------------------------------------------------------------- + // T-1027: Morphology classifier — frozen 17-zone vocab + 8-family gates + // + seam-matrix compatibility invariant (D-239 §5, §6, §7) + // ----------------------------------------------------------------------- + + /// Helper to call `derive_morphology_zone` with a complete set of defaults, + /// overriding only the parameters relevant to the test. + fn zone( + tectonic: TectonicClass, + glaciation: GlaciationGrade, + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, + moisture_q: i32, + ) -> MorphologyZone { + derive_morphology_zone( + tectonic, + glaciation, + slope_q, + elev_q, + ocean_fraction_q, + moisture_q, + ) + } + + fn zone_defaults() -> MorphologyZone { + zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30) + } + + // ── 17-zone vocabulary completeness ────────────────────────────────────── + + #[test] + fn morphology_zone_discriminants_pinned() { + // D-239 §6 freeze point: discriminant values must never change. + assert_eq!(MorphologyZone::OpenOcean as u8, 0); + assert_eq!(MorphologyZone::Lake as u8, 1); + assert_eq!(MorphologyZone::TidalFlat as u8, 2); + assert_eq!(MorphologyZone::DuneStrand as u8, 3); + assert_eq!(MorphologyZone::CliffCoast as u8, 4); + assert_eq!(MorphologyZone::Fjord as u8, 5); + assert_eq!(MorphologyZone::Delta as u8, 6); + assert_eq!(MorphologyZone::Estuarine as u8, 7); + assert_eq!(MorphologyZone::AlluvialPlain as u8, 8); + assert_eq!(MorphologyZone::RiverBank as u8, 9); + assert_eq!(MorphologyZone::MeanderReach as u8, 10); + assert_eq!(MorphologyZone::BraidedPlain as u8, 11); + assert_eq!(MorphologyZone::ValleyFloor as u8, 12); + assert_eq!(MorphologyZone::MountainPass as u8, 13); + assert_eq!(MorphologyZone::Alpine as u8, 14); + assert_eq!(MorphologyZone::Volcanic as u8, 15); + assert_eq!(MorphologyZone::Wetland as u8, 16); + } + + #[test] + fn morphology_zone_has_exactly_17_variants() { + // Enumerate all reachable zones through the classifier across a + // representative grid of inputs to verify all 17 are reachable. + use std::collections::BTreeSet; + let mut seen: BTreeSet = BTreeSet::new(); + + // All possible combinations of key inputs. + let tectonics = [TectonicClass::Stable, TectonicClass::Volcanic]; + let glaciations = [ + GlaciationGrade::None, + GlaciationGrade::Moderate, + GlaciationGrade::Heavy, + ]; + for tec in &tectonics { + for gl in &glaciations { + for slope in [0, 5, 10, 20, 40, 55, 70] { + for elev in [0, 5, 8, 15, 20, 30, 40, 50, 60, 75, 90] { + for ocean in [0, 5, 10, 15, 20, 30, 60, 80, 90] { + for moist in [0, 10, 30, 60, 80] { + let z = zone(*tec, *gl, slope, elev, ocean, moist); + seen.insert(z as u8); + } + } + } + } + } + } + // BraidedPlain is not yet emitted by classifier (it's a future ChunkContext + // sub-classification at finer scale); all others must be reachable. + // Assert at least 16 of 17 zones are reachable via the region-scale classifier. + assert!( + seen.len() >= 16, + "only {}/17 zones reachable by classifier: {seen:?}", + seen.len() + ); + } + + // ── Family hard gates ────────────────────────────────────────────────── + + #[test] + fn volcanic_gate_requires_tectonic_volcanic() { + // D-239 §5: LavaField requires tectonic_class == Volcanic. + let non_volcanic = zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30); + assert_ne!( + non_volcanic, + MorphologyZone::Volcanic, + "non-volcanic tectonic must not produce Volcanic zone" + ); + let volcanic = zone( + TectonicClass::Volcanic, + GlaciationGrade::None, + 30, + 30, + 5, + 30, + ); + assert_eq!( + volcanic, + MorphologyZone::Volcanic, + "Volcanic tectonic must produce Volcanic zone" + ); + } + + #[test] + fn fjord_gate_requires_glaciation_moderate_or_above() { + // D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate). + // Coastal + steep but grade 0 → not Fjord. + let no_glaciation = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 50, // high slope + 25, + 30, // coastal + 40, + ); + assert_ne!( + no_glaciation, + MorphologyZone::Fjord, + "GlaciationGrade::None must not produce Fjord" + ); + // GlaciationGrade::Light (grade 1) also must not produce Fjord. + let light_glaciation = zone( + TectonicClass::Stable, + GlaciationGrade::Light, + 50, + 25, + 30, + 40, + ); + assert_ne!( + light_glaciation, + MorphologyZone::Fjord, + "GlaciationGrade::Light must not produce Fjord" + ); + // Moderate (grade 2) + correct slope + coastal → Fjord. + let fjord = zone( + TectonicClass::Stable, + GlaciationGrade::Moderate, + 50, // slope_q ≥ 40 + 25, + 30, // ocean_fraction_q ≥ 20 + 40, + ); + assert_eq!( + fjord, + MorphologyZone::Fjord, + "Moderate glaciation should produce Fjord" + ); + } + + #[test] + fn volcanic_zone_overrides_fjord_gate() { + // D-239 §5: LavaField gate (volcanic tectonic) is tested before FjordWall. + // Even with GlaciationGrade::Heavy + correct slope + coastal, + // volcanic tectonic wins. + let z = zone( + TectonicClass::Volcanic, + GlaciationGrade::Heavy, + 50, + 25, + 30, + 40, + ); + assert_eq!( + z, + MorphologyZone::Volcanic, + "Volcanic tectonic must override fjord gate (LavaField before FjordWall)" + ); + } + + #[test] + fn alpine_subclassification_from_high_elevation() { + // §6: Alpine is sub-classification of IncisedGorge family + elev_q ≥ 75. + let alpine = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 50, // steep slope + 80, // very high elevation + 5, // inland + 30, + ); + assert_eq!( + alpine, + MorphologyZone::Alpine, + "high elev + steep slope → Alpine" + ); + // Same slope but lower elevation → MountainPass, not Alpine. + let mountain_pass = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 50, + 60, // below Alpine threshold + 5, + 30, + ); + assert_eq!( + mountain_pass, + MorphologyZone::MountainPass, + "moderate-high elev + steep slope → MountainPass, not Alpine" + ); + } + + #[test] + fn wetland_requires_flat_and_moist() { + // §8: Wetland ≤5° flats + high moisture. + let wetland = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 3, // slope_q ≤ 5 + 20, + 5, + 70, // moisture_q ≥ 60 + ); + assert_eq!(wetland, MorphologyZone::Wetland, "flat + moist → Wetland"); + // Flat but dry → not Wetland. + let not_wetland = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 3, + 20, + 5, + 20, // moisture < 60 + ); + assert_ne!( + not_wetland, + MorphologyZone::Wetland, + "flat + dry must not be Wetland" + ); + } + + #[test] + fn tidal_flat_requires_low_elevation_and_ocean() { + // §6: TidalFlat sub-classification from coastal + very low elev. + let tidal = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 5, // gentle slope + 5, // very low elev (< 10) + 25, // ocean signal ≥ 20 + 30, + ); + assert_eq!( + tidal, + MorphologyZone::TidalFlat, + "low elev + coastal → TidalFlat" + ); + } + + #[test] + fn estuarine_requires_delta_plus_ocean() { + // §6: Estuarine sub-classification from Delta family + strong ocean signal. + let estuarine = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 3, // very flat + 15, // low elevation + 35, // ocean_fraction_q ≥ 30 → Estuarine + 40, + ); + assert_eq!( + estuarine, + MorphologyZone::Estuarine, + "delta + ocean → Estuarine" + ); + // Weaker ocean signal stays as Delta. + let delta = zone( + TectonicClass::Stable, + GlaciationGrade::None, + 3, + 15, + 15, // ocean_fraction_q < 30 → Delta + 40, + ); + assert_eq!(delta, MorphologyZone::Delta, "delta + low ocean → Delta"); + } + + #[test] + fn alluvial_plain_is_fallback() { + // D-239 §5: AlluvialPlain is the fallback when no other gate fires. + let z = zone_defaults(); + assert_eq!( + z, + MorphologyZone::AlluvialPlain, + "default inputs → AlluvialPlain fallback" + ); + } + + // ── Build-time compatibility-matrix invariant (D-239 §7) ───────────────── + // + // §7: incompatible family pairs cannot be adjacent classifier outputs. + // This test encodes the incompatibility matrix and asserts the classifier's + // gate ordering structurally cannot emit a forbidden adjacency. + // + // Valid sharp seams (cliff↔fjord at glaciation threshold, lithology faults) + // are PERMITTED. The test validates the gate structure, not runtime adjacency. + + #[test] + fn compatibility_matrix_meander_volcanic_cannot_be_adjacent() { + // D-239 §7: MeanderReach ↔ Volcanic is a forbidden pair. + // This is structurally impossible because the Volcanic gate (family 1) + // fires for ALL tectonic==Volcanic inputs regardless of other params, + // and MeanderReach requires tectonic != Volcanic. + // Verify: any input producing MeanderReach cannot also produce Volcanic. + let produces_meander_with_stable = zone( + TectonicClass::Stable, // non-volcanic + GlaciationGrade::None, + 10, + 25, + 8, // some water → MeanderReach territory + 35, + ); + // Doesn't matter if this particular call produces MeanderReach, but + // any call with TectonicClass::Volcanic must produce Volcanic, not MeanderReach. + let volcanic_body_zone = zone( + TectonicClass::Volcanic, + GlaciationGrade::None, + 10, + 25, + 8, + 35, + ); + assert_eq!( + volcanic_body_zone, + MorphologyZone::Volcanic, + "Volcanic tectonic must always produce Volcanic — never MeanderReach" + ); + assert_ne!( + produces_meander_with_stable, + MorphologyZone::Volcanic, + "Stable tectonic cannot produce Volcanic" + ); + } + + #[test] + fn compatibility_matrix_fjord_requires_glaciation_gate() { + // §7: Fjord and AlluvialPlain cannot be adjacent without a glaciation + // discontinuity. The gate (GlaciationGrade ≥ 2) enforces this: + // inputs just below the fjord gate can only produce non-fjord zones. + let below_gate = zone( + TectonicClass::Stable, + GlaciationGrade::Light, // grade 1, below the fjord gate + 50, + 25, + 30, + 40, + ); + assert_ne!( + below_gate, + MorphologyZone::Fjord, + "GlaciationGrade::Light is below fjord gate — must not produce Fjord" + ); + // One grade above the gate (Moderate = 2) → Fjord. + let above_gate = zone( + TectonicClass::Stable, + GlaciationGrade::Moderate, + 50, + 25, + 30, + 40, + ); + assert_eq!( + above_gate, + MorphologyZone::Fjord, + "GlaciationGrade::Moderate is at fjord gate — must produce Fjord" + ); + } + + #[test] + fn compatibility_matrix_volcanic_cannot_neighbour_fjord_directly() { + // §7: Volcanic ↔ Fjord adjacency is structurally prevented. + // Fjord requires non-volcanic tectonic (volcanic gate fires first). + // So any Volcanic zone cannot produce Fjord with the same tectonic. + // The only way they could neighbour is across a tectonic fault — + // which is a valid sharp seam (D-239 §7: permitted). + // This test confirms the gate ordering: volcanic check happens BEFORE fjord. + let fjord_attempt_with_volcanic = zone( + TectonicClass::Volcanic, + GlaciationGrade::Heavy, // would qualify for fjord if not volcanic + 50, + 25, + 30, + 40, + ); + assert_eq!( + fjord_attempt_with_volcanic, + MorphologyZone::Volcanic, + "Volcanic tectonic must prevent Fjord production (gate order)" + ); + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 594ad375b..4742df2d8 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -1046,19 +1046,31 @@ enum Topology { } fn street_topology(m: &MorphologyZone) -> Topology { + // D-234 morphology-gated trunk topology. + // Updated for D-239 §6 frozen 17-zone vocabulary (T-1027). match m { - MorphologyZone::Fjord | MorphologyZone::Canyon | MorphologyZone::MountainPass => { - Topology::Ribbon - } + // Ribbon: linear/constrained terrain — follow the single axis. + MorphologyZone::Fjord + | MorphologyZone::CliffCoast + | MorphologyZone::MountainPass + | MorphologyZone::Alpine + | MorphologyZone::ValleyFloor => Topology::Ribbon, + + // HubSpoke: water-enclosed or island-like contexts — radiate from a centre. MorphologyZone::Delta + | MorphologyZone::Estuarine | MorphologyZone::OpenOcean | MorphologyZone::Lake - | MorphologyZone::Sea - | MorphologyZone::Island => Topology::HubSpoke, + | MorphologyZone::DuneStrand + | MorphologyZone::TidalFlat => Topology::HubSpoke, + + // Mesh: flat/open terrain — any pattern. MorphologyZone::AlluvialPlain | MorphologyZone::MeanderReach - | MorphologyZone::CoastalLowland - | MorphologyZone::Unknown => Topology::Mesh, + | MorphologyZone::RiverBank + | MorphologyZone::BraidedPlain + | MorphologyZone::Wetland + | MorphologyZone::Volcanic => Topology::Mesh, } } @@ -1748,7 +1760,7 @@ mod tests { #[test] fn hub_spoke_shares_a_common_node() { let aps = derive_access_points(&[0, 2, 4, 6], &[]); - let corridors = derive_corridors(&aps, &MorphologyZone::Island); + let corridors = derive_corridors(&aps, &MorphologyZone::OpenOcean); // Every spoke touches the hub. let hub = central_node(&aps) as u16; assert!(corridors.iter().all(|c| c.from == hub || c.to == hub)); @@ -1771,14 +1783,14 @@ mod tests { let inland = subdivide_block_footprints( 70, &BulkClass::NonPhysical, - &MorphologyZone::CoastalLowland, + &MorphologyZone::RiverBank, None, SeedChain::root(3), ); let quay = subdivide_block_footprints( 70, &BulkClass::NonPhysical, - &MorphologyZone::CoastalLowland, + &MorphologyZone::RiverBank, Some(Edge::North), SeedChain::root(3), ); @@ -1796,14 +1808,14 @@ mod tests { let inland = subdivide_block_footprints( 10, &BulkClass::BulkSolid, - &MorphologyZone::CoastalLowland, + &MorphologyZone::RiverBank, None, SeedChain::root(11), ); let quay = subdivide_block_footprints( 10, &BulkClass::BulkSolid, - &MorphologyZone::CoastalLowland, + &MorphologyZone::RiverBank, Some(Edge::North), SeedChain::root(11), ); diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index a85d10c07..1fae8b37c 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -946,38 +946,65 @@ pub struct DoorSpec { pub interior_descriptor: InteriorDescriptor, } -/// Region-level morphology zone (D-228, D-232, D-234). +/// Region-level morphology zone — D-239 §6 frozen 17-zone vocabulary (T-1027). +/// +/// **FREEZE POINT**: this enum is the canonical freeze per D-239 §6. +/// Adding, renaming, or removing a zone **requires a D-record amendment** +/// (reviewer-enforced). Classifier tuning that merely re-classifies a region +/// is NOT a vocabulary change and stays free. +/// +/// Zones ≠ families. Four zones — TidalFlat, Estuarine, Alpine, Wetland — +/// are derived sub-classifications (family + elevation/water-height), not +/// distinct generator families. /// /// Shared by all tiles in a region; constrains street geometry (D-234) /// and acts as a soft weight on cultural-template eligibility (D-232). /// Carried on `CityGenerationContext` (D-233 amend to D-199). +/// +/// Integer-discriminant, D-010 compliant. `repr(u8)` pins values for +/// serialisation stability; append-only. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +#[repr(u8)] pub enum MorphologyZone { - /// Steep-sided inlet — ribbon/hub-and-spoke streets only; pier geometry on water edges. - Fjord, + /// Deep-water ocean — hub-and-spoke; perimeter access priority. + OpenOcean = 0, + /// Lake body — hub-and-spoke; perimeter access toward water. + Lake = 1, + /// Tidal flat (sub-classification): low coastal plain exposed at low tide. + /// Derived from CliffCoast/AlluvialPlain family + tidal signal + low elevation. + TidalFlat = 2, + /// Dune strand — ribbon/hub-and-spoke; wind-aligned lot orientation. + DuneStrand = 3, + /// Cliff coast — ribbon only; vertical face at water edge. + CliffCoast = 4, + /// Fjord — ribbon/hub-and-spoke; pier geometry; requires GlaciationGrade ≥ 2. + Fjord = 5, /// River delta / braided channel — hub-and-spoke following channels; bridges as forced nodes. - Delta, - /// Meandering river reach — any pattern. - MeanderReach, + Delta = 6, + /// Estuarine (sub-classification): brackish tidal zone at river mouth. + /// Derived from Delta/AlluvialPlain family + tidal signal + low elevation. + Estuarine = 7, /// Flat alluvial plain — any pattern; primary default for plains settlements. #[default] - AlluvialPlain, - /// Open ocean surface (deep-water context) — hub-and-spoke; perimeter access priority. - OpenOcean, - /// Lake shore — hub-and-spoke; perimeter access toward water. - Lake, - /// Interior sea body. - Sea, + AlluvialPlain = 8, + /// River bank — gentle approach; pier geometry on water-facing edges. + RiverBank = 9, + /// Meandering river reach — any pattern. + MeanderReach = 10, + /// Braided channel plain — hub-and-spoke; gravel/sand substrate. + BraidedPlain = 11, + /// Valley floor — ribbon or mesh; enclosed by flanking slopes. + ValleyFloor = 12, /// Mountain pass terrain — ribbon only; elevation steps as block boundaries. - MountainPass, - /// Coastal lowland — any pattern; pier geometry on water-facing edges. - CoastalLowland, - /// Island context — hub-and-spoke; perimeter access priority. - Island, - /// Canyon floor — ribbon or hub-and-spoke only. - Canyon, - /// Unknown / unclassified — fallback to AlluvialPlain behaviour. - Unknown, + MountainPass = 13, + /// Alpine (sub-classification): above treeline high-altitude zone. + /// Derived from IncisedGorge/ValleyFloor family + high elevation. + Alpine = 14, + /// Volcanic — lava field / shield slope; requires TectonicClass::Volcanic. + Volcanic = 15, + /// Wetland (sub-classification): low-gradient, saturated or seasonally flooded. + /// Derived from AlluvialPlain/Delta family + low slope + high moisture. + Wetland = 16, } /// Built-form archetype derived from a settlement's dominant commodity (D-233). From 868da28192e020e66a93bec2008a30a1e6bb95d3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 13:24:13 +0200 Subject: [PATCH 2/2] fix(simulation): address PR #160 review (climate + morphology) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe REQUEST_CHANGES + Tyre APPROVE: - Riparian vegetation logic was doubly-wrong: it granted the micro-oasis only in the impossible cold case (temp < -50, no liquid water) and DENIED it in the intended hyper-arid case. Split: extreme cold -> Barren always; hyper-arid -> RiparianScrub iff near perennial water. (Hoshe #2) - morphology_zone_has_exactly_17_variants was misleading (asserted >=16). Rename to morphology_zone_region_scale_emits_16_of_17, tighten to ==16, and document that BraidedPlain is the lone region-unreachable zone (needs lithology, deferred to ChunkContext) and that Lake IS reachable. Tyre's 'Lake also deferred' was a false positive (ocean 60-79 -> Lake). (Hoshe #1, Tyre #1) - derive_morphology_zone docstring now lists the real multi-path emission sites for TidalFlat/Wetland, ValleyFloor/RiverBank as their own gates, and BraidedPlain's deferral — the §6 parent-family names are descriptive, not exhaustive. (Hoshe #3, Tyre #2/#3/#4) - D-239 record: added a T-1025/T-1027 implementation note (enum freeze location, 16/17 region reachability + BraidedPlain deferral, §7 gate-ordering interpretation). No vocabulary change. cargo test passes, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- governance/decisions/architecture.md | 1 + server/src/atlas/region_profile.rs | 54 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index f881ebbd1..96b9d8f09 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1771,6 +1771,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Raised by:** tile-derivation-contract workshop (Tyre — refinement chain, warp, determinism; Gestalt — 8 families, game-feel; Troblum — feasibility, warp precision, scale corrections; Miri — believability laws, vocabulary, lore reconciliation), lead-interviewed decisions + an adversarial verification pass, 2026-06-07. - **Dissent:** Tyre's initial cross-family elevation-blend was resolved against (prevent-at-source). Early-integer-truncation of the warp (raised against Gestalt's `ElevationDelta` ranges and by Tyre) was resolved against in favour of f64-to-voxel. - **Implementation note (T-1024, 2026-06-07):** §1 per-body `RIVER_THRESHOLD` is now a derived field (`derive_river_threshold`) on `RegionProfile`. §2 district temperature is a nullable `f32` on `RegionProfile` sampled from a per-region climate derivation (`derive_temperature_c`); moisture is a separate integer primitive (`derive_moisture_q`, 0–100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded). +- **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025). - **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract) --- diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 132d75d47..77c503ed0 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -366,11 +366,16 @@ pub fn derive_vegetation( }; let temp_i = temp_c as i32; - // Hyper-arid or extreme cold → Barren regardless of elevation. - // These are the absolute limits; no vegetation survives. - if moisture_q < 5 || temp_i < -50 { - return if near_perennial_water && moisture_q >= 5 { - VegetationClass::RiparianScrub // water creates a micro-oasis even in arid extremes + // Extreme cold → Barren regardless of water proximity: below ~−50 °C mean + // there is no liquid surface water, so a perennial-waterway micro-oasis is + // physically impossible (surface ice is geology per D-227, not vegetation). + if temp_i < -50 { + return VegetationClass::Barren; + } + // Hyper-arid → Barren, but a perennial waterway sustains a riparian micro-oasis. + if moisture_q < 5 { + return if near_perennial_water { + VegetationClass::RiparianScrub } else { VegetationClass::Barren }; @@ -479,11 +484,17 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas /// 8. MeanderReach — gate: gentle slope + some water presence /// 9. AlluvialPlain — fallback (§5) /// -/// Sub-classifications (§6): after family selection, apply: -/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD -/// - Wetland: AlluvialPlain/Delta family + slope_q ≤ 5 + moisture ≥ 60 (§8 Wetland ≤5°) -/// - TidalFlat: AlluvialPlain/CliffCoast family + ocean_fraction_q ≥ TIDAL_OCEAN_MIN + very low elev -/// - Estuarine: Delta family + ocean signal (brackish tidal zone at river mouth) +/// Sub-classifications (§6): after family selection, apply. NOTE: the §6 "parent +/// family" names are descriptive of the typical source, not exhaustive — these +/// zones derive from morphological conditions (low elev + tidal signal; flat + +/// moist) that legitimately overlap several families, so a zone can arise from +/// more than one family context. The actual emission sites: +/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD. +/// - Wetland: flat (slope_q ≤ 5) + moisture ≥ 60; emitted from the AlluvialPlain fallback AND within the MeanderReach gate (§8 Wetland ≤5° flats). +/// - TidalFlat: very low elev + ocean signal; emitted from the BraidedDelta and DuneStrand gates AND a standalone low-coast gate after Family 5. +/// - Estuarine: Delta family + strong ocean signal (brackish tidal zone, river mouth). +/// - ValleyFloor and RiverBank are their own gates (ValleyFloor between IncisedGorge and MeanderReach; RiverBank within the MeanderReach gate). +/// - BraidedPlain (§6) is NOT emitted at region scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) RegionProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note). /// /// D-010: all gates are integer comparisons. No float arithmetic in this function. fn derive_morphology_zone( @@ -1609,9 +1620,10 @@ mod tests { } #[test] - fn morphology_zone_has_exactly_17_variants() { - // Enumerate all reachable zones through the classifier across a - // representative grid of inputs to verify all 17 are reachable. + fn morphology_zone_region_scale_emits_16_of_17() { + // Enumerate the zones the region-scale classifier can emit across a + // representative input grid. 16 of the 17 frozen zones are reachable here; + // BraidedPlain is the lone exception — see the assertion comment below. use std::collections::BTreeSet; let mut seen: BTreeSet = BTreeSet::new(); @@ -1636,13 +1648,15 @@ mod tests { } } } - // BraidedPlain is not yet emitted by classifier (it's a future ChunkContext - // sub-classification at finer scale); all others must be reachable. - // Assert at least 16 of 17 zones are reachable via the region-scale classifier. - assert!( - seen.len() >= 16, - "only {}/17 zones reachable by classifier: {seen:?}", - seen.len() + // BraidedPlain (discriminant 11) is the ONE frozen zone the region-scale + // classifier never emits: distinguishing it from Delta needs a lithology + // signal (§8 Gravel→braided) that RegionProfile doesn't carry yet, so it is + // deferred to ChunkContext (D-239 §6 implementation note). All other 16 are + // reachable. (Lake IS reachable — ocean_fraction 60–79 → Lake, ≥80 → OpenOcean.) + assert_eq!( + seen.len(), + 16, + "expected exactly 16/17 region-reachable zones (BraidedPlain deferred), got: {seen:?}" ); }