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:
@@ -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 (0–100 integer).
|
||||
/// Derive the district moisture primitive (0–100 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(¶ms);
|
||||
let q = derive_moisture_q(¶ms, 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(¶ms);
|
||||
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"
|
||||
@@ -2064,7 +2113,7 @@ mod tests {
|
||||
atmosphere: Some("none".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = derive_moisture_q(¶ms);
|
||||
let q = derive_moisture_q(¶ms, 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(¶ms);
|
||||
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}"
|
||||
@@ -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.
|
||||
|
||||
@@ -304,8 +304,12 @@ pub fn build_region_profile(
|
||||
let body_seed = seed.seed();
|
||||
let baseline_temp_c = derive_region_baseline_c(®ion_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(®ion_params, 0, 100, constants);
|
||||
|
||||
RegionProfile {
|
||||
pos: region_pos,
|
||||
|
||||
Reference in New Issue
Block a user