feat(simulation): district moisture spatial gradient (T-1080)

moisture_q was a single body constant (derive_moisture_q took only &BodyParams →
80 for every one of 2048 ocean-world districts), so vegetation/terrain/ecotones
were uniform — "nothing to fuzz" (the T-1080 believability bug).

- district_profile.rs: derive_moisture_q is now a body ceiling (hydrosphere+atmosphere)
  × per-district gradient — latitude (equator wet → pole dry), elevation (orographic/
  rain-shadow), continentality (100 − ocean_fraction_q). Integer/D-010, no new plumbing
  (elev_q/ocean_fraction_q/latitude_deg already reach build_district_profile). Mirrors
  D-240's temperature model. Coefficients in ClimateConstants + climate_constants.toml
  [moisture_gradient] (provisional; Q-123 calibrates). Tuned so a wet body stays mostly
  green with drier patches and a frozen body clamps mostly barren. + gradient unit test.
- region_profile.rs: region-tier moisture gets the region-latitude gradient (elevation/
  continentality are district-scale).
- Propagates to precipitation_class / vegetation_class / morphology_zone (all read
  moisture_q). D-239 §2 amended. Believability golden regenerated.

Probe (Arbour @ yolo): moisture_q distinct 1 → 49 (spread 0 → 48); 5/7 → 6/7 criteria.
Edict (frozen): moisture 0..20 ×21 — gradient within its low ceiling. Remaining fail
(vegetation-present) is the metric dividing by all-sampled incl. water on a 2/3-ocean
world — a Q-123 calibration concern, not a climate defect.

clippy --all-targets -D warnings clean; 1580 lib tests + harnesses pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 17:25:18 +02:00
co-authored by Claude Opus 4.8
parent cdc55032df
commit bb5069aa13
7 changed files with 171 additions and 23 deletions
+100 -14
View File
@@ -668,6 +668,20 @@ pub struct ClimateConstants {
/// 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 {
@@ -727,6 +741,13 @@ impl Default for ClimateConstants {
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,
}
}
}
@@ -942,17 +963,32 @@ pub fn derive_district_temperature_c(
Some(t_lapse.clamp(cold, warm))
}
/// Derive moisture primitive (0100 integer).
/// Derive the district moisture primitive (0100 integer; 0 = arid, 100 = saturated).
///
/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated.
/// Integer output (D-010).
/// 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 canonical set the `[hydrosphere_maritime]` table (D-240) keys on — grouped
/// by available surface moisture. Keying on a vocab the data doesn't use (e.g. only
/// `"ocean"`, which is 19 bodies, while 175 use `"liquid_water"`) silently dropped most
/// water worlds to the default (T-1034 — same root cause as D-240).
pub fn derive_moisture_q(params: &BodyParams) -> i32 {
/// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available
/// surface moisture (T-1034).
pub fn derive_moisture_q(
params: &BodyParams,
elev_q: i32,
ocean_fraction_q: i32,
climate: &ClimateConstants,
) -> i32 {
let hydro = params.hydrosphere.as_deref().unwrap_or("none");
let atmo = params.atmosphere.as_deref().unwrap_or("none");
@@ -980,7 +1016,20 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 {
"dense" => 15,
_ => 0,
};
(base + atmo_boost).clamp(0, 100)
// Body moisture ceiling — the wettest a district on this body can be.
let ceiling = (base + atmo_boost).clamp(0, 100);
// ── 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)
}
// ---------------------------------------------------------------------------
@@ -1142,7 +1191,7 @@ fn build_district_profile(
derive_temperature_c(&district_climate_params, climate, body_seed)
}
};
let moisture_q = derive_moisture_q(body_params);
let moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate);
// Climate-derived fields: computed from temperature + moisture primitives
// (D-239 §2). This is the correct call order — temperature must be resolved
@@ -2036,7 +2085,7 @@ mod tests {
atmosphere: Some("breathable".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
let q = derive_moisture_q(&params, 0, 100, &ClimateConstants::default());
assert!(q >= 70, "ocean + breathable moisture {q} should be >= 70");
}
@@ -2050,7 +2099,7 @@ mod tests {
atmosphere: Some("standard".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
let q = derive_moisture_q(&params, 0, 100, &ClimateConstants::default());
assert!(
q >= 70,
"liquid_water moisture {q} should be >= 70 (surface-liquid band), not the default 30"
@@ -2064,7 +2113,7 @@ mod tests {
atmosphere: Some("none".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
let q = derive_moisture_q(&params, 0, 100, &ClimateConstants::default());
assert_eq!(q, 0, "airless no-hydrosphere body moisture must be 0");
}
@@ -2105,7 +2154,7 @@ mod tests {
atmosphere: Some(a.to_string()),
..Default::default()
};
let q = derive_moisture_q(&params);
let q = derive_moisture_q(&params, 0, 100, &ClimateConstants::default());
assert!(
(0..=100).contains(&q),
"moisture_q {q} out of range for hydro={h} atmo={a}"
@@ -2114,6 +2163,43 @@ mod tests {
}
}
#[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.
+6 -2
View File
@@ -304,8 +304,12 @@ pub fn build_region_profile(
let body_seed = seed.seed();
let baseline_temp_c = derive_region_baseline_c(&region_params, constants, body_seed);
// Moisture at the region tier is body-scale (same as body_params).
let moisture_q = crate::atlas::district_profile::derive_moisture_q(body_params);
// Region-tier moisture = body ceiling × region-latitude gradient (T-1080). The
// elevation + continentality components are district-scale, so at the region tier
// elevation is sea level (0) and continentality is neutral (ocean_fraction 100);
// the district derivation adds those on top.
let moisture_q =
crate::atlas::district_profile::derive_moisture_q(&region_params, 0, 100, constants);
RegionProfile {
pos: region_pos,