The PR #206 eyeball caught what every numeric gate passed: the depth signal was visually flat on both test lakes (GJ1c lmq=0 across the whole basin; GJ338Bd 0-13 of 100). Two compounding causes: a fixed absolute ceiling (one body's p90 cell depth) compressing skewed depth distributions into single digits, and heightmap-pitch depth variation being sub-texel-tiny within most basins. lake_margin_q is now depth / the basin's own maximum settled depth: HydrologyResult grows basin_max_depth_scaled (computed in solve() from existing basin_cells membership, broadcast per basin), threaded through HydrologySample.basin_max_depth, normalized in lake_from_hydrology_at with a degenerate-basin epsilon guard (a genuinely uniform pond shades flat — honest, not forced). Lake EXISTENCE (filled > original) is untouched — only tone changes. Measured at district spacing: GJ1c min=0 p50=33 max=84; GJ338Bd min=8 p50=38 max=70 — full-range shore-to-deep ramps on both. No perceptual curve added: the linear per-basin ramp is already well-quartiled. project.yaml 0.4.1 -> 0.4.2 (0.4.1-tagged canvases carrying flat-lmq semantics reached real disk caches during eyeball runs and must miss). Acceptance gates green; zero golden churn (lake_margin_q not captured by either golden shape). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4829 lines
206 KiB
Rust
4829 lines
206 KiB
Rust
//! DistrictProfile carrier — the coarsest derivation stage of the D-239 refinement
|
||
//! chain (T-1023).
|
||
//!
|
||
//! In the batch pass a `DistrictProfile` summarizes one survey cell (D-256: an
|
||
//! 8×8 working-pixel block, 64×32 ≈ 2 048 cells per body), each a pure
|
||
//! deterministic function of `(seed, body_params, terrain_analysis, pos)` sampled
|
||
//! at the cell's centre world metres through the shared
|
||
//! `derive_at_metres_with_riparian` core; the on-demand pass derives the same
|
||
//! struct at any absolute world position. Stored in `BodyWorldState.districts`
|
||
//! so the Atlas can read zone labels without triggering voxel derivation
|
||
//! (D-239 §10, D-203).
|
||
//!
|
||
//! ## D-010 compliance
|
||
//!
|
||
//! All gating parameters (`GlaciationGrade`, `TectonicClass`, `river_threshold`) are
|
||
//! derived from integer body params via integer arithmetic. No `HashMap` or
|
||
//! floating-point comparison in the derivation path.
|
||
//!
|
||
//! ## MorphologyZone (T-1027, D-239 §6 freeze point)
|
||
//!
|
||
//! 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, BTreeSet};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::atlas::body_world_state::RiverNetwork;
|
||
use crate::atlas::coast_invention;
|
||
use crate::atlas::features::TerrainAnalysis;
|
||
use crate::atlas::region_profile::{self, RegionProfile};
|
||
use crate::atlas::river_course;
|
||
use crate::atlas::scale::{self, BasinDirection, RegionPos, SurveyCellPos};
|
||
use crate::seed::SeedChain;
|
||
use crate::simulation::generator::MorphologyZone;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Supporting enums
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Tectonic activity class — integer-discriminant, append-only (D-010).
|
||
///
|
||
/// Used in morphology gates (D-239 §5): LavaField requires `Volcanic`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum TectonicClass {
|
||
/// Stable craton — minimal volcanism, low earthquake risk.
|
||
#[default]
|
||
Stable = 0,
|
||
/// Active rifting / orogenesis — mountains, earthquakes, some volcanism.
|
||
Active = 1,
|
||
/// Dominant volcanic activity — lava fields, shield volcanoes.
|
||
Volcanic = 2,
|
||
/// Tidally-stressed body — volcanism driven by gravitational flexing.
|
||
TidallyForced = 3,
|
||
}
|
||
|
||
/// Glaciation grade (0–4) — integer-discriminant, append-only (D-010).
|
||
///
|
||
/// D-239 §5 gates: fjord ≥ 2; U-valleys ≥ 1; moraines ≥ 1; cirques ≥ 2;
|
||
/// grade 0 = V-ridges, never glacial U.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum GlaciationGrade {
|
||
/// No glaciation — V-ridges only.
|
||
#[default]
|
||
None = 0,
|
||
/// Glacial erosion signatures — U-valleys, moraines.
|
||
Light = 1,
|
||
/// Moderate glaciation — fjords possible, cirques.
|
||
Moderate = 2,
|
||
/// Heavy glaciation — ice sheets, extensive fjord systems.
|
||
Heavy = 3,
|
||
/// Full ice-cap or snowball body.
|
||
IceCap = 4,
|
||
}
|
||
|
||
/// 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)]
|
||
#[repr(u8)]
|
||
pub enum PrecipitationClass {
|
||
/// Arid — very low precipitation.
|
||
Arid = 0,
|
||
/// Semi-arid — moderate precipitation.
|
||
SemiArid = 1,
|
||
/// Temperate — normal precipitation.
|
||
#[default]
|
||
Temperate = 2,
|
||
/// Humid — high precipitation.
|
||
Humid = 3,
|
||
/// Super-humid / oceanic world.
|
||
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 district 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,
|
||
/// 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,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Body parameters (input to derivation)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Body-level physical parameters needed to derive `DistrictProfile`.
|
||
///
|
||
/// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns
|
||
/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`.
|
||
/// All are optional (may be NULL in the DB).
|
||
///
|
||
/// **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`,
|
||
/// `spectral_class`, `star_type`) are non-canonical placeholder data and are
|
||
/// NOT read into this struct. Temperature derives from `planet_class` envelope
|
||
/// only — see `derive_temperature_c`.
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct BodyParams {
|
||
/// `bodies.hydrosphere` — the real vocabulary in systems.db: "ice",
|
||
/// "liquid_water", "none", "rivers", "moderate", "ocean", "minimal", "trace",
|
||
/// "subsurface_liquid", "rivers-lakes", "extensive", "subsurface_ice",
|
||
/// "subsurface", "ocean-coastal" | NULL. The `[hydrosphere_maritime]` table
|
||
/// keys on these for temperature moderation (D-240).
|
||
pub hydrosphere: Option<String>,
|
||
/// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL
|
||
pub atmosphere: Option<String>,
|
||
/// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL
|
||
pub planet_class: Option<String>,
|
||
/// `bodies.tectonic_activity` — optional authored tectonic override.
|
||
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
|
||
/// from `planet_class`.
|
||
pub tectonic_activity: Option<String>,
|
||
/// Latitude of this cell's centre in the body's reference frame, in degrees.
|
||
/// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient.
|
||
///
|
||
/// This field serves at **both** the district (2 km) and region (~205 km) scales:
|
||
/// when building a `DistrictProfile` it holds the district centre latitude; when
|
||
/// passed to [`crate::atlas::region_profile::derive_region_baseline_c`] it is
|
||
/// expected to carry the **region centre latitude** (callers override it via
|
||
/// struct-update syntax before passing the params down). The name was changed
|
||
/// from `latitude_deg` to `latitude_deg` (T-1078) to remove the
|
||
/// misleading scale implication.
|
||
pub latitude_deg: f64,
|
||
/// Mean elevation of this district relative to sea level, in km. Used for lapse rate.
|
||
pub elevation_km: f64,
|
||
/// `bodies.body_radius_km` (D-204) — the body's radius in km. The single
|
||
/// body-specific input to the D-243 elastic seam (`scale::regions_per_equator`).
|
||
/// `None` if the body has no recorded radius.
|
||
pub body_radius_km: Option<f64>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DistrictPos — position on the ~1 km district grid
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Position on the 2 km district grid — re-exported from the canonical ladder
|
||
/// ([`crate::atlas::scale`], D-243) so the carrier shares one `DistrictPos` with
|
||
/// the addressing layer. `BTreeMap` key — `Ord` for D-010 determinism.
|
||
pub use crate::atlas::scale::DistrictPos;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DistrictProfile
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-district (~1 km) terrain classification derived from body params + heightmap.
|
||
///
|
||
/// Pure derivation — no per-body authoring. All gating parameters come from
|
||
/// `BodyParams`; lore-anchored bodies honour their params, not override hooks.
|
||
/// Stored in `BodyWorldState.districts` (D-203, D-239 §10).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DistrictProfile {
|
||
/// District morphology zone (D-239 §6). Uses the **existing** `MorphologyZone`
|
||
/// enum — reconciliation to the D-239 frozen 17-zone vocabulary is T-1027.
|
||
pub morphology_zone: MorphologyZone,
|
||
|
||
/// Tectonic activity class (D-239 §5).
|
||
pub tectonic_class: TectonicClass,
|
||
|
||
/// Glaciation grade 0–4 (D-239 §5 gates).
|
||
pub glaciation_grade: GlaciationGrade,
|
||
|
||
/// Precipitation class, derived from hydrosphere + body params.
|
||
pub precipitation_class: PrecipitationClass,
|
||
|
||
/// Integer-quantized slope summary for the district (0–100 scale, units: 1 = ~0.45 °).
|
||
/// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this district.
|
||
pub slope_q: i32,
|
||
|
||
/// Integer-quantized elevation percentile summary (0–100 scale).
|
||
/// Average of `TerrainAnalysis.elev_pct` × 100 across district cells.
|
||
pub elev_q: i32,
|
||
|
||
/// Ocean fraction for this district (0–100 scale, integer).
|
||
pub ocean_fraction_q: i32,
|
||
|
||
/// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4)
|
||
/// continued): `0` at/near the shoreline (the `filled == elevation`
|
||
/// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as
|
||
/// the settled water surface sits deeper above the original bedrock,
|
||
/// relative to THIS BASIN's own maximum depth — quantized
|
||
/// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when
|
||
/// the basin's own max depth is degenerate (see
|
||
/// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell
|
||
/// (never negative — a cell with no settled water above it has no
|
||
/// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s
|
||
/// coastal transition-zone gradient: `ocean_fraction_q` is always `0`
|
||
/// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never
|
||
/// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/
|
||
/// Estuarine morphology gates — every one keyed on `ocean_fraction_q` —
|
||
/// are structurally unreachable at a lake edge. `lake_margin_q` gives
|
||
/// the client a continuous tone source for lake shorelines without
|
||
/// inventing a second morphology-classification path; see
|
||
/// `lake_from_hydrology_at`'s doc for the full per-basin-normalization
|
||
/// rationale (PR #206 eyeball finding: a fixed absolute ceiling read
|
||
/// visually flat on real lakes).
|
||
#[serde(default)]
|
||
pub lake_margin_q: i32,
|
||
|
||
/// Per-district river threshold (D-239 §1). Replaces the global
|
||
/// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant
|
||
/// itself is unchanged — this value is what `DistrictProfile` carries downstream.
|
||
pub river_threshold: i32,
|
||
|
||
/// Nullable mean-annual temperature in °C (T-1024 forward declaration).
|
||
/// `None` if no atmosphere (airless; see D-239 §2, D-227).
|
||
pub temperature_c: Option<f32>,
|
||
|
||
/// 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,
|
||
|
||
/// Dominant D8 thalweg direction for this district (T-1047, D-239 §8).
|
||
///
|
||
/// The **true** D8-computed dominant flow direction aggregated from the full
|
||
/// `fdir` grid in `run_layer1` — not a seed-bit proxy. Threaded here from
|
||
/// `Layer1Output.survey_basin_dirs` (D-256(b): a survey-cell-keyed aggregate,
|
||
/// looked up by identity in `derive_all_districts` — see that function's
|
||
/// doc) so `derive_chunk_context` reads it directly instead of calling the
|
||
/// formerly-false `derive_basin_direction`.
|
||
///
|
||
/// `#[serde(default)]` ensures backward compatibility when deserializing
|
||
/// stored profiles that predate this field (T-1047).
|
||
#[serde(default)]
|
||
pub basin_direction: BasinDirection,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Derivation helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Derive `TectonicClass` from body params.
|
||
///
|
||
/// Respects the optional `tectonic_activity` authored override; falls back to
|
||
/// `planet_class` derivation. Integer / string comparison only (D-010).
|
||
fn derive_tectonic_class(params: &BodyParams) -> TectonicClass {
|
||
// Authored override takes precedence.
|
||
if let Some(ta) = ¶ms.tectonic_activity {
|
||
return match ta.as_str() {
|
||
"volcanic" => TectonicClass::Volcanic,
|
||
"active" => TectonicClass::Active,
|
||
"tidally_forced" => TectonicClass::TidallyForced,
|
||
_ => TectonicClass::Stable,
|
||
};
|
||
}
|
||
// Derive from planet_class.
|
||
match params.planet_class.as_deref().unwrap_or("") {
|
||
"volcanic" => TectonicClass::Volcanic,
|
||
"oceanic" | "ocean_world" => TectonicClass::Active,
|
||
_ => TectonicClass::Stable,
|
||
}
|
||
}
|
||
|
||
/// 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<f32>,
|
||
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 temperature + moisture primitives (D-239 §2, §8).
|
||
///
|
||
/// 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<f32>,
|
||
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;
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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 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 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. It is a RIVER-proximity signal —
|
||
/// unrelated to `open_water`, which is the ocean/lake verdict.
|
||
///
|
||
/// `open_water` (T-1126) is the **morphology-derived** water verdict — `true`
|
||
/// when the district's already-derived `MorphologyZone` is OpenOcean/Lake.
|
||
/// Callers pass the verdict in; this function must NEVER re-derive it from an
|
||
/// ocean-fraction threshold of its own (no fourth threshold — vegetation and
|
||
/// morphology can never disagree by construction). Airless takes precedence:
|
||
/// an airless body has no climate/vegetation branch at all (D-239 §2), so its
|
||
/// seas read `Absent`, not `Marine`.
|
||
pub fn derive_vegetation(
|
||
temperature_c: Option<f32>,
|
||
moisture_q: i32,
|
||
elev_q: i32,
|
||
near_perennial_water: bool,
|
||
open_water: bool,
|
||
) -> VegetationClass {
|
||
// Airless: entire climate/vegetation branch absent.
|
||
let Some(temp_c) = temperature_c else {
|
||
return VegetationClass::Absent;
|
||
};
|
||
// Open water (T-1126): the morphology verdict, passed in — see doc above.
|
||
if open_water {
|
||
return VegetationClass::Marine;
|
||
}
|
||
let temp_i = temp_c as i32;
|
||
|
||
// Extreme cold → Barren regardless of water proximity: below ~−50 °C mean
|
||
// 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
|
||
};
|
||
}
|
||
|
||
// 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-district river threshold from tectonic class + precipitation (D-239 §1).
|
||
///
|
||
/// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers.
|
||
/// Higher precipitation → lower threshold (more rivers).
|
||
/// All arithmetic is integer (D-010).
|
||
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 precip {
|
||
PrecipitationClass::Arid => 100,
|
||
PrecipitationClass::SemiArid => 50,
|
||
PrecipitationClass::Temperate => 0,
|
||
PrecipitationClass::Humid => -50,
|
||
PrecipitationClass::SuperHumid => -80,
|
||
};
|
||
// Base of 200 + body-level adjustments, clamped to [20, 500].
|
||
(200 + precip_factor + tectonic_bonus).clamp(20, 500)
|
||
}
|
||
|
||
/// Derive `MorphologyZone` from `DistrictProfile` gating params (D-239 §5, §6, §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. 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 district scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) DistrictProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
|
||
///
|
||
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
|
||
///
|
||
/// `lake_from_hydrology` (T-1184, D-227 amendment (4) / D-255(f) mechanism B):
|
||
/// the caller-computed settled-hydrology basin verdict at this exact
|
||
/// position — `true` when a bilinear sample of `HydrologyResult.filled_scaled`
|
||
/// exceeds a bilinear sample of the original elevation at the SAME position
|
||
/// (the continuous filled-surface comparison; never a discrete basin-cell
|
||
/// membership lookup, which would give a blocky, non-refining lake edge).
|
||
/// `false` both when hydrology genuinely found no lake here AND when no
|
||
/// `HydrologyResult` is available at all (`TerrainAnalysis.hydrology ==
|
||
/// None`) — both cases fall through to the pre-existing `ocean_fraction_q`
|
||
/// heuristic below unchanged, so a caller with no hydrology data reproduces
|
||
/// today's behaviour byte-for-byte. This is a MORE AUTHORITATIVE trigger
|
||
/// checked AHEAD OF the heuristic (per the araminta-round2.md §(e) ruling:
|
||
/// "Sea vs. Lake stays exactly as today... the `Lake` emission site gains a
|
||
/// second, more-authoritative trigger ahead of the existing heuristic
|
||
/// fallback") — it never touches the `ocean_fraction_q >= 80` open-ocean
|
||
/// tier, which stays exactly as before.
|
||
pub fn derive_morphology_zone(
|
||
tectonic: TectonicClass,
|
||
glaciation: GlaciationGrade,
|
||
slope_q: i32,
|
||
elev_q: i32,
|
||
ocean_fraction_q: i32,
|
||
moisture_q: i32,
|
||
lake_from_hydrology: bool,
|
||
) -> MorphologyZone {
|
||
// ── 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 district level yet; treat all as OpenOcean.
|
||
// Lake differentiation lives at ChunkContext (D-239 §10).
|
||
//
|
||
// Unchanged by T-1184: a settled-hydrology lake basin can never
|
||
// reclassify a cell the raw heightmap already reads as ≥80% below
|
||
// sea level back OUT of OpenOcean — hydrology only ever ADDS Lake
|
||
// coverage the heuristic was missing, never removes the open-ocean
|
||
// floor. (Also structurally moot: `HydrologyResult`'s priority-flood
|
||
// seeds from below-sea-level cells, so a genuine open-ocean cell's
|
||
// `filled == original` there — no lake basin ever covers it.)
|
||
return MorphologyZone::OpenOcean;
|
||
}
|
||
if lake_from_hydrology {
|
||
return MorphologyZone::Lake;
|
||
}
|
||
if ocean_fraction_q >= 60 {
|
||
return MorphologyZone::Lake;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── 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
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Climate constants (T-1024, D-239 §2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Maximum per-district elevation (km), used to scale a district's normalized
|
||
/// elevation (`elev_q`, 0–100) into the temperature-lapse input. Earth-like
|
||
/// span (~Everest); a coarse body-agnostic constant for now — a per-body relief
|
||
/// span can replace it when body relief data is carried (T-1032).
|
||
const MAX_REGION_ELEVATION_KM: f64 = 8.0;
|
||
|
||
/// Climate tuning constants. `ClimateConstants::default()` holds the embedded
|
||
/// values and is the authoritative runtime source **today**; the source-canonical
|
||
/// `server/data/climate_constants.toml` mirrors those same values and is the file
|
||
/// to edit when tuning. Runtime loading of the TOML ("tunable without recompile")
|
||
/// lands with the production dispatch wiring (T-1032) — there is intentionally no
|
||
/// `load()` method yet, so keep the TOML and the embedded `default()` in sync by
|
||
/// hand until then.
|
||
///
|
||
/// D-240: temperature derives from `planet_class` envelope, not orbit/star data.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ClimateConstants {
|
||
/// Per-`planet_class` temperature envelope: `(cold_end_c, warm_end_c)` °C.
|
||
/// A body can never derive outside its class band (D-240).
|
||
/// Keys: "temperate", "arid", "frozen", "tropical", "hot_arid", "volcanic", …
|
||
/// Prefix rules applied in `envelope()`: "cold_*" → −10°C shift; "hot_*" → +10°C;
|
||
/// "warm_*" → +5°C. Unknown class falls back to "temperate" band.
|
||
pub planet_class_temperature: std::collections::BTreeMap<String, (f32, f32)>,
|
||
/// Greenhouse warming fraction per atmosphere class (0.0–1.0 of band width).
|
||
/// Keys: "thin", "standard", "breathable", "toxic", "dense".
|
||
/// "none" / absent = 0.0 (airless bodies return `None` before reaching this).
|
||
pub greenhouse_offset_c: std::collections::BTreeMap<String, f32>,
|
||
/// Day/night swing amplitude per atmosphere class (°C).
|
||
pub diurnal_amplitude_c: std::collections::BTreeMap<String, f32>,
|
||
/// Maritime-moderation factor per `hydrosphere` (D-240): scales the
|
||
/// equator→pole gradient. `1.0` = full gradient (dry world swings the whole
|
||
/// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are
|
||
/// milder at both ends). Absent/unknown = `1.0`.
|
||
pub hydrosphere_maritime: std::collections::BTreeMap<String, f32>,
|
||
|
||
// ── Moisture gradient (T-1080, D-239 §2) ─────────────────────────────────
|
||
// Per-district moisture spatial-gradient penalties — integer points subtracted
|
||
// from the body's hydrosphere moisture *ceiling*. A living world is wet at the
|
||
// coast / lowland / tropics and drier toward the interior / highland / poles;
|
||
// without this gradient the climate field is a single body-constant (T-1080).
|
||
// Provisional magnitudes — Q-123 calibrates. Mirror `[moisture_gradient]` in
|
||
// `climate_constants.toml`.
|
||
/// Moisture lost equator→pole (× |latitude|/90).
|
||
pub moisture_lat_penalty: i32,
|
||
/// Moisture lost low→high elevation (× elev_q/100) — orographic / rain-shadow.
|
||
pub moisture_elev_penalty: i32,
|
||
/// Moisture lost coast→interior (× (100 − ocean_fraction_q)/100) — continentality.
|
||
pub moisture_continental_penalty: i32,
|
||
}
|
||
|
||
impl Default for ClimateConstants {
|
||
/// Embedded fallback — matches `server/data/climate_constants.toml`.
|
||
/// Used when the TOML file is not available (tests, embedded contexts).
|
||
fn default() -> Self {
|
||
let mut pct = std::collections::BTreeMap::new();
|
||
pct.insert("frozen".into(), (-90.0f32, -25.0f32));
|
||
pct.insert("ice".into(), (-90.0f32, -25.0f32));
|
||
pct.insert("boreal".into(), (-35.0f32, 12.0f32));
|
||
pct.insert("cold_arid".into(), (-40.0f32, 20.0f32));
|
||
pct.insert("temperate".into(), (-12.0f32, 28.0f32));
|
||
pct.insert("oceanic".into(), (-12.0f32, 28.0f32));
|
||
pct.insert("subtropical".into(), (2.0f32, 34.0f32));
|
||
pct.insert("warm_ocean".into(), (2.0f32, 34.0f32));
|
||
pct.insert("tropical".into(), (16.0f32, 40.0f32));
|
||
pct.insert("arid".into(), (-5.0f32, 45.0f32));
|
||
pct.insert("hot_arid".into(), (20.0f32, 58.0f32));
|
||
pct.insert("volcanic".into(), (30.0f32, 90.0f32));
|
||
pct.insert("geothermal".into(), (30.0f32, 90.0f32));
|
||
|
||
let mut gh = std::collections::BTreeMap::new();
|
||
gh.insert("thin".into(), 0.10f32);
|
||
gh.insert("standard".into(), 0.25f32);
|
||
gh.insert("breathable".into(), 0.25f32);
|
||
gh.insert("toxic".into(), 0.25f32);
|
||
gh.insert("dense".into(), 0.55f32);
|
||
|
||
let mut da = std::collections::BTreeMap::new();
|
||
da.insert("thin".into(), 50.0f32);
|
||
da.insert("standard".into(), 15.0f32);
|
||
da.insert("breathable".into(), 15.0f32);
|
||
da.insert("toxic".into(), 20.0f32);
|
||
da.insert("dense".into(), 3.0f32);
|
||
|
||
// Maritime moderation: water-rich worlds compress the equator–pole gradient.
|
||
// Keys are the actual `bodies.hydrosphere` vocabulary in systems.db.
|
||
let mut hm = std::collections::BTreeMap::new();
|
||
// Large surface liquid — strong moderation.
|
||
hm.insert("liquid_water".into(), 0.6f32);
|
||
hm.insert("ocean".into(), 0.6f32);
|
||
hm.insert("ocean-coastal".into(), 0.6f32);
|
||
hm.insert("extensive".into(), 0.6f32);
|
||
// Partial surface water — mild moderation.
|
||
hm.insert("rivers".into(), 0.8f32);
|
||
hm.insert("rivers-lakes".into(), 0.8f32);
|
||
hm.insert("moderate".into(), 0.8f32);
|
||
// Frozen / subsurface — slight moderation.
|
||
hm.insert("ice".into(), 0.85f32);
|
||
hm.insert("subsurface_liquid".into(), 0.9f32);
|
||
hm.insert("subsurface".into(), 0.95f32);
|
||
hm.insert("subsurface_ice".into(), 0.95f32);
|
||
// "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback.
|
||
|
||
ClimateConstants {
|
||
planet_class_temperature: pct,
|
||
greenhouse_offset_c: gh,
|
||
diurnal_amplitude_c: da,
|
||
hydrosphere_maritime: hm,
|
||
// Moisture gradient (T-1080) — provisional; Q-123 calibrates. Tuned so a
|
||
// wet body (high ceiling) stays mostly vegetated with drier patches rather
|
||
// than cratering to near-desert; a dry/frozen body (low ceiling) still
|
||
// clamps mostly barren. Total max penalty 55 < an ocean ceiling of ~80.
|
||
moisture_lat_penalty: 20,
|
||
moisture_elev_penalty: 15,
|
||
moisture_continental_penalty: 20,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClimateConstants {
|
||
/// Return the temperature envelope `(cold_c, warm_c)` for a planet class.
|
||
///
|
||
/// Looks up the class directly first. On a miss, applies prefix rules against
|
||
/// the "temperate" band: `cold_*` shifts both ends −10 °C, `hot_*` +10 °C,
|
||
/// `warm_*` +5 °C. Completely unknown classes fall back to the "temperate" band.
|
||
pub fn envelope(&self, planet_class: &str) -> (f32, f32) {
|
||
// Direct lookup first.
|
||
if let Some(&band) = self.planet_class_temperature.get(planet_class) {
|
||
return band;
|
||
}
|
||
// Prefix rules: shift the temperate fallback band.
|
||
let default_band = self
|
||
.planet_class_temperature
|
||
.get("temperate")
|
||
.copied()
|
||
.unwrap_or((-12.0, 28.0));
|
||
if planet_class.starts_with("cold_") {
|
||
(default_band.0 - 10.0, default_band.1 - 10.0)
|
||
} else if planet_class.starts_with("hot_") {
|
||
(default_band.0 + 10.0, default_band.1 + 10.0)
|
||
} else if planet_class.starts_with("warm_") {
|
||
(default_band.0 + 5.0, default_band.1 + 5.0)
|
||
} else {
|
||
default_band
|
||
}
|
||
}
|
||
|
||
/// Greenhouse fraction for a given atmosphere class (0.0–1.0), defaulting to 0.0.
|
||
pub fn greenhouse(&self, atmosphere: &str) -> f32 {
|
||
self.greenhouse_offset_c
|
||
.get(atmosphere)
|
||
.copied()
|
||
.unwrap_or(0.0)
|
||
}
|
||
|
||
/// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full
|
||
/// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band
|
||
/// midpoint (ocean ≈ 0.6 → milder at both ends).
|
||
pub fn maritime_factor(&self, hydrosphere: &str) -> f32 {
|
||
self.hydrosphere_maritime
|
||
.get(hydrosphere)
|
||
.copied()
|
||
.unwrap_or(1.0)
|
||
}
|
||
|
||
/// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30.
|
||
pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 {
|
||
self.diurnal_amplitude_c
|
||
.get(atmosphere)
|
||
.copied()
|
||
.unwrap_or(30.0)
|
||
}
|
||
}
|
||
|
||
/// Derive mean-annual district temperature in °C (nullable).
|
||
///
|
||
/// Implements D-240: temperature derives from `planet_class` envelope only.
|
||
/// Orbit/star fields (`orbital_period_days`, `spectral_class`, `star_type`,
|
||
/// `axial_tilt_deg`) are non-canonical placeholder data and are NOT inputs.
|
||
///
|
||
/// Returns `None` if no atmosphere (airless; D-227).
|
||
///
|
||
/// ## Algorithm (D-240)
|
||
///
|
||
/// 1. Look up the `(cold, warm)` envelope for `planet_class` via
|
||
/// `ClimateConstants::envelope()`. Unknown class falls back to "temperate".
|
||
/// 2. **Latitude lerp**: `t_lat = warm - (warm - cold) * (|lat| / 90)` —
|
||
/// equator → warm end, pole → cold end.
|
||
/// 3. **Atmosphere modulation**: greenhouse fraction nudges `t_lat` toward the
|
||
/// warm end by `greenhouse_frac × (warm - cold)`.
|
||
/// 4. **Elevation lapse**: subtract `lapse_rate × elevation_km` (thin atmo:
|
||
/// 3.5 °C/km, else 6.5 °C/km).
|
||
/// 5. **Seed nudge**: deterministic ±~3 °C per-body variation from `body_seed`.
|
||
/// 6. **Clamp to `[cold, warm]`** — class band is a hard invariant (D-240).
|
||
///
|
||
/// Uses f32 throughout (adequate for ~2 km district-mean temperature).
|
||
/// D-010: all downstream gating uses `temperature_c as i32`; the float here
|
||
/// is positional physics, not a structural comparison.
|
||
pub fn derive_temperature_c(
|
||
params: &BodyParams,
|
||
constants: &ClimateConstants,
|
||
body_seed: u64,
|
||
) -> Option<f32> {
|
||
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
|
||
|
||
// No atmosphere → airless body; temperature is None (D-227).
|
||
if atmosphere == "none" {
|
||
return None;
|
||
}
|
||
|
||
// Step 1: planet_class → (cold, warm) envelope (D-240).
|
||
let planet_class = params.planet_class.as_deref().unwrap_or("temperate");
|
||
let (cold, warm) = constants.envelope(planet_class);
|
||
let band_width = warm - cold;
|
||
|
||
// Step 2: latitude lerp across the maritime-moderated band (D-240). Water-rich
|
||
// worlds compress the equator→pole gradient toward the band midpoint — an ocean
|
||
// world is milder at both ends; a dry world swings the full band.
|
||
let hydrosphere = params.hydrosphere.as_deref().unwrap_or("none");
|
||
let maritime = constants.maritime_factor(hydrosphere);
|
||
let mid = (cold + warm) * 0.5;
|
||
let half = band_width * 0.5 * maritime;
|
||
let lat_frac = (params.latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
|
||
// equator (frac 0) → mid + half; pole (frac 1) → mid − half.
|
||
let t_lat = (mid + half) - (2.0 * half) * lat_frac;
|
||
|
||
// Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end.
|
||
let gh_frac = constants.greenhouse(atmosphere);
|
||
let t_atmo = t_lat + gh_frac * band_width;
|
||
|
||
// Step 4: elevation lapse rate (°C/km).
|
||
let lapse = if atmosphere == "thin" {
|
||
3.5_f32
|
||
} else {
|
||
6.5_f32
|
||
};
|
||
let t_lapse = t_atmo - lapse * (params.elevation_km as f32).max(0.0);
|
||
|
||
// Step 5: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240).
|
||
// Hash body_seed with a mixing constant to produce a per-body offset.
|
||
// The nudge is a fraction of the band width, bounded to ±3 °C maximum.
|
||
let nudge = {
|
||
// Deterministic hash: splitmix64-style single round.
|
||
let h = body_seed
|
||
.wrapping_add(0x9e37_79b9_7f4a_7c15)
|
||
.wrapping_mul(0x6c62_272e_07bb_0142);
|
||
// Map to [-1.0, 1.0] and scale to ±3°C.
|
||
let unit = (h as i64 as f64 / i64::MAX as f64) as f32;
|
||
unit * 3.0_f32
|
||
};
|
||
let t_nudged = t_lapse + nudge;
|
||
|
||
// Step 6: clamp to [cold, warm] — class band is a hard invariant (D-240).
|
||
Some(t_nudged.clamp(cold, warm))
|
||
}
|
||
|
||
/// Derive the district-level temperature as a **modulation** of a region baseline
|
||
/// (D-243 §3 / D-239 §2 split, T-1078).
|
||
///
|
||
/// This is **step (b)** of the two-phase temperature derivation:
|
||
/// - **Step (a)** is the region baseline (`region_profile::derive_region_baseline_c`):
|
||
/// latitude + greenhouse nudge + seed nudge, clamped to the class band.
|
||
/// - **Step (b)** is here: apply elevation lapse + slope aspect ON TOP of the
|
||
/// baseline, re-clamp to the class band.
|
||
///
|
||
/// When a region baseline is available (produced by the edge-fuzz blend in
|
||
/// `region_profile::region_baseline_at_district`), callers should prefer this
|
||
/// function over [`derive_temperature_c`]. The two-phase split ensures the
|
||
/// temperature gradient is a continuous, warp-perturbed scalar field (D-243 §4)
|
||
/// rather than independent per-district derivations.
|
||
///
|
||
/// ## Inputs
|
||
///
|
||
/// - `region_baseline_c` — the edge-fuzz-blended region mean temperature (from
|
||
/// `region_profile::region_baseline_at_district`). `None` means airless.
|
||
/// - `params` — `BodyParams` with the district's own `elevation_km` and
|
||
/// `atmosphere` (for lapse rate selection). The latitude/hydrosphere fields
|
||
/// are **not re-used here** — they were consumed by the region baseline.
|
||
/// - `constants` — climate constants (for the class-band clamp).
|
||
///
|
||
/// ## Returns
|
||
///
|
||
/// `None` if `region_baseline_c` is `None` (airless body). Otherwise the
|
||
/// district temperature in °C, clamped to the planet class band.
|
||
///
|
||
/// ## Slope aspect modulation (D-243 §3)
|
||
///
|
||
/// Slope aspect is a future input that will modulate temperature based on
|
||
/// sun-facing vs. shaded slopes. It is not yet available at the district tier
|
||
/// (no per-district aspect data). The parameter is reserved; pass `0.0`.
|
||
///
|
||
/// ## D-010 compliance
|
||
///
|
||
/// All structural gating downstream uses `temperature_c as i32`. The f32
|
||
/// arithmetic here is positional physics.
|
||
pub fn derive_district_temperature_c(
|
||
region_baseline_c: Option<f32>,
|
||
params: &BodyParams,
|
||
constants: &ClimateConstants,
|
||
_slope_aspect_deg: f32, // reserved for Q-105 / future per-district aspect
|
||
) -> Option<f32> {
|
||
// Airless: region baseline is None → no district temperature.
|
||
let baseline = region_baseline_c?;
|
||
|
||
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
|
||
// Double-check: if atmosphere is "none" the region baseline should already
|
||
// be None, but guard defensively.
|
||
if atmosphere == "none" {
|
||
return None;
|
||
}
|
||
|
||
// Elevation lapse rate (°C/km). Same as in derive_temperature_c.
|
||
let lapse = if atmosphere == "thin" {
|
||
3.5_f32
|
||
} else {
|
||
6.5_f32
|
||
};
|
||
let elev_km = (params.elevation_km as f32).max(0.0);
|
||
let t_lapse = baseline - lapse * elev_km;
|
||
|
||
// Slope aspect modulation: reserved for future Q-105 / per-district aspect data.
|
||
// _slope_aspect_deg is currently unused; the `let _ = …` suppresses the lint.
|
||
let _ = _slope_aspect_deg;
|
||
|
||
// Clamp to the class band (D-240 hard invariant).
|
||
let planet_class = params.planet_class.as_deref().unwrap_or("temperate");
|
||
let (cold, warm) = constants.envelope(planet_class);
|
||
Some(t_lapse.clamp(cold, warm))
|
||
}
|
||
|
||
/// Derive the district moisture primitive (0–100 integer; 0 = arid, 100 = saturated).
|
||
///
|
||
/// D-239 §2 / T-1080: moisture is a **body ceiling × per-district spatial gradient**.
|
||
/// The body ceiling comes from `hydrosphere` + `atmosphere` (an ocean world is wetter
|
||
/// on average); the gradient then varies it across the body so the climate field is
|
||
/// not a single constant (the T-1080 bug: `moisture_q = 80` for all 2048 districts,
|
||
/// which left vegetation/terrain/ecotones uniform). A living world is wetter at the
|
||
/// **coast / lowland / tropics** and drier toward the **interior / highland / poles**:
|
||
/// - **latitude** — equator wet → pole dry (`params.latitude_deg`, set per-district).
|
||
/// - **elevation** — high ground drier (orographic / rain-shadow, `elev_q`).
|
||
/// - **continentality** — interior drier than coast (`100 − ocean_fraction_q`).
|
||
///
|
||
/// Penalties are subtractive points from the ceiling, magnitudes tuned in
|
||
/// [`ClimateConstants`] (`moisture_*_penalty`, provisional pending Q-123). Integer
|
||
/// arithmetic throughout (D-010); the only float is the deterministic `latitude_deg`
|
||
/// truncation at the decision boundary, mirroring the `slope_q`/`elev_q` aggregation.
|
||
///
|
||
/// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db**
|
||
/// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available
|
||
/// surface moisture (T-1034).
|
||
/// Body-wide moisture ceiling — the wettest a district on this body can be,
|
||
/// from `hydrosphere` + `atmosphere` alone (T-1080's `ceiling` term, before
|
||
/// the per-district latitude/elevation/continentality gradient). Extracted
|
||
/// (T-1184) so a body-level-only consumer — [`crate::atlas::hydrology_equilibrium`]'s
|
||
/// `ClimateInputs::moisture_q`, which needs exactly this single scalar and
|
||
/// nothing position-specific — can share the vocabulary table with
|
||
/// [`derive_moisture_q`] instead of re-deriving a parallel one that could
|
||
/// silently drift from it. Byte-identical to the `ceiling` local this
|
||
/// function's caller computed inline before the extraction.
|
||
pub fn derive_moisture_ceiling_q(params: &BodyParams) -> i32 {
|
||
let hydro = params.hydrosphere.as_deref().unwrap_or("none");
|
||
let atmo = params.atmosphere.as_deref().unwrap_or("none");
|
||
|
||
let base: i32 = match hydro {
|
||
// Large surface liquid — saturated.
|
||
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 80,
|
||
// Partial surface water (rivers / lakes / seasonal) — moderate.
|
||
"rivers" | "rivers-lakes" | "moderate" => 55,
|
||
// Frozen surface water — low available moisture.
|
||
"ice" => 20,
|
||
// Buried water — minimal surface effect.
|
||
"subsurface_liquid" => 15,
|
||
"subsurface" | "subsurface_ice" => 10,
|
||
// Effectively dry.
|
||
"minimal" | "trace" => 5,
|
||
"none" => 0,
|
||
// Unknown vocab — conservative mid-low default.
|
||
_ => 30,
|
||
};
|
||
let atmo_boost: i32 = match atmo {
|
||
"none" => -20,
|
||
"thin" => -10,
|
||
"standard" | "breathable" => 0,
|
||
"toxic" => 5,
|
||
"dense" => 15,
|
||
_ => 0,
|
||
};
|
||
(base + atmo_boost).clamp(0, 100)
|
||
}
|
||
|
||
pub fn derive_moisture_q(
|
||
params: &BodyParams,
|
||
elev_q: i32,
|
||
ocean_fraction_q: i32,
|
||
climate: &ClimateConstants,
|
||
) -> i32 {
|
||
// Body moisture ceiling — the wettest a district on this body can be.
|
||
let ceiling = derive_moisture_ceiling_q(params);
|
||
|
||
// ── Per-district spatial gradient (T-1080) ────────────────────────────────
|
||
// Latitude: equator (0) wet → pole (90) dry. `latitude_deg` is per-district.
|
||
let lat_q = (params.latitude_deg.abs() as i32).clamp(0, 90) * 100 / 90; // 0..100
|
||
let lat_penalty = lat_q * climate.moisture_lat_penalty / 100;
|
||
// Elevation: high ground is drier (orographic uplift / rain-shadow / less retention).
|
||
let elev_penalty = elev_q.clamp(0, 100) * climate.moisture_elev_penalty / 100;
|
||
// Continentality: interior (low ocean fraction) is drier than coast / open water.
|
||
let interiorness = (100 - ocean_fraction_q.clamp(0, 100)).max(0);
|
||
let cont_penalty = interiorness * climate.moisture_continental_penalty / 100;
|
||
|
||
(ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T-1125 — invented primitives (shared by both derivation paths)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Distinct hash-path salt for the T-1162 sub-district relief stream (part
|
||
/// b) — keeps `voxel_relief`'s noise uncorrelated with the district-band
|
||
/// `terrain_detail` scatter sampled at the same position. Promoted to a
|
||
/// named const (Tyre, PR #194 forward-contract for T-1156) matching
|
||
/// `vegetation_invention::VEGETATION_MASSIF_SALT`/`VEGETATION_TEXTURE_SALT`'s
|
||
/// named-and-greppable convention, ahead of T-1156 adding a fourth
|
||
/// isolated stream (rivers) — a fourth inline hex literal here would have
|
||
/// made the pattern harder to audit at a glance.
|
||
const WINDOW_RELIEF_SALT: u64 = 0x5EED_C0DE;
|
||
|
||
/// 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`.
|
||
///
|
||
/// `min_wavelength_m` (T-1149, zoom ladder §2): threaded straight to the
|
||
/// `terrain_detail` scatter call — octaves finer than this are truncated.
|
||
/// `0.0` = no cutoff = today's behavior.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn invent_primitives(
|
||
seed: SeedChain,
|
||
body_params: &BodyParams,
|
||
climate: &ClimateConstants,
|
||
ta: &TerrainAnalysis,
|
||
px: f64,
|
||
py: f64,
|
||
world_x_m: f64,
|
||
world_y_m: f64,
|
||
region_baseline_c: Option<f32>,
|
||
min_wavelength_m: f64,
|
||
) -> 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. ────────────
|
||
// T-1162: the coast warp gets the SAME min_wavelength_m cutoff as the
|
||
// terrain-detail scatter below — one rung, one cutoff, applied to both
|
||
// continuous fields that compose the invented coastline.
|
||
let (wdx, wdy) =
|
||
coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch, min_wavelength_m);
|
||
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,
|
||
min_wavelength_m,
|
||
);
|
||
|
||
// T-1162 part (b): sub-district relief band (VOXEL_OCTAVE_WAVELENGTHS_M,
|
||
// 1,024–128 m — "the rolling hills a walking character navigates by",
|
||
// T-1081) fed into window classification for the first time. Same
|
||
// envelope/ruggedness inputs as the district-band `scatter` above (one
|
||
// amplitude ceiling, two wavelength bands composing additively — never a
|
||
// second independently-tuned amplitude rule) and the SAME
|
||
// `min_wavelength_m` cutoff. At District's real Nyquist floor (4,096 m,
|
||
// `layer_proxy::MIN_WL_BANDS_M`) every VOXEL_OCTAVE_WAVELENGTHS_M entry
|
||
// (all ≤1,024 m) is truncated, so `relief` is always exactly 0.0 there —
|
||
// District's output is unchanged byte-for-byte. At Quarter's cutoff
|
||
// (1,024 m) only the two coarsest voxel-band entries (1,024, 512)
|
||
// survive; the two finest (256, 128) stay truncated even at Quarter
|
||
// (below Quarter's own 512 m spacing's Nyquist floor of 1,024 m) — this
|
||
// is correct and expected per the D-226 T-1150 wire-contract note and
|
||
// T-1162 refinement resolution (2): a contributing wavelength is never
|
||
// capped by the rung's sample-density floor alone (the coast warp
|
||
// already crosses scales the other way), it is simply that only 2 of
|
||
// the 4 voxel octaves are coarse enough to matter at Quarter's own
|
||
// sample density; the other two are reserved for a future
|
||
// finer-than-Quarter rung. Distinct seed salt
|
||
// ([`WINDOW_RELIEF_SALT`]) keeps this stream uncorrelated with
|
||
// `scatter`'s stream at the same position (same isolation discipline as
|
||
// `coast_invention`'s warp salt).
|
||
let relief = crate::atlas::detail_scatter::voxel_relief(
|
||
seed.seed() ^ WINDOW_RELIEF_SALT,
|
||
world_x_m,
|
||
world_y_m,
|
||
env_amp,
|
||
ruggedness,
|
||
min_wavelength_m,
|
||
);
|
||
|
||
// 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);
|
||
|
||
// Both bands add into the same elevation/slope quantization — `relief` is
|
||
// 0.0 at every cutoff ≥ 2,048 m (District and coarser), so this sum is
|
||
// byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the
|
||
// cutoff discipline says it must be.
|
||
InventedPrimitives {
|
||
elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100),
|
||
slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.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
|
||
/// [`derive_district_profile`] (D-256(b)) to convert a survey cell's centre
|
||
/// pixel to the world metres it hands to the shared [`derive_at_metres_with_riparian`]
|
||
/// core — both derivation paths key their invention noise fields on the same
|
||
/// world-metre convention this way. Radius-less bodies fall back to the
|
||
/// 1-working-pixel = 1-district convention `derive_district` uses.
|
||
///
|
||
/// `pub(crate)` (T-1170): also used by the river course inventor
|
||
/// (`river_course::cell_world_m`) to resolve a river cell's pixel position to
|
||
/// its world-metre anchor — the SAME mapping, reused rather than duplicated.
|
||
pub(crate) fn pixel_to_world_m(
|
||
px: f64,
|
||
py: f64,
|
||
w: usize,
|
||
h: usize,
|
||
radius_km: Option<f64>,
|
||
) -> (f64, f64) {
|
||
match radius_km {
|
||
Some(r) if r > 0.0 => {
|
||
let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0);
|
||
let lat_frac = if h > 1 {
|
||
py / (h - 1) as f64 - 0.5
|
||
} else {
|
||
0.0
|
||
};
|
||
(wx, lat_frac * (std::f64::consts::PI * r * 1000.0))
|
||
}
|
||
_ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64),
|
||
}
|
||
}
|
||
|
||
/// Absolute world metres → fractional working-grid pixel — the inverse of
|
||
/// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/
|
||
/// [`derive_orbital_at_metres`] compute inline for their own `(px, py)`
|
||
/// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather
|
||
/// than duplicated a third time, for the river course inventor's Stage A
|
||
/// valley-seeking control path, which needs bilinear `elev_pct` reads at
|
||
/// arbitrary world positions without paying for a full `DistrictProfile`
|
||
/// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per
|
||
/// candidate"). Returns `(px, py)` only — callers that also need latitude
|
||
/// (temperature-sensitive derivation) still compute it themselves; the course
|
||
/// inventor's Stage A elevation proxy has no use for latitude.
|
||
pub(crate) fn world_m_to_pixel(
|
||
wx: f64,
|
||
wy: f64,
|
||
w: usize,
|
||
h: usize,
|
||
radius_km: Option<f64>,
|
||
) -> (f64, f64) {
|
||
match radius_km {
|
||
Some(r_km) if r_km > 0.0 => {
|
||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||
let px = (wx / circumference_m).rem_euclid(1.0) * w as f64;
|
||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
|
||
let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64;
|
||
(px, py)
|
||
}
|
||
_ => {
|
||
let dm = scale::DISTRICT_M as f64;
|
||
let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64);
|
||
let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64);
|
||
(px, py)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public derivation function
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Derive a `DistrictProfile` for the survey cell at `pos` on the D-256(b)
|
||
/// survey raster — a thin wrapper over the shared [`derive_at_metres_with_riparian`]
|
||
/// core at the survey cell's centre world metres (D-256(c): "one derive core,
|
||
/// two position sets").
|
||
///
|
||
/// Pure (no I/O, no side effects). Inputs are the body's params and the
|
||
/// pre-computed `TerrainAnalysis` from Layer 1.
|
||
///
|
||
/// `grid_cells_per_district` controls how many heightmap cells map to one survey
|
||
/// cell; default is 8 (`scale::HEIGHTMAP_CELLS_PER_DISTRICT`, at the standard
|
||
/// working grid `heightmap::GRID_W` × `heightmap::GRID_H`, T-1170) — several
|
||
/// thousand cells/body, within the D-203 budget.
|
||
///
|
||
/// ## Survey-cell-centre position (D-256(b))
|
||
///
|
||
/// The centre pixel is the midpoint of the cell's covering pixel block,
|
||
/// `8·rx + 3.5` in the interior (the geometrically correct centre of the
|
||
/// 8-point bilinear sample lattice — kept exactly as before this ticket, NOT
|
||
/// an error) — clamped at the grid edges where the block is truncated
|
||
/// (`saturating_add`/`.min(w)`/`.min(h)`), matching this function's
|
||
/// pre-D-256 pixel-range math exactly. That pixel is converted to world
|
||
/// metres via [`pixel_to_world_m`], then handed to the shared core with
|
||
/// `min_wavelength_m = 0.0` (no octave cutoff, matching [`derive_district`]'s
|
||
/// own default).
|
||
///
|
||
/// ## Region baseline + latitude (D-256(c))
|
||
///
|
||
/// The core derives its own latitude and region-climate baseline from the
|
||
/// survey-cell-centre world metres — this is what auto-fixes the two latent
|
||
/// same-position divergences the D-256 investigation found: one inverse
|
||
/// mapping computed once cannot disagree with itself (the former three
|
||
/// inconsistent latitudes collapse to one), and the region baseline now
|
||
/// floor-divides the TRUE world metres instead of keying off the pseudo-grid
|
||
/// index (the former body-uniform region-(0,0) climate collapse).
|
||
///
|
||
/// ## Parameters
|
||
///
|
||
/// - `body_id` — the body's string identifier; required for the D-243 §4 climate
|
||
/// edge-fuzz warp domain separation (distinct bodies get distinct warps).
|
||
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
|
||
/// if a neighbour region is missing it is derived on the fly. Build with
|
||
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
|
||
/// - `basin_direction` — the true L1 D8 thalweg direction for this cell.
|
||
/// `basin_direction` is an inert pass-through field (nothing in the
|
||
/// derivation reads it — the D-256 ruling's proof), so it is applied via a
|
||
/// post-call field override on the core's returned profile rather than
|
||
/// threaded through the core itself.
|
||
/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c),
|
||
/// consulted for the riparian point test via
|
||
/// [`river_course::near_perennial_water_at`] (edges near this cell
|
||
/// invented on demand, the same pure function the window path uses).
|
||
/// `None` when no river network is available (e.g. a body with no Layer-1
|
||
/// drainage pass, or a caller that predates T-1168) — the riparian signal
|
||
/// degrades to `false` in that case, matching the pre-T-1168 hardcoded
|
||
/// default exactly, never a panic or an error.
|
||
pub fn derive_district_profile(
|
||
seed: SeedChain,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
pos: SurveyCellPos,
|
||
grid_cells_per_district: usize,
|
||
climate: &ClimateConstants,
|
||
body_id: &str,
|
||
region_cache: &BTreeMap<RegionPos, RegionProfile>,
|
||
basin_direction: BasinDirection,
|
||
river_network: Option<&RiverNetwork>,
|
||
) -> DistrictProfile {
|
||
let SurveyCellPos(rx, ry) = pos;
|
||
let w = ta.w;
|
||
let h = ta.h;
|
||
let gcpr = grid_cells_per_district.max(1);
|
||
|
||
// D-256(b): the survey cell's covering pixel block, clamped at the grid
|
||
// edges — UNCHANGED from the pre-D-256 cell-aggregate-centre math (only
|
||
// the position TYPE changed, not the arithmetic).
|
||
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);
|
||
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);
|
||
|
||
// T-1168 Ruling 4b: batch-path riparian signal — edges near this
|
||
// cell invented on demand via the SAME pure function the window
|
||
// path uses. `river_network.is_none()` degrades to `false` (see this
|
||
// function's doc), never a panic.
|
||
let near_perennial_water = river_network
|
||
.map(|rn| {
|
||
river_course::near_perennial_water_at(
|
||
seed,
|
||
ta,
|
||
body_params,
|
||
rn,
|
||
(world_x_m, world_y_m),
|
||
scale::DISTRICT_M as f64,
|
||
0.0, // batch path — no octave cutoff, matches this function's own default
|
||
)
|
||
})
|
||
.unwrap_or(false);
|
||
|
||
// D-256(c): the shared core, at the survey-cell-centre world metres, with
|
||
// the pre-built region cache (batch performance — thousands of cells
|
||
// sharing one derived region set).
|
||
let mut profile = derive_at_metres_with_riparian(
|
||
seed,
|
||
body_id,
|
||
body_params,
|
||
ta,
|
||
world_x_m,
|
||
world_y_m,
|
||
climate,
|
||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||
near_perennial_water,
|
||
Some(region_cache),
|
||
);
|
||
|
||
// D-256(c) binding requirement 1: basin_direction is inert to every other
|
||
// field's derivation (proven in the D-256 ruling) — a post-call override
|
||
// with the true L1 D8 value is exactly equivalent to threading it through
|
||
// the core, and keeps the core itself free of a field only the batch
|
||
// path can supply.
|
||
profile.basin_direction = basin_direction;
|
||
profile
|
||
}
|
||
|
||
/// Build the climate + morphology fields of a `DistrictProfile` from its three
|
||
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
|
||
/// every position-derivation caller reaches (D-256(c): `derive_district_profile`'s
|
||
/// survey-cell-centre position and `derive_district`'s exact district position
|
||
/// both route through it via [`derive_at_metres_with_riparian`]). Pure (T-1024,
|
||
/// D-239 §2 / D-240).
|
||
///
|
||
/// ## Region baseline parameter (D-243 §3, T-1078)
|
||
///
|
||
/// `region_baseline_c` is the edge-fuzz-blended region mean temperature from
|
||
/// [`crate::atlas::region_profile::region_baseline_at_district`].
|
||
///
|
||
/// - When `Some(baseline)`: district temperature is derived as a **modulation**
|
||
/// of the baseline via [`derive_district_temperature_c`] — elevation lapse
|
||
/// only (latitude/greenhouse/nudge already in the baseline). This is the
|
||
/// D-243 §3 correct two-phase path.
|
||
/// - When `None`: falls back to the legacy single-phase [`derive_temperature_c`]
|
||
/// for backward compatibility (used by unit tests and paths where no region
|
||
/// layer has run yet).
|
||
///
|
||
/// The distinction is important for edge fuzz: only the two-phase path produces
|
||
/// a continuous, warp-perturbed temperature gradient. The single-phase path
|
||
/// still satisfies D-240 but without edge fuzz.
|
||
///
|
||
/// ## Vegetation patchiness (T-1162)
|
||
///
|
||
/// `world_x_m`/`world_y_m`/`min_wavelength_m` feed
|
||
/// [`crate::atlas::vegetation_invention::moisture_perturb_q`] — the nature-layer
|
||
/// patchiness field that perturbs `moisture_q` (see that module's docs for the
|
||
/// full design rationale) before precipitation/glaciation/vegetation are
|
||
/// derived from it, so the three stay in lockstep. `world_x_m == 0.0 &&
|
||
/// world_y_m == 0.0 && min_wavelength_m == 0.0` is NOT a special "disabled"
|
||
/// case — the origin is a legal world position — vegetation patchiness is
|
||
/// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the
|
||
/// coast invention's own always-on posture (the ceiling being zero, not a
|
||
/// separate flag, is what turns it off on airless/dry bodies).
|
||
///
|
||
/// ## Riparian signal (T-1168, Ruling 4a-4d)
|
||
///
|
||
/// `near_perennial_water` is the T-1168 riparian point test result for
|
||
/// `(world_x_m, world_y_m)` — a separate boolean signal into
|
||
/// [`derive_vegetation`], computed by the caller (window path: distance to
|
||
/// the retained `Layer1Output`'s invented courses; batch path: distance to
|
||
/// on-demand-invented courses for nearby edges — both via the SAME pure
|
||
/// [`crate::atlas::river_course::near_perennial_water`] function). **This
|
||
/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it
|
||
/// is threaded straight through to `derive_vegetation` unchanged, after every
|
||
/// moisture/temperature/morphology field above it has already been resolved.
|
||
///
|
||
/// ## Lake sourcing (T-1184, D-227 amendment (4); T-1188 depth band)
|
||
///
|
||
/// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`]
|
||
/// verdict for this position — threaded straight into
|
||
/// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing
|
||
/// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's
|
||
/// depth-band quantization, threaded straight onto the output profile
|
||
/// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s
|
||
/// doc for what it represents and why lakes need it where oceans don't).
|
||
/// Computed by the caller (not here) for the same reason `near_perennial_water`
|
||
/// is: this function stays free of `TerrainAnalysis`/pixel-position concerns,
|
||
/// taking only the already-reduced per-position signals every other field
|
||
/// here consumes.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_district_profile(
|
||
seed: SeedChain,
|
||
body_params: &BodyParams,
|
||
climate: &ClimateConstants,
|
||
slope_q: i32,
|
||
elev_q: i32,
|
||
ocean_fraction_q: i32,
|
||
region_baseline_c: Option<f32>,
|
||
basin_direction: BasinDirection,
|
||
world_x_m: f64,
|
||
world_y_m: f64,
|
||
min_wavelength_m: f64,
|
||
near_perennial_water: bool,
|
||
lake_from_hydrology: bool,
|
||
lake_margin_q: i32,
|
||
) -> DistrictProfile {
|
||
let tectonic_class = derive_tectonic_class(body_params);
|
||
|
||
// District-local BodyParams: elevation_km comes from the district's own
|
||
// elev_q (0–100 scaled to the body's elevation span). latitude_deg
|
||
// is already set per-district by the caller. Per-cell refinement at ChunkContext.
|
||
let district_climate_params = BodyParams {
|
||
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
|
||
..body_params.clone()
|
||
};
|
||
|
||
// Temperature derivation: two-phase (D-243 §3) when a region baseline is
|
||
// available; single-phase legacy fallback otherwise.
|
||
let temperature_c = match region_baseline_c {
|
||
Some(baseline) => {
|
||
// Two-phase path: apply only elevation lapse on top of the
|
||
// edge-fuzz-blended region baseline (D-243 §3 / D-239 §2 split).
|
||
// slope_aspect_deg = 0.0: reserved, not yet available (Q-105).
|
||
derive_district_temperature_c(Some(baseline), &district_climate_params, climate, 0.0)
|
||
}
|
||
None => {
|
||
// Legacy single-phase path: derive temperature from scratch.
|
||
// D-240: body-scoped seed for the deterministic per-body nudge.
|
||
let body_seed = seed.seed();
|
||
derive_temperature_c(&district_climate_params, climate, body_seed)
|
||
}
|
||
};
|
||
let base_moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate);
|
||
|
||
// T-1162: vegetation-patchiness field perturbs the moisture INPUT (see
|
||
// `vegetation_invention` module docs for the full design rationale) —
|
||
// this is what turns a uniform per-district class tint into massifs at
|
||
// Region scale resolving to distinct woods/copses/clearings at
|
||
// District/Quarter. Applied uniformly at every derivation path
|
||
// (on-demand, batch, orbital) since all three route through this shared
|
||
// classification tail.
|
||
let veg_envelope = crate::atlas::vegetation_invention::vegetation_envelope(body_params);
|
||
let moisture_perturb = crate::atlas::vegetation_invention::moisture_perturb_q(
|
||
&veg_envelope,
|
||
seed.seed(),
|
||
world_x_m,
|
||
world_y_m,
|
||
min_wavelength_m,
|
||
);
|
||
let moisture_q = (base_moisture_q + moisture_perturb).clamp(0, 100);
|
||
|
||
// 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);
|
||
|
||
// 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,
|
||
slope_q,
|
||
elev_q,
|
||
ocean_fraction_q,
|
||
moisture_q,
|
||
lake_from_hydrology,
|
||
);
|
||
|
||
// Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is
|
||
// the caller-computed riparian point test result — see this function's
|
||
// doc for the full threading contract (Ruling 4a-4d). 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,
|
||
near_perennial_water,
|
||
open_water,
|
||
);
|
||
|
||
DistrictProfile {
|
||
morphology_zone,
|
||
tectonic_class,
|
||
glaciation_grade,
|
||
precipitation_class,
|
||
slope_q,
|
||
elev_q,
|
||
ocean_fraction_q,
|
||
lake_margin_q,
|
||
river_threshold,
|
||
temperature_c,
|
||
moisture_q,
|
||
vegetation_class,
|
||
basin_direction,
|
||
}
|
||
}
|
||
|
||
/// On-demand 2 km district profile (D-243 §2, T-1077) — the corrected carrier.
|
||
///
|
||
/// Maps the district to a fractional heightmap position via the body radius (the
|
||
/// elastic seam, D-204), bilinearly interpolates the Layer-1 terrain (the
|
||
/// continental *envelope*), then composes the adaptive detail-scatter
|
||
/// ([`crate::atlas::detail_scatter`]) for the mid-scale relief the heightmap is
|
||
/// too coarse to carry. `body_radius_km = None` (e.g. tiny test bodies) falls back
|
||
/// to direct heightmap indexing.
|
||
///
|
||
/// Pure and deterministic (D-227/D-010): a function of
|
||
/// `(seed, body_id, body_params, terrain, district_pos)`; the f64 scatter is
|
||
/// quantised to integer `slope_q`/`elev_q` at the decision boundary.
|
||
///
|
||
/// ## Region baseline (D-243 §3/§4, T-1078)
|
||
///
|
||
/// The district temperature is derived as a **modulation** of the edge-fuzz-blended
|
||
/// region baseline ([`region_profile::region_baseline_at_district`]). No pre-built
|
||
/// region cache is required here — the on-demand path derives the four surrounding
|
||
/// region baselines on-the-fly (pure, deterministic, cheap: four `derive_region_baseline_c`
|
||
/// calls). For batch derivation of many districts use [`derive_all_districts`], which
|
||
/// builds a region cache once per body.
|
||
///
|
||
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
|
||
pub fn derive_district(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
district_pos: DistrictPos,
|
||
climate: &ClimateConstants,
|
||
) -> DistrictProfile {
|
||
let (dx, dy) = district_pos;
|
||
let dm = scale::DISTRICT_M as f64;
|
||
// Thin wrapper (T-1149): quantize DistrictPos -> world metres, then hand off
|
||
// to the metres-addressable interior. `min_wavelength_m = 0.0` = no octave
|
||
// cutoff, preserving this function's output byte-for-byte.
|
||
derive_at_metres(
|
||
seed,
|
||
body_id,
|
||
body_params,
|
||
ta,
|
||
dx as f64 * dm,
|
||
dy as f64 * dm,
|
||
climate,
|
||
0.0,
|
||
&[],
|
||
)
|
||
}
|
||
|
||
/// The metres-addressable derivation interior (T-1149, zoom ladder keystone,
|
||
/// design doc §2/§8 step 1) — `derive_district`'s former inline body, extracted
|
||
/// so a fractional-metres position (not just an integer [`DistrictPos`]) can be
|
||
/// classified. This is what makes the quarter rung (512 m spacing, T-1150)
|
||
/// possible without a second derivation pipeline: same function, finer step.
|
||
///
|
||
/// `wx`/`wy` are absolute world metres — NOT required to fall on a district-grid
|
||
/// multiple of [`scale::DISTRICT_M`]; any fractional position is legal.
|
||
///
|
||
/// `min_wavelength_m` (§2): forwarded to the `terrain_detail` octave sum inside
|
||
/// [`invent_primitives`] — octaves finer than this cutoff are truncated. `0.0`
|
||
/// = no cutoff = [`derive_district`]'s existing behavior.
|
||
///
|
||
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
|
||
///
|
||
/// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses
|
||
/// (already culled to the caller's neighbourhood — the window path's own
|
||
/// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the
|
||
/// riparian point test via [`river_course::near_perennial_water`]. Passing
|
||
/// `&[]` (the common case for a position far from any river, and every
|
||
/// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded
|
||
/// `false` default — byte-identical output for every caller that doesn't
|
||
/// thread real course geometry through. This is a PRE-INVENTED slice, not a
|
||
/// `RiverNetwork` — this function is called once per window CELL (thousands
|
||
/// of times per window), so re-inventing courses on every call here (rather
|
||
/// than once per window) would be the exact per-candidate-derive cost this
|
||
/// whole batch's Ruling 3b was written to avoid.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn derive_at_metres(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
wx: f64,
|
||
wy: f64,
|
||
climate: &ClimateConstants,
|
||
min_wavelength_m: f64,
|
||
nearby_courses: &[river_course::InventedCourse],
|
||
) -> DistrictProfile {
|
||
// T-1168 Ruling 4a: the riparian point test against the caller-supplied
|
||
// (already-culled) course slice — the SAME pure predicate the batch path
|
||
// uses via `near_perennial_water_at`. Computed here, BEFORE the core call,
|
||
// so the core itself never touches course geometry (D-256(c) binding
|
||
// requirement 2: a naive wrapper passing `&[]` internally would silently
|
||
// regress every riverside cell to `false` — this public signature and its
|
||
// byte-behavior are unchanged by the D-256 extraction).
|
||
let near_perennial_water = river_course::near_perennial_water((wx, wy), nearby_courses);
|
||
derive_at_metres_with_riparian(
|
||
seed,
|
||
body_id,
|
||
body_params,
|
||
ta,
|
||
wx,
|
||
wy,
|
||
climate,
|
||
min_wavelength_m,
|
||
near_perennial_water,
|
||
None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction
|
||
)
|
||
}
|
||
|
||
/// The D-256(c) shared derive core — `derive_at_metres`'s former inline body,
|
||
/// extracted so [`derive_district_profile`] can become a thin wrapper over the
|
||
/// SAME metres-addressable derivation instead of re-implementing it. Private:
|
||
/// the only two sanctioned callers are `derive_at_metres` (which computes
|
||
/// `near_perennial_water` from its public `nearby_courses` slice exactly as
|
||
/// before, and passes `None` for `region_cache` — an on-the-fly region
|
||
/// baseline derivation, matching its pre-extraction behavior byte-for-byte)
|
||
/// and `derive_district_profile` (which computes `near_perennial_water` via
|
||
/// `near_perennial_water_at`, the on-demand course inventor, and passes its
|
||
/// pre-built per-body `region_cache` for the same performance reason the
|
||
/// batch path built one in the first place — thousands of district calls
|
||
/// sharing one derived region set rather than each re-deriving up to 4
|
||
/// baselines).
|
||
///
|
||
/// `basin_direction` on the returned profile is always [`BasinDirection::default`]
|
||
/// (North) here — the caller-supplied true L1 D8 value, when available, is
|
||
/// applied as a post-call field override (D-256(c) binding requirement 1: the
|
||
/// field is inert to every other field's derivation, proven in the D-256
|
||
/// ruling, so an override after the fact is exactly equivalent to threading it
|
||
/// through).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn derive_at_metres_with_riparian(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
wx: f64,
|
||
wy: f64,
|
||
climate: &ClimateConstants,
|
||
min_wavelength_m: f64,
|
||
near_perennial_water: bool,
|
||
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
|
||
) -> DistrictProfile {
|
||
// World metres -> fractional heightmap pixel + latitude. Mirrors
|
||
// `derive_district`'s former inline mapping exactly, just keyed on
|
||
// fractional (wx, wy) instead of an integer DistrictPos scaled up first.
|
||
let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km {
|
||
Some(r_km) if r_km > 0.0 => {
|
||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||
// Longitude wraps; (0,0) sits at lon 0 / the equator.
|
||
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
|
||
// Latitude: equator at py = h/2, clamped at the poles.
|
||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // −0.5 = N pole, +0.5 = S
|
||
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
|
||
(px, py, wx, wy, -lat_frac * 180.0)
|
||
}
|
||
_ => {
|
||
// No radius: the working grid IS the metre grid (tiny test bodies),
|
||
// 1 DISTRICT_M = 1 heightmap pixel — the inverse of
|
||
// `pixel_to_world_m`'s own no-radius convention.
|
||
let dm = scale::DISTRICT_M as f64;
|
||
let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64);
|
||
let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64);
|
||
let lat_deg = if ta.h > 1 {
|
||
90.0 - (py / (ta.h - 1) as f64) * 180.0
|
||
} else {
|
||
0.0
|
||
};
|
||
(px, py, px * dm, py * dm, lat_deg)
|
||
}
|
||
};
|
||
|
||
let params = BodyParams {
|
||
latitude_deg: lat_deg,
|
||
..body_params.clone()
|
||
};
|
||
|
||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this
|
||
// position. `region_cache` is `None` for the on-demand caller
|
||
// (`derive_at_metres`, derives the four surrounding region baselines
|
||
// directly — pure, deterministic, cheap) or `Some` for the batch caller
|
||
// (`derive_district_profile`, reuses its pre-built per-body cache).
|
||
// `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.
|
||
//
|
||
// `region_baseline_at_district` keys on the CONTAINING DistrictPos (via
|
||
// `rem_euclid` inside `region_profile.rs`), not on fractional metres — so a
|
||
// sub-district sample (e.g. a quarter, T-1150) floor-divides down to its
|
||
// containing district here. This is D-243's design intent (climate is a
|
||
// district-tier field, R2/zoom-ladder-design-doc §9): temperature is a hard
|
||
// step at every district boundary at every rung, by construction — it does
|
||
// not refine continuously the way elevation/slope do under a finer
|
||
// min_wavelength_m.
|
||
//
|
||
// D-256(c): this is also what auto-fixes the batch path's former
|
||
// region-(0,0) collapse — `derive_district_profile` now reaches this same
|
||
// floor-divide on its own survey-cell-centre world metres instead of
|
||
// keying off a pseudo-grid index.
|
||
let district_pos: DistrictPos = (
|
||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||
);
|
||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||
seed.seed(),
|
||
body_id,
|
||
district_pos,
|
||
¶ms,
|
||
climate,
|
||
seed,
|
||
region_cache,
|
||
);
|
||
|
||
// 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,
|
||
min_wavelength_m,
|
||
);
|
||
|
||
// T-1184: the settled-hydrology lake test, sampled at the SAME (px, py)
|
||
// fractional working-grid position every other envelope field here reads
|
||
// — the continuous filled-surface comparison (D-227 amendment (4)).
|
||
// T-1188: the same sample also yields the depth-band tone source
|
||
// (`lake_margin_q`) lake shorelines were missing.
|
||
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
|
||
|
||
build_district_profile(
|
||
seed,
|
||
¶ms,
|
||
climate,
|
||
prims.slope_q,
|
||
prims.elev_q,
|
||
prims.ocean_fraction_q,
|
||
region_baseline_c,
|
||
BasinDirection::default(),
|
||
world_x_m,
|
||
world_y_m,
|
||
min_wavelength_m,
|
||
near_perennial_water,
|
||
lake_from_hydrology,
|
||
lake_margin_q,
|
||
)
|
||
}
|
||
|
||
/// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the
|
||
/// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`]
|
||
/// entirely — **no coastline warp, no detail-scatter octave sum, no classification
|
||
/// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district`
|
||
/// only — bilinear blend of 4 region baselines, no `invent_primitives`, no
|
||
/// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung
|
||
/// and coarser) sits below `detail_scatter`'s own octave floor
|
||
/// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of
|
||
/// magnitude finer than a region), so the invented terrain has nothing left to
|
||
/// contribute at this spacing; calling it would burn cycles synthesizing detail
|
||
/// no orbital pixel can resolve. What DOES vary at orbital spacing is the
|
||
/// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental
|
||
/// shape) and the **region climate baseline** — this function samples exactly
|
||
/// those two, nothing else.
|
||
///
|
||
/// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation),
|
||
/// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its
|
||
/// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit)
|
||
/// — no octave sum, no coast-warp trig, no character/envelope computation. See
|
||
/// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the
|
||
/// measured per-cell figure this claim rests on.
|
||
///
|
||
/// Produces the SAME six-field tail every other rung produces (`morphology_zone`,
|
||
/// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`)
|
||
/// by routing the bilinear-only primitives through the same
|
||
/// [`build_district_profile`] classification tail every other rung uses — one
|
||
/// classification pipeline, never a second orbital-only decision tree (D-227:
|
||
/// classification thresholds don't get a coarse-rung variant any more than the
|
||
/// quarter rung got its own "quarter mode" thresholds, design doc §6).
|
||
///
|
||
/// **R2 (stepped fields):** `moisture_q`/`temperature_c`/`morphology_zone`/etc.
|
||
/// are exactly as stepped here as at every other rung — `region_baseline_at_district`
|
||
/// floor-divides to the containing `DistrictPos` regardless of caller spacing (see
|
||
/// [`derive_at_metres`]'s own doc on this), so this function does not make
|
||
/// temperature MORE continuous at orbital scale; it inherits the same
|
||
/// district-tier step the design doc documents as permanent, by construction.
|
||
///
|
||
/// **`slope_q` is fixed at 0`** — the bilinear-only envelope carries no
|
||
/// per-cell slope signal at orbital spacing (`ta.slope_deg` is a district-scale
|
||
/// proxy; sampling it here would imply a precision the coarse envelope doesn't
|
||
/// have). `slope_q` only affects morphology gates 3–6 (FjordWall/CliffCoast/
|
||
/// BraidedDelta/DuneStrand) and the invented-primitives `carve` term this
|
||
/// function never runs — passing 0 means those gates fall through to their
|
||
/// low-slope alternatives, which is the correct behavior for a coastline sampled
|
||
/// at coarser-than-detail-scatter resolution (no invented ruggedness to report).
|
||
pub fn derive_orbital_at_metres(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
wx: f64,
|
||
wy: f64,
|
||
climate: &ClimateConstants,
|
||
) -> DistrictProfile {
|
||
// Same world-metres -> fractional heightmap pixel + latitude mapping
|
||
// derive_at_metres uses — the envelope is the SAME TerrainAnalysis grid at
|
||
// every rung, only the sampling density differs.
|
||
let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km {
|
||
Some(r_km) if r_km > 0.0 => {
|
||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
|
||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
|
||
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
|
||
(px, py, wx, wy, -lat_frac * 180.0)
|
||
}
|
||
_ => {
|
||
let dm = scale::DISTRICT_M as f64;
|
||
let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64);
|
||
let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64);
|
||
let lat_deg = if ta.h > 1 {
|
||
90.0 - (py / (ta.h - 1) as f64) * 180.0
|
||
} else {
|
||
0.0
|
||
};
|
||
(px, py, px * dm, py * dm, lat_deg)
|
||
}
|
||
};
|
||
|
||
let params = BodyParams {
|
||
latitude_deg: lat_deg,
|
||
..body_params.clone()
|
||
};
|
||
|
||
// The envelope only — no coast-warp, no detail-scatter. This is exactly
|
||
// `invent_primitives`' step-1 "driver tier" raw bilinear reads, promoted to
|
||
// be the FINAL primitives instead of a one-step-stale input to invention.
|
||
let elev_q =
|
||
((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100);
|
||
let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0)
|
||
.round() as i32)
|
||
.clamp(0, 100);
|
||
// No invented ruggedness at orbital spacing (see the function doc's note
|
||
// on slope_q) — the envelope carries no per-cell slope signal this coarse.
|
||
let slope_q = 0;
|
||
|
||
let district_pos: DistrictPos = (
|
||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||
);
|
||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||
seed.seed(),
|
||
body_id,
|
||
district_pos,
|
||
¶ms,
|
||
climate,
|
||
seed,
|
||
None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres
|
||
);
|
||
|
||
// T-1184: same continuous filled-surface comparison every rung samples,
|
||
// at the orbital rung's own (px, py) — lake edges refine at Region
|
||
// spacing exactly as they do at every finer rung (D-227 amendment (4)).
|
||
// T-1188: the same sample also yields the depth-band tone source.
|
||
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
|
||
|
||
build_district_profile(
|
||
seed,
|
||
¶ms,
|
||
climate,
|
||
slope_q,
|
||
elev_q,
|
||
ocean_fraction_q,
|
||
region_baseline_c,
|
||
BasinDirection::default(),
|
||
world_x_m,
|
||
world_y_m,
|
||
// T-1162: vegetation patchiness's massif tier is NEVER cutoff-gated
|
||
// (see vegetation_invention module docs) and is cheap (two small fBm
|
||
// sums, not the invent_primitives bilinear+warp+scatter pipeline this
|
||
// function deliberately skips) — passing 0.0 here means the orbital
|
||
// path samples the SAME uncut massif+texture field derive_at_metres
|
||
// would at min_wavelength_m=0.0, preserving cross-rung coherence for
|
||
// the vegetation verdict even though slope/elevation stay
|
||
// envelope-only at this rung.
|
||
0.0,
|
||
// T-1168 Ruling 4e/5a: NO windowed course invention at Region
|
||
// granularity (`layer_proxy::build_courses_for_window` early-returns
|
||
// for `WindowGranularity::Region` — the whole-body skeleton path
|
||
// draws Region-rung rivers instead, Ruling 5a). Always `false` here
|
||
// — honest, not a gap: even if courses existed at Region, the 1-3 m
|
||
// riparian band is many orders of magnitude below Region's ~205 km
|
||
// spacing and could never fire (Ruling 4e).
|
||
false,
|
||
lake_from_hydrology,
|
||
lake_margin_q,
|
||
)
|
||
}
|
||
|
||
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
|
||
/// Columns wrap (equirectangular); rows clamp at the poles.
|
||
///
|
||
/// `pub(crate)` (T-1170): also the elevation-proxy read the river course
|
||
/// inventor's Stage A valley-seeking control path uses
|
||
/// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff
|
||
/// the coast warp already makes (`invent_primitives`'s step 3), reused rather
|
||
/// than re-implemented so the two invention fields can never silently drift
|
||
/// on interpolation semantics.
|
||
pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||
if w == 0 || h == 0 {
|
||
return 0.0;
|
||
}
|
||
let x0 = px.floor();
|
||
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
|
||
let tx = (px - x0) as f32;
|
||
let ty = (py - y0) as f32;
|
||
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
|
||
let ix1 = (ix0 + 1) % w;
|
||
let iy0 = (y0 as usize).min(h - 1);
|
||
let iy1 = (iy0 + 1).min(h - 1);
|
||
let v00 = field[iy0 * w + ix0];
|
||
let v10 = field[iy0 * w + ix1];
|
||
let v01 = field[iy1 * w + ix0];
|
||
let v11 = field[iy1 * w + ix1];
|
||
let a = v00 + (v10 - v00) * tx;
|
||
let b = v01 + (v11 - v01) * tx;
|
||
a + (b - a) * ty
|
||
}
|
||
|
||
/// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin
|
||
/// normalization: a basin whose own max depth is at or below this floor
|
||
/// (in the same `[0.0, 1.0]` normalized elevation-fraction units as
|
||
/// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q`
|
||
/// reads `0` everywhere in it rather than dividing by a near-zero
|
||
/// denominator (which would amplify heightmap sampling noise into an
|
||
/// artificial, meaningless gradient). `1e-5` is ~17× smaller than the
|
||
/// smallest genuinely-flooded per-cell depth observed in the T-1188
|
||
/// calibration survey (below), well inside "this basin has no real depth
|
||
/// signal at this heightmap resolution" territory.
|
||
const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5;
|
||
|
||
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
|
||
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
|
||
/// `(is_lake, lake_margin_q)`:
|
||
///
|
||
/// - `is_lake` — `true` when a bilinear sample of the settled filled-surface
|
||
/// field strictly exceeds a bilinear sample of the original elevation at
|
||
/// the SAME fractional working-grid position — the continuous comparison
|
||
/// that makes lake edges refine with rung exactly like coastlines, rather
|
||
/// than projecting `HydrologyResult.basins[*].cells` membership as a
|
||
/// discrete, non-refining lookup (explicitly rejected, see this function's
|
||
/// callers' docs). `false` when `ta.hydrology` is `None` (no solve
|
||
/// available for this analysis — every caller must already treat `false`
|
||
/// here as "fall through to the `ocean_fraction_q` heuristic", never as an
|
||
/// error).
|
||
/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to
|
||
/// shade); otherwise the settled depth `(filled - original)` at this
|
||
/// position, normalized against THIS BASIN's own maximum depth
|
||
/// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME
|
||
/// position — see that field's doc), then quantized to `[0, 100]`.
|
||
///
|
||
/// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding,
|
||
/// T-1188 round 2):** the original design used one fixed absolute-depth
|
||
/// ceiling calibrated against a single body's p90 depth. Two compounding
|
||
/// effects made that read visually flat on real lakes: (1) a linear
|
||
/// absolute scale compresses the bulk of any MORE-skewed basin's depth
|
||
/// distribution into single-digit values; (2) heightmap resolution
|
||
/// (~40–78 km/px) means within-basin absolute-depth variation is often
|
||
/// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread
|
||
/// under 0.0003 normalized units — genuinely below what a fixed ceiling
|
||
/// calibrated for a DIFFERENT body's deeper lakes could ever resolve).
|
||
/// Normalizing against each basin's own max depth fixes both: every
|
||
/// non-degenerate basin uses the full 0–100 range on ITS OWN terms,
|
||
/// independent of the body's absolute elevation scale or any other
|
||
/// basin's depth. A basin at or below
|
||
/// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0`
|
||
/// everywhere (a genuinely uniform shallow pond shades flat — honest,
|
||
/// not forced) rather than dividing by ~zero.
|
||
///
|
||
/// This is the continuous tone source lake shorelines were missing —
|
||
/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes
|
||
/// sit above sea level; `ta.ocean_mask` never fires there), so every
|
||
/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast,
|
||
/// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally
|
||
/// unreachable at a lake edge even though the underlying position
|
||
/// sampling refines correctly with rung (verified: a shoreline-crossing
|
||
/// sweep at district/quarter/block spacing lands on the exact same
|
||
/// continuous world-metres crossing at every rung, and a 10 m fine sweep
|
||
/// confirms sub-block precision — the T-1188 hypothesis (a) positional
|
||
/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b))
|
||
/// without touching that already-correct positional refinement.
|
||
///
|
||
/// All three fields (`elevation`, `filled`, `basin_max_depth`) are sampled
|
||
/// via the SAME `bilinear` helper `ocean_fraction_q`'s own
|
||
/// `ta.elev_pct`/`ta.ocean_mask` reads already use at every derive-core call
|
||
/// site (T-1178/T-1154's per-cell rate numbers already include equivalent-
|
||
/// cost sampling in the measured per-rung budget — one more bilinear sample
|
||
/// is not a new cost category, per the workshop's own pipeline-slot ruling).
|
||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
|
||
let Some(h) = ta.hydrology.as_ref() else {
|
||
return (false, 0);
|
||
};
|
||
let filled = bilinear(&h.filled, ta.w, ta.h, px, py);
|
||
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
|
||
let is_lake = filled > original;
|
||
let lake_margin_q = if is_lake {
|
||
let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py);
|
||
if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON {
|
||
0
|
||
} else {
|
||
let depth = (filled - original).max(0.0);
|
||
((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32
|
||
}
|
||
} else {
|
||
0
|
||
};
|
||
(is_lake, lake_margin_q)
|
||
}
|
||
|
||
/// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage).
|
||
fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||
if w == 0 || h == 0 {
|
||
return 0.0;
|
||
}
|
||
let x0 = px.floor();
|
||
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
|
||
let tx = (px - x0) as f32;
|
||
let ty = (py - y0) as f32;
|
||
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
|
||
let ix1 = (ix0 + 1) % w;
|
||
let iy0 = (y0 as usize).min(h - 1);
|
||
let iy1 = (iy0 + 1).min(h - 1);
|
||
let f = |r: usize, c: usize| mask[r * w + c] as i32 as f32;
|
||
let a = f(iy0, ix0) + (f(iy0, ix1) - f(iy0, ix0)) * tx;
|
||
let b = f(iy1, ix0) + (f(iy1, ix1) - f(iy1, ix0)) * tx;
|
||
a + (b - a) * ty
|
||
}
|
||
|
||
/// Eagerly derive a coarse profile grid covering the body, by direct heightmap
|
||
/// tiling (`grid_cells_per_district` cells per cell).
|
||
///
|
||
/// **Scale note (D-256(b)):** this is the *survey raster* — the coarse eager
|
||
/// grid (one cell per `gcpr` heightmap pixels, tens-to-hundreds of km) kept as
|
||
/// the Atlas zone overlay source and the L2/L3 planning input (settlement
|
||
/// placement context, believability sampling, skeleton dispatch context). The
|
||
/// **corrected 2 km carrier** the voxel chain consumes is the on-demand
|
||
/// [`derive_district`] (heightmap interpolation + detail-scatter via the
|
||
/// elastic seam). Both now route through the SAME [`derive_at_metres_with_riparian`]
|
||
/// core (D-256(c)) — one derive core, two position sets.
|
||
///
|
||
/// Returns a `BTreeMap<SurveyCellPos, DistrictProfile>` covering the full
|
||
/// heightmap at the given survey-grid resolution.
|
||
///
|
||
/// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells.
|
||
///
|
||
/// ## Region baseline (D-256(c), cache pre-build per PR #199 review)
|
||
///
|
||
/// A region-baseline cache is pre-built here on the TRUE region keys the
|
||
/// shared core looks up: each survey cell's centre world metres floor-divides
|
||
/// to its containing district (the same mapping
|
||
/// [`derive_at_metres_with_riparian`] applies internally), and that
|
||
/// district's region ±1 neighbour ring covers every key
|
||
/// [`region_profile::region_baseline_at_district`]'s edge-fuzz blend can
|
||
/// read (base + signed x/y/xy neighbours). The pre-D-256 pre-build keyed on
|
||
/// the SURVEY grid's own pseudo-coordinates (`district_to_region((rx, ry))`)
|
||
/// — the wrong key space entirely, which is what produced the body-uniform
|
||
/// region-(0,0) climate collapse; the true-key rebuild can span up to ~2048
|
||
/// distinct regions plus ring on a big body (the survey grid covers the
|
||
/// whole body surface in metres). Cache-hit and cache-miss are
|
||
/// byte-identical ([`region_profile::build_region_profile`] and the miss
|
||
/// branch in `region_baseline_at_district` compute `mean_temp_c` with the
|
||
/// same expressions — D-227 purity, guarded by the wrapper≡core agreement
|
||
/// tests), so the cache is purely the cost model: each covering region
|
||
/// derives once per body instead of four misses per survey cell.
|
||
///
|
||
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
|
||
/// warp domain separation.
|
||
///
|
||
/// `basin_dirs` is the per-SURVEY-CELL dominant D8 thalweg direction computed
|
||
/// in `run_layer1` (T-1047) — `Layer1Output::survey_basin_dirs`. It is
|
||
/// **honestly a survey-cell aggregate**: each entry votes over exactly the
|
||
/// 8×8 working-pixel block one `DistrictProfile` here summarizes, so
|
||
/// [`SurveyCellPos`] is its correct key, not merely a convenient one, and the
|
||
/// lookup below is identity (`m.get(&pos)`) — NOT a world-metres floor-divide
|
||
/// into the true D-243 district grid (that would be the wrong map: the
|
||
/// aggregate's own key space is the survey raster, never was the true grid).
|
||
/// Missing entries (edge cells with no land cells) default to
|
||
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
|
||
/// every cell gets `BasinDirection::North`.
|
||
///
|
||
/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every
|
||
/// [`derive_district_profile`] call for the batch-path riparian signal — the
|
||
/// `road_graph` precedent (`cascade.rs`'s already-unpacked
|
||
/// `layer1.river_network`, same source, same threading pattern).
|
||
pub fn derive_all_districts(
|
||
seed: SeedChain,
|
||
body_params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
grid_cells_per_district: usize,
|
||
body_id: &str,
|
||
basin_dirs: Option<&BTreeMap<SurveyCellPos, BasinDirection>>,
|
||
river_network: Option<&RiverNetwork>,
|
||
) -> BTreeMap<SurveyCellPos, DistrictProfile> {
|
||
let climate = ClimateConstants::default();
|
||
let gcpr = grid_cells_per_district.max(1);
|
||
let survey_cols = ta.w.div_ceil(gcpr) as i32;
|
||
let survey_rows = ta.h.div_ceil(gcpr) as i32;
|
||
|
||
// Pre-build the region-baseline cache on the TRUE region keys the shared
|
||
// core will look up (see this function's doc): each survey cell's centre
|
||
// world metres → containing district → region ±1 ring. Deduped via
|
||
// BTreeSet (D-010: deterministic iteration), derived once per body.
|
||
let mut covering_regions: BTreeSet<RegionPos> = BTreeSet::new();
|
||
for ry in 0..survey_rows {
|
||
for rx in 0..survey_cols {
|
||
let (wx, wy) = survey_cell_centre_world_m(
|
||
SurveyCellPos(rx, ry),
|
||
gcpr,
|
||
ta.w,
|
||
ta.h,
|
||
body_params.body_radius_km,
|
||
);
|
||
// The SAME floor-divide derive_at_metres_with_riparian applies to
|
||
// reach its region key — one mapping, never a second one.
|
||
let district_pos: DistrictPos = (
|
||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||
);
|
||
let base = scale::district_to_region(district_pos);
|
||
for ndy in -1..=1i32 {
|
||
for ndx in -1..=1i32 {
|
||
covering_regions.insert((base.0 + ndx, base.1 + ndy));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let region_cache =
|
||
region_profile::derive_regions_for_body(seed, body_params, &climate, covering_regions);
|
||
|
||
let mut out = BTreeMap::new();
|
||
for ry in 0..survey_rows {
|
||
for rx in 0..survey_cols {
|
||
let pos = SurveyCellPos(rx, ry);
|
||
// Identity lookup: basin_dirs is keyed by SurveyCellPos (see this
|
||
// function's doc) — the SAME survey cell this loop is deriving a
|
||
// profile for, no position translation needed or correct.
|
||
let basin_direction = basin_dirs
|
||
.and_then(|m| m.get(&pos).copied())
|
||
.unwrap_or_default();
|
||
let profile = derive_district_profile(
|
||
seed,
|
||
body_params,
|
||
ta,
|
||
pos,
|
||
gcpr,
|
||
&climate,
|
||
body_id,
|
||
®ion_cache,
|
||
basin_direction,
|
||
river_network,
|
||
);
|
||
out.insert(pos, profile);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The survey cell's centre pixel → world metres, per [`derive_district_profile`]'s
|
||
/// own D-256(b) `8·rx + 3.5` (clamped) convention — factored out so any
|
||
/// caller resolving a `SurveyCellPos` to a world position (e.g.
|
||
/// [`derive_all_districts`]'s `basin_dirs` lookup, or `believability::analyze`'s
|
||
/// voxel-sample chunk resolution) reaches the SAME world position the profile
|
||
/// itself is centred on, rather than a second, possibly-disagreeing mapping.
|
||
///
|
||
/// `pub` (D-256): `believability.rs` and `bin/aliveness_probe.rs` (a separate
|
||
/// crate) both need this bridge and have no `TerrainAnalysis`/`BodyParams` in
|
||
/// scope (they only see `BodyWorldState`'s cached dims + a body-params read),
|
||
/// so this takes the primitive `w`/`h`/`body_radius_km` rather than the
|
||
/// wrapper structs — the same primitives [`pixel_to_world_m`] itself takes.
|
||
pub fn survey_cell_centre_world_m(
|
||
pos: SurveyCellPos,
|
||
gcpr: usize,
|
||
w: usize,
|
||
h: usize,
|
||
body_radius_km: Option<f64>,
|
||
) -> (f64, f64) {
|
||
let SurveyCellPos(rx, ry) = pos;
|
||
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);
|
||
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;
|
||
pixel_to_world_m(px, py, w, h, body_radius_km)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::BodyHeightmap;
|
||
use crate::seed::SeedDomain;
|
||
|
||
fn test_hm() -> BodyHeightmap {
|
||
let (w, h) = (64u32, 32u32);
|
||
let n = (w * h) as usize;
|
||
let data = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32 / h as f32;
|
||
let c = (i % w as usize) as f32 / w as f32;
|
||
(r * 0.6 + c * 0.4).min(1.0)
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "test".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.3,
|
||
}
|
||
}
|
||
|
||
fn test_seed() -> SeedChain {
|
||
SeedChain::root(42).derive(SeedDomain::Body, 1)
|
||
}
|
||
|
||
fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
TerrainAnalysis::analyze(hm, &dr)
|
||
}
|
||
|
||
#[test]
|
||
fn derive_all_districts_covers_full_heightmap() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let params = BodyParams::default();
|
||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||
|
||
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
|
||
assert_eq!(districts.len(), 32, "district count mismatch");
|
||
}
|
||
|
||
/// T-1047/D-256: the `Some(basin_dirs)` threading path — supplied
|
||
/// per-SURVEY-CELL D8 directions propagate to
|
||
/// `DistrictProfile.basin_direction` by IDENTITY lookup, and survey cells
|
||
/// not in the map fall back to the default (North). `basin_dirs` mirrors
|
||
/// the SHAPE the real producer (`Layer1Output::survey_basin_dirs`,
|
||
/// `layer1::aggregate_survey_basin_dirs`) actually emits — keyed by
|
||
/// [`SurveyCellPos`], the same key space `derive_all_districts` iterates
|
||
/// — so this test exercises the production seam (survey-keyed producer →
|
||
/// identity-lookup consumer), not a map the test invents to match its own
|
||
/// lookup logic.
|
||
#[test]
|
||
fn derive_all_districts_threads_supplied_basin_directions() {
|
||
use crate::atlas::scale::BasinDirection;
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let params = BodyParams::default();
|
||
|
||
// Real SurveyCellPos keys from a baseline (None) run.
|
||
let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||
let mut keys = baseline.keys().copied();
|
||
let cell_east = keys.next().expect("at least one survey cell");
|
||
let cell_south = keys.next().expect("at least two survey cells");
|
||
let cell_unmapped = keys.next().expect("at least three survey cells");
|
||
|
||
let mut basin_dirs: BTreeMap<SurveyCellPos, BasinDirection> = BTreeMap::new();
|
||
basin_dirs.insert(cell_east, BasinDirection::East);
|
||
basin_dirs.insert(cell_south, BasinDirection::South);
|
||
|
||
let districts = derive_all_districts(
|
||
test_seed(),
|
||
¶ms,
|
||
&ta,
|
||
8,
|
||
"test_body",
|
||
Some(&basin_dirs),
|
||
None,
|
||
);
|
||
|
||
assert_eq!(districts[&cell_east].basin_direction, BasinDirection::East);
|
||
assert_eq!(
|
||
districts[&cell_south].basin_direction,
|
||
BasinDirection::South
|
||
);
|
||
// A survey cell not present in basin_dirs falls back to the default
|
||
// direction (North).
|
||
assert_eq!(
|
||
districts[&cell_unmapped].basin_direction,
|
||
BasinDirection::North
|
||
);
|
||
}
|
||
|
||
// --- derive_district (on-demand 2 km, interpolation + detail-scatter) -----
|
||
|
||
fn earth_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn derive_district_is_deterministic() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let a = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
|
||
let b = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
|
||
assert_eq!(a.elev_q, b.elev_q);
|
||
assert_eq!(a.slope_q, b.slope_q);
|
||
assert_eq!(a.morphology_zone, b.morphology_zone);
|
||
assert_eq!(a.temperature_c, b.temperature_c);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_district_radius_maps_to_latitude_climate() {
|
||
// With a body radius, equatorial vs near-polar districts get different
|
||
// temperature (the seam maps district_y → latitude). Pole = colder.
|
||
//
|
||
// T-1162: `derive_district` calls with `min_wavelength_m = 0.0` (no
|
||
// cutoff), so it now admits the extended coast-warp + sub-district
|
||
// relief octaves this ticket adds — real per-position elevation noise
|
||
// that a SINGLE probe district at each latitude is no longer immune
|
||
// to (elevation lapse feeds temperature; a single unlucky relief
|
||
// sample can swing one probe point by ~1°C, enough to flip a
|
||
// single-pair comparison at these specific hand-picked positions).
|
||
// Average temperature over several districts spanning a few hundred
|
||
// metres at each latitude band — the same zero-mean-cancellation
|
||
// technique `derivation_harness.rs`'s cross-district blend test uses
|
||
// — so the assertion tests the LATITUDE law, not one noise sample.
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
// meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away.
|
||
let merid_districts =
|
||
(std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32;
|
||
let avg_temp_c = |dy: i32| -> f32 {
|
||
let mut sum = 0.0f32;
|
||
let mut n = 0;
|
||
for dx in 0..8 {
|
||
let prof = derive_district(test_seed(), "test_body", &p, &ta, (dx, dy), &climate);
|
||
sum += prof
|
||
.temperature_c
|
||
.expect("breathable body must have a temperature");
|
||
n += 1;
|
||
}
|
||
sum / n as f32
|
||
};
|
||
let eq = avg_temp_c(0);
|
||
let hi = avg_temp_c(merid_districts / 2 - 2);
|
||
assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})");
|
||
}
|
||
|
||
#[test]
|
||
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(),
|
||
width: w,
|
||
height: h,
|
||
data: vec![0.6; (w * h) as usize],
|
||
sea_level: 0.3,
|
||
};
|
||
let ta = test_ta(&flat);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
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
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_district_no_radius_falls_back_to_direct_indexing() {
|
||
// body_radius_km = None (tiny test bodies): the district grid is the
|
||
// heightmap grid; derivation still succeeds and is deterministic.
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default() // body_radius_km: None
|
||
};
|
||
let a = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
|
||
let b = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
|
||
assert_eq!(a.elev_q, b.elev_q);
|
||
assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q));
|
||
}
|
||
|
||
// --- derive_at_metres (T-1149 keystone extraction) -------------------------
|
||
|
||
/// `derive_district` is a thin wrapper: at an exact district-aligned metre
|
||
/// position, with `min_wavelength_m = 0.0`, it must be BIT-IDENTICAL to
|
||
/// calling `derive_at_metres` directly (the acceptance criterion the
|
||
/// ticket names explicitly — existing callers see byte-identical output).
|
||
#[test]
|
||
fn derive_at_metres_matches_derive_district_at_aligned_position_zero_cutoff() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dp = (1234, -567);
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
|
||
let via_metres = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
dp.0 as f64 * dm,
|
||
dp.1 as f64 * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||
}
|
||
|
||
/// Same equivalence check on the no-radius (tiny test body) branch — the
|
||
/// two derivation paths diverge internally (fractional-pixel clamp vs.
|
||
/// direct district indexing) and must be checked independently.
|
||
#[test]
|
||
fn derive_at_metres_matches_derive_district_no_radius() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default() // body_radius_km: None
|
||
};
|
||
let dp = (20, 10);
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
|
||
let via_metres = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
dp.0 as f64 * dm,
|
||
dp.1 as f64 * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||
}
|
||
|
||
/// Field-by-field `DistrictProfile` equality — the struct has no
|
||
/// `PartialEq` derive (production type, not test-only), so the
|
||
/// bit-identical acceptance checks above compare fields directly instead
|
||
/// of adding a derive to non-test code for test convenience.
|
||
fn assert_district_profiles_eq(a: &DistrictProfile, b: &DistrictProfile) {
|
||
assert_eq!(a.morphology_zone as u8, b.morphology_zone as u8);
|
||
assert_eq!(a.tectonic_class as u8, b.tectonic_class as u8);
|
||
assert_eq!(a.glaciation_grade as u8, b.glaciation_grade as u8);
|
||
assert_eq!(a.precipitation_class as u8, b.precipitation_class as u8);
|
||
assert_eq!(a.slope_q, b.slope_q);
|
||
assert_eq!(a.elev_q, b.elev_q);
|
||
assert_eq!(a.ocean_fraction_q, b.ocean_fraction_q);
|
||
assert_eq!(a.river_threshold, b.river_threshold);
|
||
assert_eq!(a.temperature_c, b.temperature_c);
|
||
assert_eq!(a.moisture_q, b.moisture_q);
|
||
assert_eq!(a.vegetation_class as u8, b.vegetation_class as u8);
|
||
assert_eq!(a.basin_direction as u8, b.basin_direction as u8);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// T-1184 — lake sourcing from settled hydrology (D-227 amendment (4))
|
||
// -------------------------------------------------------------------
|
||
|
||
/// Bowl-shaped heightmap (high rim, low centre) — same fixture shape as
|
||
/// `hydrology_equilibrium.rs`'s own `bowl_grid` and `layer1.rs`'s
|
||
/// `bowl_hm`, reproduced locally (both are `#[cfg(test)]`-private to
|
||
/// their own modules) so this module's tests can build a
|
||
/// `TerrainAnalysis` with real hydrology attached via
|
||
/// `with_hydrology` without depending on solver-internal or
|
||
/// layer1-internal test helpers. `sea_level: 0.0` keeps the ENTIRE grid
|
||
/// dry land except the filled basin, so `ocean_fraction_q` can never
|
||
/// independently trigger the pre-existing `>= 60` heuristic — any
|
||
/// `Lake` verdict this test observes can only come from the hydrology
|
||
/// gate.
|
||
fn bowl_hm_no_ocean() -> BodyHeightmap {
|
||
let (w, h) = (64u32, 32u32);
|
||
let n = (w * h) as usize;
|
||
let cx = w as f32 / 2.0;
|
||
let cy = h as f32 / 2.0;
|
||
let max_r = cx.min(cy).max(1.0);
|
||
let data = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32;
|
||
let c = (i % w as usize) as f32;
|
||
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
|
||
0.1 + d * 0.8
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "bowl_test".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.0,
|
||
}
|
||
}
|
||
|
||
/// Real end-to-end wiring: solve hydrology on the bowl fixture, attach it
|
||
/// via `with_hydrology` (the same call `layer1::run_layer1` makes in
|
||
/// production), and confirm `derive_at_metres` classifies the bowl
|
||
/// CENTRE as `Lake` — sourced from the hydrology gate, not the
|
||
/// `ocean_fraction_q` heuristic (impossible here: `sea_level == 0.0`
|
||
/// means `ocean_fraction_q` is always 0 on this fixture).
|
||
#[test]
|
||
fn derive_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
|
||
let hm = bowl_hm_no_ocean();
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
|
||
let hydrology = crate::atlas::hydrology_equilibrium::solve(
|
||
&hm.data,
|
||
hm.width,
|
||
hm.height,
|
||
hm.sea_level,
|
||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
|
||
);
|
||
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
|
||
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default() // body_radius_km: None -> 1 DISTRICT_M = 1 px
|
||
};
|
||
let dm = scale::DISTRICT_M as f64;
|
||
// Bowl centre in pixel space is (32, 16); no-radius mode maps
|
||
// DistrictPos 1:1 onto heightmap pixels.
|
||
let prof = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
32.0 * dm,
|
||
16.0 * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
assert_eq!(
|
||
prof.morphology_zone,
|
||
MorphologyZone::Lake,
|
||
"bowl centre must classify Lake via the hydrology-sourced gate; \
|
||
ocean_fraction_q is always 0 on this fixture (sea_level=0.0), so \
|
||
this cannot be the pre-existing heuristic"
|
||
);
|
||
assert_eq!(
|
||
prof.ocean_fraction_q, 0,
|
||
"sanity: heuristic gate never fires here"
|
||
);
|
||
}
|
||
|
||
/// The same bowl centre, sampled via `derive_orbital_at_metres` (Region
|
||
/// rung) — confirms the hydrology gate is wired into BOTH derive paths
|
||
/// through the shared `build_district_profile` tail, not just the
|
||
/// district/quarter/chunk path.
|
||
#[test]
|
||
fn derive_orbital_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
|
||
let hm = bowl_hm_no_ocean();
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
|
||
let hydrology = crate::atlas::hydrology_equilibrium::solve(
|
||
&hm.data,
|
||
hm.width,
|
||
hm.height,
|
||
hm.sea_level,
|
||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
|
||
);
|
||
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
|
||
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default()
|
||
};
|
||
let dm = scale::DISTRICT_M as f64;
|
||
let prof = derive_orbital_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
32.0 * dm,
|
||
16.0 * dm,
|
||
&climate,
|
||
);
|
||
assert_eq!(
|
||
prof.morphology_zone,
|
||
MorphologyZone::Lake,
|
||
"orbital rung must also source Lake from hydrology at the bowl centre"
|
||
);
|
||
}
|
||
|
||
/// No hydrology attached (`ta.hydrology == None`, the state every
|
||
/// pre-T-1184 caller and every OTHER test in this module is already in)
|
||
/// must fall through to the pre-existing `ocean_fraction_q` heuristic
|
||
/// byte-identically — the whole point of making `with_hydrology` an
|
||
/// opt-in builder rather than changing `analyze`'s default output.
|
||
#[test]
|
||
fn derive_at_metres_without_hydrology_falls_back_to_heuristic() {
|
||
let hm = bowl_hm_no_ocean();
|
||
let ta = test_ta(&hm); // no with_hydrology call — ta.hydrology stays None
|
||
assert!(ta.hydrology.is_none());
|
||
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default()
|
||
};
|
||
let dm = scale::DISTRICT_M as f64;
|
||
let prof = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
32.0 * dm,
|
||
16.0 * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
// sea_level=0.0 on this fixture means ocean_fraction_q is always 0,
|
||
// so without hydrology the bowl centre must NOT classify Lake (no
|
||
// trigger available at all) — proving the fallback path is inert,
|
||
// not silently finding a lake some other way.
|
||
assert_ne!(
|
||
prof.morphology_zone,
|
||
MorphologyZone::Lake,
|
||
"without hydrology data, the bowl centre must not classify Lake — \
|
||
confirms with_hydrology is what supplies the signal, not some \
|
||
other implicit path"
|
||
);
|
||
}
|
||
|
||
/// The D-255(f) mandatory cache-hit == cache-miss determinism gate,
|
||
/// applied to lake classification specifically: deriving the SAME
|
||
/// position through the SAME `HydrologyResult` (as if reading a resident
|
||
/// coarser canvas) must be byte-identical to solving hydrology fresh a
|
||
/// second time and deriving again (as if the cache had been evicted and
|
||
/// hydrology re-solved) — D-227's "evict -> recompute -> byte-identical"
|
||
/// test, instantiated for the hydrology-sourced `morphology_zone` gate
|
||
/// this ticket adds.
|
||
#[test]
|
||
fn lake_classification_cache_hit_equals_cache_miss() {
|
||
let hm = bowl_hm_no_ocean();
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let climate_inputs = crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 };
|
||
|
||
// "Cache hit" path: solve once, reuse the SAME HydrologyResult for
|
||
// every sample (mirrors a resident coarser canvas served from cache).
|
||
let hydrology_cached = crate::atlas::hydrology_equilibrium::solve(
|
||
&hm.data,
|
||
hm.width,
|
||
hm.height,
|
||
hm.sea_level,
|
||
climate_inputs,
|
||
);
|
||
let ta_hit = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_cached);
|
||
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default()
|
||
};
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
// Sample several positions (centre, rim, corner) through the "hit" path.
|
||
let positions = [(32.0, 16.0), (5.0, 5.0), (60.0, 28.0), (32.0, 4.0)];
|
||
let hit_zones: Vec<MorphologyZone> = positions
|
||
.iter()
|
||
.map(|&(px, py)| {
|
||
derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta_hit,
|
||
px * dm,
|
||
py * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
)
|
||
.morphology_zone
|
||
})
|
||
.collect();
|
||
|
||
// "Cache miss" path: re-solve hydrology fresh (a second, independent
|
||
// solve() call — D-227's eviction/recompute case) and re-derive the
|
||
// SAME positions.
|
||
let hydrology_fresh = crate::atlas::hydrology_equilibrium::solve(
|
||
&hm.data,
|
||
hm.width,
|
||
hm.height,
|
||
hm.sea_level,
|
||
climate_inputs,
|
||
);
|
||
let ta_miss = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_fresh);
|
||
let miss_zones: Vec<MorphologyZone> = positions
|
||
.iter()
|
||
.map(|&(px, py)| {
|
||
derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta_miss,
|
||
px * dm,
|
||
py * dm,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
)
|
||
.morphology_zone
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
hit_zones, miss_zones,
|
||
"cache-hit path (reused HydrologyResult) and cache-miss path \
|
||
(freshly re-solved HydrologyResult) must classify byte-identically \
|
||
at every sampled position (D-227 / D-255(f))"
|
||
);
|
||
// Non-vacuous: at least the centre position must actually be a lake,
|
||
// so this test is exercising the gate, not trivially passing because
|
||
// nothing ever classified Lake.
|
||
assert!(
|
||
hit_zones.contains(&MorphologyZone::Lake),
|
||
"sanity: the position sweep must include at least one Lake cell"
|
||
);
|
||
}
|
||
|
||
/// A non-district-aligned fractional metre position (e.g. a quarter-grid
|
||
/// sample, T-1150) must derive without panicking and stay within the same
|
||
/// value ranges as the district-aligned case — the whole point of the
|
||
/// extraction is that ANY fractional world position is now legal input,
|
||
/// not just integer DistrictPos multiples.
|
||
#[test]
|
||
fn derive_at_metres_accepts_fractional_sub_district_position() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
// A quarter-grid offset (512 m, D-243) inside district (1234, -567).
|
||
let prof = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
1234.0 * dm + 512.0,
|
||
-567.0 * dm + 512.0,
|
||
&climate,
|
||
512.0,
|
||
&[],
|
||
);
|
||
assert!((0..=100).contains(&prof.elev_q));
|
||
assert!((0..=100).contains(&prof.slope_q));
|
||
}
|
||
|
||
/// A `min_wavelength_m` cutoff must actually change the invented terrain
|
||
/// primitives relative to the uncut (0.0) derive at the SAME position —
|
||
/// otherwise the parameter would be silently inert at this layer (the
|
||
/// enveloped_fbm-level test already covers the raw scatter function; this
|
||
/// confirms the wiring survives through invent_primitives/derive_at_metres).
|
||
#[test]
|
||
fn derive_at_metres_cutoff_changes_invented_primitives() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
let mut any_differs = false;
|
||
for i in 0..20 {
|
||
let wx = (100 + i * 37) as f64 * dm;
|
||
let wy = (100 + i * 53) as f64 * dm;
|
||
let uncut = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
let cut = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries
|
||
&[],
|
||
);
|
||
if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q {
|
||
any_differs = true;
|
||
}
|
||
}
|
||
assert!(
|
||
any_differs,
|
||
"a mid-band min_wavelength_m cutoff must change invented terrain \
|
||
at at least one sampled position"
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// T-1162 — coast crinkle / sub-district relief / vegetation patchiness
|
||
// -------------------------------------------------------------------
|
||
|
||
/// Determinism of the T-1162 fields specifically: two independent
|
||
/// `derive_at_metres` calls at the SAME cutoff-bearing position (Quarter
|
||
/// spacing, admitting the new coast-warp + relief + vegetation content)
|
||
/// must be bit-identical (D-010/D-227) — the new machinery is pure, same
|
||
/// as everything else in this module.
|
||
#[test]
|
||
fn t1162_new_fields_are_deterministic_at_quarter_cutoff() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
for i in 0..12 {
|
||
let wx = (300 + i * 41) as f64 * dm * 0.1;
|
||
let wy = (300 + i * 29) as f64 * dm * 0.1;
|
||
let a = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
1_024.0,
|
||
&[],
|
||
);
|
||
let b = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
1_024.0,
|
||
&[],
|
||
);
|
||
assert_district_profiles_eq(&a, &b);
|
||
}
|
||
}
|
||
|
||
/// Cutoff-exclusion discipline (T-1162 parts a/b): District's REAL
|
||
/// quantized band is `4,096` m (`MIN_WL_BANDS_M`'s pre-existing finest
|
||
/// entry — matches `terrain_detail`'s own finest octave, i.e. District's
|
||
/// Nyquist floor). At that exact cutoff, `derive_at_metres` must produce
|
||
/// output IDENTICAL to a second call at the same cutoff — this is the
|
||
/// "byte-identical if the cutoff excludes the new octaves" guarantee for
|
||
/// the sub-district relief band (all four `VOXEL_OCTAVE_WAVELENGTHS_M`
|
||
/// entries are ≤1,024 m, strictly below 4,096) and the two FINEST
|
||
/// coast-warp additions (2,048/1,024 m, also below 4,096). The coast
|
||
/// warp's 8,192/4,096 m additions are legitimately ADMITTED at District's
|
||
/// own floor (4,096 IS District's Nyquist limit, not "too fine for
|
||
/// District") — that is intended enrichment, not a leak, and is
|
||
/// deliberately NOT asserted away here (see the companion
|
||
/// `quarter_cutoff_admits_more_than_district` test for the positive
|
||
/// case). This test instead pins that AT THE SAME NOMINAL CUTOFF VALUE,
|
||
/// repeated derivation is stable — the determinism half of the contract.
|
||
#[test]
|
||
fn district_floor_cutoff_is_stable_and_deterministic() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
for i in 0..20 {
|
||
let wx = (150 + i * 47) as f64 * dm;
|
||
let wy = (150 + i * 31) as f64 * dm;
|
||
let a = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
4_096.0,
|
||
&[],
|
||
);
|
||
let b = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
4_096.0,
|
||
&[],
|
||
);
|
||
assert_district_profiles_eq(&a, &b);
|
||
}
|
||
}
|
||
|
||
/// The sub-district relief band specifically (part b) is fully excluded
|
||
/// at District's floor (4,096 m — every `VOXEL_OCTAVE_WAVELENGTHS_M`
|
||
/// entry is ≤1,024 m, strictly below 4,096). Isolated directly against
|
||
/// `detail_scatter::voxel_relief` (rather than through the full
|
||
/// `derive_at_metres` stack, where the coast warp's OWN 8,192/4,096/2,048
|
||
/// additions would confound a two-cutoff comparison — see the module doc
|
||
/// on `MIN_WL_BANDS_M` for why 4,096 vs any value in `(1_024, 4_096)`
|
||
/// legitimately differs on the coast-warp side alone): at cutoff 4,096
|
||
/// the relief contribution is exactly zero, matching the
|
||
/// `flat_envelope_invents_nothing`-style empty-sum guard.
|
||
#[test]
|
||
fn voxel_relief_band_fully_excluded_at_district_floor() {
|
||
for i in 0..20 {
|
||
let wx = (150 + i * 91) as f64 * 137.0;
|
||
let wy = (150 + i * 67) as f64 * -211.0;
|
||
let relief = crate::atlas::detail_scatter::voxel_relief(
|
||
test_seed().seed(),
|
||
wx,
|
||
wy,
|
||
0.8,
|
||
0.6,
|
||
4_096.0,
|
||
);
|
||
assert_eq!(
|
||
relief, 0.0,
|
||
"voxel_relief must contribute exactly zero at District's 4,096 m floor \
|
||
(every VOXEL_OCTAVE_WAVELENGTHS_M entry is ≤1,024 m)"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The companion positive case: Quarter's real band (1,024 m) admits
|
||
/// content District's real band (4,096 m) excludes — the extension must
|
||
/// not be inert. Sweeps several positions and requires at least one to
|
||
/// diverge (a single unlucky zero-crossing position would otherwise
|
||
/// false-fail).
|
||
#[test]
|
||
fn quarter_cutoff_admits_more_than_district() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
let mut any_differs = false;
|
||
for i in 0..20 {
|
||
let wx = (150 + i * 47) as f64 * dm;
|
||
let wy = (150 + i * 31) as f64 * dm;
|
||
let district = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
4_096.0,
|
||
&[],
|
||
);
|
||
let quarter = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
1_024.0,
|
||
&[],
|
||
);
|
||
if district.elev_q != quarter.elev_q
|
||
|| district.slope_q != quarter.slope_q
|
||
|| district.moisture_q != quarter.moisture_q
|
||
{
|
||
any_differs = true;
|
||
}
|
||
}
|
||
assert!(
|
||
any_differs,
|
||
"Quarter's finer cutoff must admit SOME content District's cutoff excludes \
|
||
at at least one sampled position — the T-1162 extension must not be inert"
|
||
);
|
||
}
|
||
|
||
/// Unknown/coarser cutoffs never admit finer octaves: a cutoff ABOVE
|
||
/// every extended band (coast warp's coarsest is 262,144 m) must produce
|
||
/// IDENTICAL output to the pre-extension "everything truncated" case —
|
||
/// confirms the extension didn't accidentally widen what a coarse cutoff
|
||
/// admits, only what a fine one does.
|
||
#[test]
|
||
fn coarse_cutoff_admits_nothing_from_t1162_extension_either() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
for i in 0..10 {
|
||
let wx = (200 + i * 61) as f64 * dm;
|
||
let wy = (200 + i * 43) as f64 * dm;
|
||
// Above the coastal warp's own coarsest octave (262,144 m) — every
|
||
// octave in every band (coast, terrain, voxel relief) is excluded.
|
||
let far_above = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
300_000.0,
|
||
&[],
|
||
);
|
||
// An even more extreme cutoff must produce the SAME result — once
|
||
// every octave is truncated, going coarser still changes nothing.
|
||
let even_further = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
10_000_000.0,
|
||
&[],
|
||
);
|
||
assert_district_profiles_eq(&far_above, &even_further);
|
||
}
|
||
}
|
||
|
||
/// Vegetation cross-rung coherence (the ticket's hard requirement): the
|
||
/// MAJORITY vegetation class over a sampled patch at Region-equivalent
|
||
/// (uncut massif-only) scale must be preserved when the SAME patch is
|
||
/// refined to Quarter spacing — Quarter punches clearings/copses (some
|
||
/// cells legitimately differ), but it must not flip the patch's dominant
|
||
/// class wholesale. Uses a wet, warm, low-elevation body so Forest is the
|
||
/// achievable majority class and the massif field has genuine amplitude
|
||
/// to work with (see `vegetation_envelope`'s wetness-product ceiling).
|
||
#[test]
|
||
fn vegetation_majority_class_preserved_under_quarter_refinement() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("tropical".into()),
|
||
body_radius_km: Some(6371.0),
|
||
latitude_deg: 5.0, // near-equator: warm, wet, low treeline pressure
|
||
..Default::default()
|
||
};
|
||
let dm = scale::DISTRICT_M as f64;
|
||
// A patch of 8x8 quarter-cells (one district's worth) around a fixed
|
||
// low-elevation coastal-adjacent-but-inland district.
|
||
let base_wx = 40.0 * dm;
|
||
let base_wy = 15.0 * dm;
|
||
|
||
let district_class = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
base_wx,
|
||
base_wy,
|
||
&climate,
|
||
2_048.0,
|
||
&[],
|
||
)
|
||
.vegetation_class;
|
||
|
||
// Sample the surrounding quarter grid (512 m spacing) and tally class
|
||
// frequency — the majority must match the district-rung verdict at
|
||
// the patch centre if cross-rung coherence holds. Skip Marine (open
|
||
// water) tallies since the ticket's coherence claim is about land
|
||
// vegetation classes refining, not the ocean/land boundary itself.
|
||
use std::collections::BTreeMap;
|
||
let mut tally: BTreeMap<u8, u32> = BTreeMap::new();
|
||
let qm = scale::QUARTER_M as f64;
|
||
for dy in -2..2 {
|
||
for dx in -2..2 {
|
||
let wx = base_wx + dx as f64 * qm;
|
||
let wy = base_wy + dy as f64 * qm;
|
||
let prof = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
1_024.0,
|
||
&[],
|
||
);
|
||
if prof.vegetation_class != VegetationClass::Marine {
|
||
*tally.entry(prof.vegetation_class as u8).or_insert(0) += 1;
|
||
}
|
||
}
|
||
}
|
||
if district_class == VegetationClass::Marine {
|
||
// The centre itself is open water — nothing to assert about land
|
||
// majority at this probe point; the test still ran the refinement
|
||
// sweep above without panicking, which is the structural check.
|
||
return;
|
||
}
|
||
let majority = tally
|
||
.iter()
|
||
.max_by_key(|&(_, count)| count)
|
||
.map(|(&class, _)| class);
|
||
assert_eq!(
|
||
majority,
|
||
Some(district_class as u8),
|
||
"Quarter-refined majority vegetation class must match the District-rung \
|
||
verdict at the patch centre (tally: {tally:?}, district: {district_class:?})"
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// derive_orbital_at_metres (T-1152, design doc §2/§4 orbital row)
|
||
// -------------------------------------------------------------------
|
||
|
||
/// Determinism (D-010/D-227): two independent orbital derives at the same
|
||
/// position produce a bit-identical `DistrictProfile`, mirroring
|
||
/// `derive_district_is_deterministic`'s pattern for the finer rungs.
|
||
#[test]
|
||
fn derive_orbital_at_metres_is_deterministic() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::REGION_M as f64;
|
||
|
||
let a = derive_orbital_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
3.0 * dm,
|
||
2.0 * dm,
|
||
&climate,
|
||
);
|
||
let b = derive_orbital_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
3.0 * dm,
|
||
2.0 * dm,
|
||
&climate,
|
||
);
|
||
assert_district_profiles_eq(&a, &b);
|
||
}
|
||
|
||
/// The orbital path must NOT run `invent_primitives` — the design doc's
|
||
/// central constraint (§2: "no invent_primitives at orbital wavelengths").
|
||
/// Direct proof: `slope_q` is always exactly 0 (invention is the only
|
||
/// source of nonzero slope_q at this call depth — see
|
||
/// `derive_orbital_at_metres`'s doc on why slope_q is fixed), sampled
|
||
/// across enough distinct positions that a nonzero value appearing even
|
||
/// once would falsify the claim.
|
||
#[test]
|
||
fn derive_orbital_at_metres_never_invents_slope() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::REGION_M as f64;
|
||
|
||
for i in 0..25 {
|
||
let wx = (i * 7) as f64 * dm * 0.37;
|
||
let wy = (i * 11) as f64 * dm * 0.29;
|
||
let prof =
|
||
derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate);
|
||
assert_eq!(
|
||
prof.slope_q, 0,
|
||
"orbital derive must never report invented slope (position {i})"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The orbital derive's `elev_q`/`temperature_c` must come from the SAME
|
||
/// envelope + region-baseline sources `derive_at_metres` reads — not an
|
||
/// independent/divergent computation. At a position where the invented
|
||
/// scatter happens to contribute exactly zero (impossible to guarantee by
|
||
/// construction, so this test instead checks the WEAKER, always-true
|
||
/// property: both paths' `elev_q` derive from the same underlying
|
||
/// bilinear envelope, so they must be close — within the invented
|
||
/// scatter's own bounded contribution range, not arbitrarily different).
|
||
/// This guards against the orbital path silently reading a different
|
||
/// terrain field entirely (a copy-paste bug this refactor is exactly the
|
||
/// kind of change that could introduce).
|
||
#[test]
|
||
fn derive_orbital_at_metres_elevation_tracks_the_same_envelope() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::DISTRICT_M as f64;
|
||
|
||
// Sample at a DISTRICT-aligned position (within the orbital function's
|
||
// legal domain — it accepts any world position, this just makes the
|
||
// district-mode comparison call meaningful) so both paths read the
|
||
// exact same fractional heightmap pixel.
|
||
let wx = 40.0 * dm;
|
||
let wy = 20.0 * dm;
|
||
let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate);
|
||
let full = derive_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
wx,
|
||
wy,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
|
||
// The invented scatter is a bounded perturbation on top of the raw
|
||
// envelope (detail_scatter's amplitude is capped well under 100 elev_q
|
||
// points) — the two must be in the same ballpark, not exactly equal
|
||
// (that would defeat the point of invention existing at all at the
|
||
// finer rung) and not wildly different (that would mean the orbital
|
||
// path is reading a different field).
|
||
let elev_diff = (orbital.elev_q - full.elev_q).abs();
|
||
assert!(
|
||
elev_diff <= 50,
|
||
"orbital elev_q ({}) and full-derive elev_q ({}) must come from the \
|
||
same envelope, not diverge arbitrarily",
|
||
orbital.elev_q,
|
||
full.elev_q
|
||
);
|
||
}
|
||
|
||
/// Orbital-scale windows must still fill all six dense wire arrays the
|
||
/// client's colorizer family reads (T-1152: "the orbital cells must fill
|
||
/// the same six dense arrays the DistrictWindowLayer carries") — this is
|
||
/// checked at the `DistrictProfile` level (the pre-packing source of
|
||
/// those six fields): every field the packer reads
|
||
/// (`morphology_zone`/`elev_q`/`temperature_c`/`moisture_q`/
|
||
/// `vegetation_class`/`glaciation_grade`) must be populated the same way
|
||
/// regardless of rung — this test asserts the orbital output is a
|
||
/// legitimate `DistrictProfile`, not a partially-filled stand-in.
|
||
#[test]
|
||
fn derive_orbital_at_metres_populates_all_six_wire_fields() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let climate = ClimateConstants::default();
|
||
let p = earth_params();
|
||
let dm = scale::REGION_M as f64;
|
||
|
||
let prof = derive_orbital_at_metres(
|
||
test_seed(),
|
||
"test_body",
|
||
&p,
|
||
&ta,
|
||
5.0 * dm,
|
||
3.0 * dm,
|
||
&climate,
|
||
);
|
||
assert!((0..=100).contains(&prof.elev_q));
|
||
assert!((0..=100).contains(&prof.moisture_q));
|
||
// temperature_c is Some for a breathable-atmosphere body (earth_params).
|
||
assert!(prof.temperature_c.is_some());
|
||
// morphology_zone/vegetation_class/glaciation_grade are enums with no
|
||
// "unset" state — successfully constructing the DistrictProfile at
|
||
// all (no panic) is the actual assertion; the field reads below just
|
||
// confirm they're reachable typed values, matching the discipline
|
||
// `derive_district_is_deterministic` and neighbours already use.
|
||
let _ = prof.morphology_zone;
|
||
let _ = prof.vegetation_class;
|
||
let _ = prof.glaciation_grade;
|
||
}
|
||
|
||
#[test]
|
||
fn derive_district_profile_is_deterministic() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
// D-240: no orbit/star fields in BodyParams — only class/atmo/hydro.
|
||
let params = BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
..Default::default()
|
||
};
|
||
let pos = SurveyCellPos(2, 1);
|
||
let climate = ClimateConstants::default();
|
||
let p1 = derive_district_profile(
|
||
test_seed(),
|
||
¶ms,
|
||
&ta,
|
||
pos,
|
||
8,
|
||
&climate,
|
||
"test_body",
|
||
&BTreeMap::new(),
|
||
BasinDirection::North,
|
||
None,
|
||
);
|
||
let p2 = derive_district_profile(
|
||
test_seed(),
|
||
¶ms,
|
||
&ta,
|
||
pos,
|
||
8,
|
||
&climate,
|
||
"test_body",
|
||
&BTreeMap::new(),
|
||
BasinDirection::North,
|
||
None,
|
||
);
|
||
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
|
||
assert_eq!(
|
||
format!("{:?}", p1.morphology_zone),
|
||
format!("{:?}", p2.morphology_zone),
|
||
"morphology_zone must be deterministic"
|
||
);
|
||
assert_eq!(p1.tectonic_class, p2.tectonic_class);
|
||
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
|
||
assert_eq!(p1.river_threshold, p2.river_threshold);
|
||
assert_eq!(p1.slope_q, p2.slope_q);
|
||
assert_eq!(p1.elev_q, p2.elev_q);
|
||
}
|
||
|
||
#[test]
|
||
fn volcanic_body_gets_volcanic_tectonic() {
|
||
let params = BodyParams {
|
||
planet_class: Some("volcanic".into()),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic);
|
||
}
|
||
|
||
#[test]
|
||
fn authored_tectonic_override_wins() {
|
||
let params = BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
tectonic_activity: Some("volcanic".into()),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic);
|
||
}
|
||
|
||
#[test]
|
||
fn airless_body_gets_no_glaciation() {
|
||
// 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() {
|
||
// −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() {
|
||
// 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!(
|
||
t_arid > t_humid,
|
||
"arid body should have higher river threshold than humid body"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tectonic_class_discriminants_pinned() {
|
||
// Load-bearing: renumbering breaks the D-010 integer derivation contract.
|
||
assert_eq!(TectonicClass::Stable as u8, 0);
|
||
assert_eq!(TectonicClass::Active as u8, 1);
|
||
assert_eq!(TectonicClass::Volcanic as u8, 2);
|
||
assert_eq!(TectonicClass::TidallyForced as u8, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn glaciation_grade_discriminants_pinned() {
|
||
assert_eq!(GlaciationGrade::None as u8, 0);
|
||
assert_eq!(GlaciationGrade::Light as u8, 1);
|
||
assert_eq!(GlaciationGrade::Moderate as u8, 2);
|
||
assert_eq!(GlaciationGrade::Heavy as u8, 3);
|
||
assert_eq!(GlaciationGrade::IceCap as u8, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn precipitation_class_discriminants_pinned() {
|
||
assert_eq!(PrecipitationClass::Arid as u8, 0);
|
||
assert_eq!(PrecipitationClass::SemiArid as u8, 1);
|
||
assert_eq!(PrecipitationClass::Temperate as u8, 2);
|
||
assert_eq!(PrecipitationClass::Humid as u8, 3);
|
||
assert_eq!(PrecipitationClass::SuperHumid as u8, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn regions_use_btreemap_order() {
|
||
let hm = test_hm();
|
||
let ta = test_ta(&hm);
|
||
let params = BodyParams::default();
|
||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
|
||
let first = districts.keys().next().expect("at least one district");
|
||
assert_eq!(
|
||
*first,
|
||
SurveyCellPos(0, 0),
|
||
"first district must be at origin"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn river_threshold_clamped_to_range() {
|
||
// Even with extreme params, threshold stays in [20, 500].
|
||
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"
|
||
);
|
||
assert!(
|
||
(20..=500).contains(&t_arid),
|
||
"arid threshold {t_arid} out of range"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1024 / D-240 climate derivation tests — planet_class envelope model
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Convenience: derive temperature with a fixed seed (0) — deterministic, but
|
||
/// note seed 0 still applies its (constant, non-zero) nudge like any other seed.
|
||
fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option<f32> {
|
||
let params = BodyParams {
|
||
planet_class: Some(planet_class.into()),
|
||
atmosphere: Some(atmosphere.into()),
|
||
latitude_deg: lat,
|
||
elevation_km: elev_km,
|
||
..Default::default()
|
||
};
|
||
derive_temperature_c(¶ms, &ClimateConstants::default(), 0)
|
||
}
|
||
|
||
#[test]
|
||
fn airless_body_has_no_temperature() {
|
||
let params = BodyParams {
|
||
atmosphere: Some("none".into()),
|
||
..Default::default()
|
||
};
|
||
let climate = ClimateConstants::default();
|
||
assert_eq!(
|
||
derive_temperature_c(¶ms, &climate, 0),
|
||
None,
|
||
"airless body must return None temperature (D-227)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn polar_region_is_colder_than_equatorial() {
|
||
// seed=0 has nudge; compare equatorial vs polar for same body → ordering holds.
|
||
let t_eq = temp("temperate", "breathable", 0.0, 0.0).unwrap();
|
||
let t_pol = temp("temperate", "breathable", 90.0, 0.0).unwrap();
|
||
assert!(
|
||
t_pol < t_eq,
|
||
"polar temperature {t_pol}°C must be less than equatorial {t_eq}°C"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn high_elevation_is_colder() {
|
||
let t_low = temp("temperate", "breathable", 0.0, 0.0).unwrap();
|
||
let t_high = temp("temperate", "breathable", 0.0, 5.0).unwrap();
|
||
// Lapse rate 6.5°C/km × 5 km = 32.5°C; nudge same for same seed.
|
||
assert!(
|
||
t_high < t_low,
|
||
"mountain temperature {t_high}°C must be less than sea level {t_low}°C"
|
||
);
|
||
let delta = t_low - t_high;
|
||
// The clamping to [cold, warm] may reduce the apparent delta at the band edge,
|
||
// but 5 km lapse should still register at least 5°C.
|
||
assert!(
|
||
delta >= 5.0,
|
||
"elevation delta {delta}°C too small for 5 km lapse"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tropical_body_temperature_plausible() {
|
||
// Tropical band is [16, 40]°C. Equatorial, sea-level, seed=0.
|
||
let t = temp("tropical", "breathable", 0.0, 0.0).unwrap();
|
||
assert!(
|
||
(16.0..=40.0).contains(&t),
|
||
"tropical equatorial temperature {t}°C out of class band [16, 40]"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn frozen_body_temperature_plausible() {
|
||
// Frozen band is [-90, -25]°C.
|
||
let t = temp("frozen", "thin", 45.0, 0.0).unwrap();
|
||
assert!(
|
||
(-90.0..=-25.0).contains(&t),
|
||
"frozen temperature {t}°C out of class band [-90, -25]"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn temperature_is_deterministic() {
|
||
// Same inputs → same output, always.
|
||
let params = BodyParams {
|
||
atmosphere: Some("thin".into()),
|
||
planet_class: Some("arid".into()),
|
||
latitude_deg: 45.0,
|
||
elevation_km: 1.5,
|
||
..Default::default()
|
||
};
|
||
let climate = ClimateConstants::default();
|
||
let t1 = derive_temperature_c(¶ms, &climate, 12345);
|
||
let t2 = derive_temperature_c(¶ms, &climate, 12345);
|
||
assert_eq!(t1, t2, "temperature derivation must be deterministic");
|
||
}
|
||
|
||
#[test]
|
||
fn different_seeds_produce_different_nudges() {
|
||
// Two different body seeds should produce slightly different temperatures
|
||
// for the same class/lat/elev.
|
||
let params = BodyParams {
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
latitude_deg: 30.0,
|
||
elevation_km: 0.0,
|
||
..Default::default()
|
||
};
|
||
let climate = ClimateConstants::default();
|
||
let t_a = derive_temperature_c(¶ms, &climate, 1);
|
||
let t_b = derive_temperature_c(¶ms, &climate, 999_999_999);
|
||
// Both still within the class band, but not identical.
|
||
assert_ne!(t_a, t_b, "different seeds should produce different nudges");
|
||
let (cold, warm) = climate.envelope("temperate");
|
||
assert!(t_a.unwrap() >= cold && t_a.unwrap() <= warm);
|
||
assert!(t_b.unwrap() >= cold && t_b.unwrap() <= warm);
|
||
}
|
||
|
||
/// D-240 class-band consistency guard: every planet_class must derive within
|
||
/// its `[cold, warm]` band across a full latitude sweep, all atmospheres,
|
||
/// and sea level. This is the key acceptance test for T-1033.
|
||
#[test]
|
||
fn every_planet_class_derives_within_its_band() {
|
||
let climate = ClimateConstants::default();
|
||
let planet_classes = [
|
||
"frozen",
|
||
"ice",
|
||
"boreal",
|
||
"cold_arid",
|
||
"temperate",
|
||
"oceanic",
|
||
"subtropical",
|
||
"warm_ocean",
|
||
"tropical",
|
||
"arid",
|
||
"hot_arid",
|
||
"volcanic",
|
||
"geothermal",
|
||
];
|
||
let atmospheres = ["thin", "standard", "breathable", "toxic", "dense"];
|
||
// Sweep latitudes 0°–90° in 5° steps; elevation = 0.
|
||
let latitudes: Vec<f64> = (0..=90).step_by(5).map(|d| d as f64).collect();
|
||
let seeds: [u64; 4] = [0, 1, u64::MAX / 2, u64::MAX];
|
||
|
||
for class in &planet_classes {
|
||
let (cold, warm) = climate.envelope(class);
|
||
for atmo in &atmospheres {
|
||
for &lat in &latitudes {
|
||
// Sweep sea level AND high altitude so the lapse term + clamp
|
||
// are exercised jointly against the cold end, not just lat alone.
|
||
for elev in [0.0_f64, 6.0] {
|
||
// Sweep hydrosphere too, so the maritime-compressed gradient
|
||
// path is covered by the band invariant (not just None/1.0).
|
||
for hydro in ["none", "ocean", "liquid_water", "rivers", "ice"] {
|
||
for seed in seeds {
|
||
let params = BodyParams {
|
||
planet_class: Some((*class).into()),
|
||
atmosphere: Some((*atmo).into()),
|
||
hydrosphere: Some(hydro.into()),
|
||
latitude_deg: lat,
|
||
elevation_km: elev,
|
||
..Default::default()
|
||
};
|
||
let t = derive_temperature_c(¶ms, &climate, seed)
|
||
.expect("non-airless body must have temperature");
|
||
assert!(
|
||
t >= cold && t <= warm,
|
||
"class={class} atmo={atmo} hydro={hydro} lat={lat} \
|
||
elev={elev} seed={seed}: temperature {t}°C outside \
|
||
band [{cold}, {warm}]"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn maritime_hydrosphere_compresses_the_gradient() {
|
||
// D-240: a water-rich world has a SMALLER equator→pole temperature delta
|
||
// than a dry world of the same class (maritime moderation).
|
||
let climate = ClimateConstants::default();
|
||
let mk = |hydro: &str, lat: f64| BodyParams {
|
||
planet_class: Some("temperate".into()),
|
||
atmosphere: Some("standard".into()),
|
||
hydrosphere: Some(hydro.into()),
|
||
latitude_deg: lat,
|
||
elevation_km: 0.0,
|
||
..Default::default()
|
||
};
|
||
// Same seed → only hydrosphere differs.
|
||
let delta = |hydro: &str| {
|
||
(derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap()
|
||
- derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap())
|
||
.abs()
|
||
};
|
||
let ocean_delta = delta("ocean");
|
||
let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard")
|
||
assert!(
|
||
ocean_delta < dry_delta,
|
||
"ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_planet_class_falls_back_to_temperate_band() {
|
||
// An unrecognised class string must not panic; it falls back to temperate.
|
||
let climate = ClimateConstants::default();
|
||
let (cold, warm) = climate.envelope("temperate");
|
||
let params = BodyParams {
|
||
planet_class: Some("unknown_alien_class".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.0,
|
||
..Default::default()
|
||
};
|
||
let t = derive_temperature_c(¶ms, &climate, 0)
|
||
.expect("breathable body must have temperature");
|
||
assert!(
|
||
t >= cold && t <= warm,
|
||
"unknown class temperature {t}°C outside temperate band [{cold}, {warm}]"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn prefix_classes_derive_within_shifted_band() {
|
||
// "cold_weird" is not in the table; prefix rule should shift temperate.
|
||
let climate = ClimateConstants::default();
|
||
let (base_cold, base_warm) = climate.envelope("temperate");
|
||
let (cold, warm) = climate.envelope("cold_weird");
|
||
assert!(
|
||
(cold - (base_cold - 10.0)).abs() < f32::EPSILON * 10.0,
|
||
"cold_ prefix should shift cold_end by -10"
|
||
);
|
||
assert!(
|
||
(warm - (base_warm - 10.0)).abs() < f32::EPSILON * 10.0,
|
||
"cold_ prefix should shift warm_end by -10"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn moisture_q_ocean_breathable_is_high() {
|
||
let params = BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
..Default::default()
|
||
};
|
||
let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default());
|
||
assert!(q >= 70, "ocean + breathable moisture {q} should be >= 70");
|
||
}
|
||
|
||
#[test]
|
||
fn moisture_q_liquid_water_is_high_not_default() {
|
||
// T-1034 regression: "liquid_water" is the dominant surface-water vocab in
|
||
// systems.db (175 bodies). It must map to the high surface-liquid band, not
|
||
// silently fall to the `_ => 30` default like it did before the fix.
|
||
let params = BodyParams {
|
||
hydrosphere: Some("liquid_water".into()),
|
||
atmosphere: Some("standard".into()),
|
||
..Default::default()
|
||
};
|
||
let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default());
|
||
assert!(
|
||
q >= 70,
|
||
"liquid_water moisture {q} should be >= 70 (surface-liquid band), not the default 30"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn moisture_q_airless_is_zero() {
|
||
let params = BodyParams {
|
||
hydrosphere: Some("none".into()),
|
||
atmosphere: Some("none".into()),
|
||
..Default::default()
|
||
};
|
||
let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default());
|
||
assert_eq!(q, 0, "airless no-hydrosphere body moisture must be 0");
|
||
}
|
||
|
||
#[test]
|
||
fn moisture_q_clamped_to_range() {
|
||
// All combinations must stay in [0, 100]. Sweep the full canonical
|
||
// `bodies.hydrosphere` vocabulary (T-1034) plus an unknown fallback.
|
||
let hydros = [
|
||
"liquid_water",
|
||
"ocean",
|
||
"ocean-coastal",
|
||
"extensive",
|
||
"rivers",
|
||
"rivers-lakes",
|
||
"moderate",
|
||
"ice",
|
||
"subsurface_liquid",
|
||
"subsurface",
|
||
"subsurface_ice",
|
||
"minimal",
|
||
"trace",
|
||
"none",
|
||
"unknown",
|
||
];
|
||
let atmos = [
|
||
"none",
|
||
"thin",
|
||
"standard",
|
||
"breathable",
|
||
"toxic",
|
||
"dense",
|
||
"unknown",
|
||
];
|
||
for h in &hydros {
|
||
for a in &atmos {
|
||
let params = BodyParams {
|
||
hydrosphere: Some(h.to_string()),
|
||
atmosphere: Some(a.to_string()),
|
||
..Default::default()
|
||
};
|
||
let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default());
|
||
assert!(
|
||
(0..=100).contains(&q),
|
||
"moisture_q {q} out of range for hydro={h} atmo={a}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn moisture_q_has_spatial_gradient() {
|
||
// T-1080: moisture must vary across the body, not be a single body constant.
|
||
let climate = ClimateConstants::default();
|
||
let ocean = |lat: f64| BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
latitude_deg: lat,
|
||
..Default::default()
|
||
};
|
||
// Wettest: equatorial coastal lowland. Driest: polar interior highland.
|
||
let wet = derive_moisture_q(&ocean(0.0), 0, 100, &climate);
|
||
let dry = derive_moisture_q(&ocean(90.0), 90, 0, &climate);
|
||
assert!(
|
||
wet > dry,
|
||
"equatorial coast ({wet}) must be wetter than polar interior ({dry})"
|
||
);
|
||
assert!(
|
||
wet - dry >= 40,
|
||
"moisture gradient ({} pts) should be substantial",
|
||
wet - dry
|
||
);
|
||
// Each axis independently lowers moisture from the wet corner.
|
||
assert!(
|
||
derive_moisture_q(&ocean(90.0), 0, 100, &climate) < wet,
|
||
"latitude lowers moisture"
|
||
);
|
||
assert!(
|
||
derive_moisture_q(&ocean(0.0), 90, 100, &climate) < wet,
|
||
"elevation lowers moisture"
|
||
);
|
||
assert!(
|
||
derive_moisture_q(&ocean(0.0), 0, 0, &climate) < wet,
|
||
"continentality lowers moisture"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn climate_constants_envelope_direct_lookup() {
|
||
// Direct lookup returns the exact registered band.
|
||
let cc = ClimateConstants::default();
|
||
let (cold, warm) = cc.envelope("tropical");
|
||
assert!(
|
||
(cold - 16.0).abs() < f32::EPSILON * 10.0,
|
||
"tropical cold_end should be 16°C"
|
||
);
|
||
assert!(
|
||
(warm - 40.0).abs() < f32::EPSILON * 10.0,
|
||
"tropical warm_end should be 40°C"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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, 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, 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, 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<VegetationClass> = None;
|
||
for elev_q in (0..=100).step_by(5) {
|
||
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!(
|
||
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 district near perennial water → RiparianScrub.
|
||
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, false); // 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);
|
||
// 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, 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. `lake_from_hydrology`
|
||
/// defaults to `false` (T-1184) — no existing caller of this helper tests
|
||
/// the hydrology-sourced lake gate; see `lake_from_hydrology_true_wins_...`
|
||
/// below for the dedicated hydrology-path tests.
|
||
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,
|
||
false,
|
||
)
|
||
}
|
||
|
||
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_region_scale_emits_16_of_17() {
|
||
// Enumerate the zones the district-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<u8> = 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 (discriminant 11) is the ONE frozen zone the district-scale
|
||
// classifier never emits: distinguishing it from Delta needs a lithology
|
||
// signal (§8 Gravel→braided) that DistrictProfile 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 district-reachable zones (BraidedPlain deferred), got: {seen:?}"
|
||
);
|
||
}
|
||
|
||
// ── 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)"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1078 / D-243 §3: derive_district_temperature_c — two-phase split
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn district_modulation_applies_lapse_on_baseline() {
|
||
// High elevation must produce colder district temperature than sea level,
|
||
// given the same region baseline.
|
||
let climate = ClimateConstants::default();
|
||
let baseline = Some(15.0f32); // hypothetical region baseline at sea level
|
||
|
||
let sea_level_params = BodyParams {
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
elevation_km: 0.0,
|
||
..Default::default()
|
||
};
|
||
let high_params = BodyParams {
|
||
elevation_km: 4.0,
|
||
..sea_level_params.clone()
|
||
};
|
||
|
||
let t_sea = derive_district_temperature_c(baseline, &sea_level_params, &climate, 0.0)
|
||
.expect("breathable body must have temperature");
|
||
let t_high = derive_district_temperature_c(baseline, &high_params, &climate, 0.0)
|
||
.expect("breathable body must have temperature");
|
||
|
||
assert!(
|
||
t_high < t_sea,
|
||
"district at 4 km ({t_high}°C) must be colder than sea level ({t_sea}°C)"
|
||
);
|
||
// 4 km × 6.5 °C/km = 26 °C lapse; clamping may reduce it, but at
|
||
// least a few degrees should register.
|
||
let delta = t_sea - t_high;
|
||
assert!(delta >= 5.0, "4 km elevation delta {delta}°C too small");
|
||
}
|
||
|
||
#[test]
|
||
fn district_modulation_airless_baseline_returns_none() {
|
||
// None baseline (airless body) → None district temperature.
|
||
let climate = ClimateConstants::default();
|
||
let params = BodyParams {
|
||
atmosphere: Some("thin".into()),
|
||
planet_class: Some("frozen".into()),
|
||
elevation_km: 0.0,
|
||
..Default::default()
|
||
};
|
||
let t = derive_district_temperature_c(None, ¶ms, &climate, 0.0);
|
||
assert_eq!(
|
||
t, None,
|
||
"None baseline must propagate as None district temperature"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn district_modulation_within_class_band() {
|
||
// Even with high lapse, the clamped output must stay within the class band.
|
||
let climate = ClimateConstants::default();
|
||
let (cold, warm) = climate.envelope("frozen");
|
||
// Baseline at the warm end of the frozen band.
|
||
let baseline = Some(warm);
|
||
let params = BodyParams {
|
||
atmosphere: Some("thin".into()),
|
||
planet_class: Some("frozen".into()),
|
||
elevation_km: 8.0, // max elevation → would push far below cold end
|
||
..Default::default()
|
||
};
|
||
let t = derive_district_temperature_c(baseline, ¶ms, &climate, 0.0)
|
||
.expect("non-airless body must have temperature");
|
||
assert!(
|
||
t >= cold && t <= warm,
|
||
"district temperature {t}°C outside frozen band [{cold}, {warm}]"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn district_modulation_does_not_re_apply_latitude_or_greenhouse() {
|
||
// The district modulation function must NOT include latitude or greenhouse
|
||
// effects — those are already in the region baseline. Pass different baselines
|
||
// (simulating the latitude gradient) and verify the delta is exactly what the
|
||
// lapse adds, with no additional latitude-induced shift.
|
||
let climate = ClimateConstants::default();
|
||
let params = BodyParams {
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
elevation_km: 2.0,
|
||
latitude_deg: 0.0, // this should be irrelevant for the modulation
|
||
..Default::default()
|
||
};
|
||
|
||
// Two different baselines (simulating equatorial vs mid-latitude regions).
|
||
let t_warm_region = derive_district_temperature_c(Some(20.0), ¶ms, &climate, 0.0);
|
||
let t_cool_region = derive_district_temperature_c(Some(5.0), ¶ms, &climate, 0.0);
|
||
|
||
// Both get the same lapse (same params), so the delta between them must
|
||
// equal the delta between the baselines: 15°C.
|
||
let delta = t_warm_region.unwrap() - t_cool_region.unwrap();
|
||
assert!(
|
||
(delta - 15.0).abs() < 1.0,
|
||
"district modulation should preserve the baseline delta (got {delta}°C, expected ~15°C)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn district_modulation_thin_atmosphere_uses_lower_lapse() {
|
||
// Thin atmosphere → lapse = 3.5 °C/km (vs 6.5 for standard/breathable).
|
||
// At 2 km elevation, thin should be ~6 °C warmer than breathable.
|
||
let climate = ClimateConstants::default();
|
||
let baseline = Some(0.0f32);
|
||
|
||
let thin_params = BodyParams {
|
||
atmosphere: Some("thin".into()),
|
||
planet_class: Some("frozen".into()),
|
||
elevation_km: 2.0,
|
||
..Default::default()
|
||
};
|
||
let breathable_params = BodyParams {
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
elevation_km: 2.0,
|
||
..Default::default()
|
||
};
|
||
|
||
let t_thin = derive_district_temperature_c(baseline, &thin_params, &climate, 0.0).unwrap();
|
||
let t_breathable =
|
||
derive_district_temperature_c(baseline, &breathable_params, &climate, 0.0).unwrap();
|
||
|
||
// thin lapse: 3.5 × 2 = 7°C; breathable lapse: 6.5 × 2 = 13°C.
|
||
// t_thin should be ~6°C warmer than t_breathable (both start from 0°C).
|
||
// Note: clamping to class bands may reduce the difference at band edges.
|
||
// Just verify the ordering holds.
|
||
assert!(
|
||
t_thin > t_breathable || {
|
||
// If clamping squishes both to the cold end, verify at least thin
|
||
// didn't produce MORE lapse than breathable.
|
||
let (frozen_cold, _) = climate.envelope("frozen");
|
||
let (_, temperate_warm) = climate.envelope("temperate");
|
||
t_thin >= frozen_cold && t_breathable <= temperate_warm
|
||
},
|
||
"thin atmosphere lapse ({t_thin}°C) should be milder than breathable ({t_breathable}°C) at same elevation"
|
||
);
|
||
}
|
||
}
|