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
+22
View File
@@ -2717,3 +2717,25 @@ Refinement (Si pass + user ruling, 2026-06-28) — resolves the ''direction to d
- SCOPE: Wetland zone STAYS AlluvialPlain (it is land a marsh, not open water; only the 3 named zones get WaterBody). Transient tidal wet/dry alternation deferred to Q-105.
- DETERMINISM: integer arithmetic only (D-010); warp before the integer voxel address, as the other families.
- VERIFICATION: the T-1083 believability harness must flip ''water renders wet'' toward green for Arbour + Edict; update the golden (UPDATE_GOLDEN) to capture the improvement.', NULL, '2026-06-28 13:10:56', '2026-06-28 13:10:56.408', '2026-06-28 13:10:56.408', NULL, 'f9737eab39f048ff84b8478ca11f5215', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJTXTADJRHYEE1WTAENYAG', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 13:42:27', '2026-06-28 13:42:27.248', '2026-06-28 13:42:27.248', NULL, '556cdb48b70c650c73c13cff9781817e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJTXTADJRHYEE1WTAENYAG', 'description', 'Probe evidence (Arbour @ yolo): moisture_q=80, precip=SuperHumid, veg_class=Forest in ~every one of 2048 districts; the voxel layer then derives terrain=Wetland 100% everywhere. The derivation is LAW-CORRECT (flat + wet -> Wetland, D-239 section 8) — the defect is upstream: the district-scale climate/moisture field has near-zero cross-district variance, so there is no gradient to drive ecotones, material variety, or vegetation bands. Without gradient the ''edge-fuzzed climate'' promise is invisible (nothing to fuzz). Direction to discuss: where moisture/precip SHOULD vary (latitude, orography/rain-shadow, continentality, distance-to-water) and how to introduce believable spatial gradient into the district derivation. Believability gap, not a crash.', 'Probe evidence (Arbour @ yolo): moisture_q=80, precip=SuperHumid, veg_class=Forest in ~every one of 2048 districts; the voxel layer then derives terrain=Wetland 100% everywhere. The derivation is LAW-CORRECT (flat + wet -> Wetland, D-239 section 8) — the defect is upstream: the district-scale climate/moisture field has near-zero cross-district variance, so there is no gradient to drive ecotones, material variety, or vegetation bands. Without gradient the ''edge-fuzzed climate'' promise is invisible (nothing to fuzz). Direction to discuss: where moisture/precip SHOULD vary (latitude, orography/rain-shadow, continentality, distance-to-water) and how to introduce believable spatial gradient into the district derivation. Believability gap, not a crash.
---
Refinement (Si pass, 2026-06-28) READY (no design fork; localized fix).
ROOT CAUSE: derive_moisture_q (district_profile.rs) takes only &BodyParams returns a body-scale constant (ocean 80) identical for all districts. Structurally cannot vary.
FIX: restructure derive_moisture_q to body-ceiling (from hydrosphere) modulated by per-district factors, mirroring derive_temperature_c''s shape (envelope latitude lerp elevation lapse seed nudge), integer/D-010:
- LATITUDE band: equator wetter pole drier (uses latitude_deg, already threaded for temperature).
- ELEVATION lapse: high elev_q drier (orographic/rain-shadow).
- CONTINENTALITY: far from water drier, via TerrainAnalysis.water_dist (Chebyshev cells-to-water, already computed in features.rs; aggregate a per-district mean in the existing derive_district_profile cell loop).
- small deterministic seed nudge (±N) for per-district variety.
Signature: derive_moisture_q(params, elev_q, ocean_fraction_q, water_dist_q). build_district_profile forwards them; derive_district_profile accumulates water_dist.
PROPAGATION: precipitation_class (derive_precipitation_class_from_climate) + vegetation_class (derive_vegetation) + morphology Wetland gate (slope_q<=5 && moisture_q>=60) all read moisture_q diversify automatically.
TUNING: add [moisture_gradient] to server/data/climate_constants.toml + matching ClimateConstants struct defaults (kept in sync by hand until T-1032 wires TOML loading, per the existing constraint).
DETERMINISM: integer (water_dist u16, integer division, clamp 0..100). D-010 ok.
VERIFY: believability harness gate moisture_q.distinct>=3 flips FAILPASS; vegetation-present should rise too. Magnitude calibration is Q-123 (provisional coefficients now). Files: district_profile.rs, climate_constants.toml.', NULL, '2026-06-28 13:50:47', '2026-06-28 13:50:47.912', '2026-06-28 13:50:47.912', NULL, '47bb1324b99af91b19f50ee8f8d3825a', 2) ON CONFLICT(hash) DO NOTHING;
+22
View File
@@ -3650,3 +3650,25 @@ Refinement (Si pass + user ruling, 2026-06-28) — resolves the ''direction to d
- SCOPE: Wetland zone STAYS AlluvialPlain (it is land a marsh, not open water; only the 3 named zones get WaterBody). Transient tidal wet/dry alternation deferred to Q-105.
- DETERMINISM: integer arithmetic only (D-010); warp before the integer voxel address, as the other families.
- VERIFICATION: the T-1083 believability harness must flip ''water renders wet'' toward green for Arbour + Edict; update the golden (UPDATE_GOLDEN) to capture the improvement.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-28 05:54:58.429', '2026-06-28 13:10:56.408', NULL, '8f55fc31d54598d0756308b8e6ef645c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJTXTADJRHYEE1WTAENYAG', 'story', '06FGSJNP8YM537DJCC82ZD6544', 'District climate fields lack spatial gradient — uniform moisture/precip/vegetation yields monotone terrain', 'Probe evidence (Arbour @ yolo): moisture_q=80, precip=SuperHumid, veg_class=Forest in ~every one of 2048 districts; the voxel layer then derives terrain=Wetland 100% everywhere. The derivation is LAW-CORRECT (flat + wet -> Wetland, D-239 section 8) — the defect is upstream: the district-scale climate/moisture field has near-zero cross-district variance, so there is no gradient to drive ecotones, material variety, or vegetation bands. Without gradient the ''edge-fuzzed climate'' promise is invisible (nothing to fuzz). Direction to discuss: where moisture/precip SHOULD vary (latitude, orography/rain-shadow, continentality, distance-to-water) and how to introduce believable spatial gradient into the district derivation. Believability gap, not a crash.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-28 05:54:42.258', '2026-06-28 13:42:27.248', NULL, '8daa8d5b388dbc0aa004da1f0abd970f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJTXTADJRHYEE1WTAENYAG', 'story', '06FGSJNP8YM537DJCC82ZD6544', 'District climate fields lack spatial gradient — uniform moisture/precip/vegetation yields monotone terrain', 'Probe evidence (Arbour @ yolo): moisture_q=80, precip=SuperHumid, veg_class=Forest in ~every one of 2048 districts; the voxel layer then derives terrain=Wetland 100% everywhere. The derivation is LAW-CORRECT (flat + wet -> Wetland, D-239 section 8) — the defect is upstream: the district-scale climate/moisture field has near-zero cross-district variance, so there is no gradient to drive ecotones, material variety, or vegetation bands. Without gradient the ''edge-fuzzed climate'' promise is invisible (nothing to fuzz). Direction to discuss: where moisture/precip SHOULD vary (latitude, orography/rain-shadow, continentality, distance-to-water) and how to introduce believable spatial gradient into the district derivation. Believability gap, not a crash.
---
Refinement (Si pass, 2026-06-28) READY (no design fork; localized fix).
ROOT CAUSE: derive_moisture_q (district_profile.rs) takes only &BodyParams returns a body-scale constant (ocean 80) identical for all districts. Structurally cannot vary.
FIX: restructure derive_moisture_q to body-ceiling (from hydrosphere) modulated by per-district factors, mirroring derive_temperature_c''s shape (envelope latitude lerp elevation lapse seed nudge), integer/D-010:
- LATITUDE band: equator wetter pole drier (uses latitude_deg, already threaded for temperature).
- ELEVATION lapse: high elev_q drier (orographic/rain-shadow).
- CONTINENTALITY: far from water drier, via TerrainAnalysis.water_dist (Chebyshev cells-to-water, already computed in features.rs; aggregate a per-district mean in the existing derive_district_profile cell loop).
- small deterministic seed nudge (±N) for per-district variety.
Signature: derive_moisture_q(params, elev_q, ocean_fraction_q, water_dist_q). build_district_profile forwards them; derive_district_profile accumulates water_dist.
PROPAGATION: precipitation_class (derive_precipitation_class_from_climate) + vegetation_class (derive_vegetation) + morphology Wetland gate (slope_q<=5 && moisture_q>=60) all read moisture_q diversify automatically.
TUNING: add [moisture_gradient] to server/data/climate_constants.toml + matching ClimateConstants struct defaults (kept in sync by hand until T-1032 wires TOML loading, per the existing constraint).
DETERMINISM: integer (water_dist u16, integer division, clamp 0..100). D-010 ok.
VERIFY: believability harness gate moisture_q.distinct>=3 flips FAILPASS; vegetation-present should rise too. Magnitude calibration is Q-123 (provisional coefficients now). Files: district_profile.rs, climate_constants.toml.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-28 05:54:42.258', '2026-06-28 13:50:47.912', NULL, '4f5ee94f5fd8f5e447968619a520f915', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+1
View File
@@ -1777,6 +1777,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Implementation note (T-1024, 2026-06-07):** §1 per-body `RIVER_THRESHOLD` is now a derived field (`derive_river_threshold`) on `RegionProfile`. §2 district temperature is a nullable `f32` on `RegionProfile` sampled from a per-region climate derivation (`derive_temperature_c`); moisture is a separate integer primitive (`derive_moisture_q`, 0100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded).
- **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025).
- **Amendment (T-1082, 2026-06-28):** §5's family set grows from **8 to 9** — a `WaterBody` generator family is added for the water zones **OpenOcean / Lake / TidalFlat**, which previously fell through to the dry-land `AlluvialPlain` fallback (the D-245 believability bug: oceans rendered as dry forested land — `water=Dry` + `Forest` on the sea). `WaterBody` lays `Water::Deep`/`Shallow` (a Shallow shoal band on the district-anchored coast line `coast_anchor_m`, Deep beyond; TidalFlat is all-Shallow), a seabed `TerrainMaterial` (`Rock` on steep districts / `Sand` on gentle / `Wetland` for tidal mud), `Vegetation::Barren`, the water surface at `elevation_m = 0` (the §8 mouths-at-sea-level convention), and seasonal `Ice` via `derive_cover` (frozen seas). This **refines §6**: open ocean / lake / tidal flat are still derived zone *labels*, but their *generator* is now `WaterBody`, not `AlluvialPlain` — so §6's "tidal flat … not [a] distinct generator famil[y]" is superseded for the dispatch of those three zones. `Wetland` stays a land zone on `AlluvialPlain` (a marsh — saturated ground, not open water). Lives in `server/src/atlas/voxel.rs` (`MorphologyFamily::WaterBody` + `generate_water_body`, dispatched by `zone_to_family`); verified by the T-1083 believability harness (water-renders-wet flips PASS for Arbour + Edict). The §5 8-family decision-tree classifier (`derive_morphology_zone`) is unchanged — this is a voxel-tier *generator* family, selected by zone, not a new RegionProfile classifier branch.
- **Amendment (T-1080, 2026-06-28):** §2's **moisture** primitive is now a **body ceiling × per-district spatial gradient**, not a single body constant. The hydrosphere+atmosphere value (`derive_moisture_q`) is the *wettest* a district on the body can be; per-district **latitude** (equator wet → pole dry), **elevation** (orographic / rain-shadow), and **continentality** (`100 ocean_fraction_q`, coast wet → interior dry) subtract from it. Integer (D-010); coefficients in `ClimateConstants` / `climate_constants.toml` `[moisture_gradient]` (provisional, Q-123 calibrates). This mirrors D-240's latitude-graded *temperature* model and fixes the T-1080 believability bug (`moisture_q = 80` for all 2 048 districts → uniform vegetation/terrain — "nothing to fuzz"). It **propagates**: `precipitation_class`, `vegetation_class`, and the `morphology_zone` Wetland gate all read `moisture_q`, so those diversify for free. Lives in `district_profile.rs::derive_moisture_q`; verified by the T-1083 believability harness (moisture-gradient flips PASS, `distinct` 1 → ~49 on Arbour). The body-scale ceiling keeps each world's character; the gradient varies it within.
- **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract)
---
+13
View File
@@ -84,3 +84,16 @@ ice = 0.85 # frozen surface — slight moderation
subsurface_liquid = 0.9
subsurface = 0.95 # buried water — minimal surface effect
subsurface_ice = 0.95
# ---------------------------------------------------------------------------
[moisture_gradient]
# Per-district moisture spatial gradient (T-1080, D-239 §2). Integer points
# subtracted from the body's hydrosphere moisture *ceiling* so the field varies
# across the body instead of being a single constant (the bug: moisture_q = 80 for
# every district -> uniform vegetation/terrain). Wet at coast/lowland/tropics, drier
# toward interior/highland/poles. Provisional magnitudes — Q-123 calibrates. Mirrors
# the embedded ClimateConstants::default(); not yet loaded at runtime (TOML wiring is
# T-1032), kept in sync by hand like the other tables.
lat_penalty = 20 # lost equator->pole (x |latitude|/90)
elev_penalty = 15 # lost low->high elevation (x elev_q/100, rain-shadow)
continental_penalty = 20 # lost coast->interior (x (100 - ocean_fraction_q)/100)
+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,
+7 -7
View File
@@ -6,9 +6,9 @@
"voxel_sampled_districts": 64,
"contrast": {
"moisture_q": {
"min": 80,
"min": 32,
"max": 80,
"distinct": 1
"distinct": 49
},
"elev_q": {
"min": 0,
@@ -27,13 +27,13 @@
},
"morphology_zones": 9,
"vegetation_classes": 3,
"terrain_materials": 4
"terrain_materials": 3
},
"coherence": {
"water_districts": 46,
"water_districts_wet": 43,
"water_districts_wet": 44,
"drainage_samples": 10,
"drainage_monotonic": 8,
"drainage_monotonic": 9,
"vegetation_samples": 64,
"vegetated_districts": 11
}
@@ -45,9 +45,9 @@
"voxel_sampled_districts": 64,
"contrast": {
"moisture_q": {
"min": 20,
"min": 0,
"max": 20,
"distinct": 1
"distinct": 21
},
"elev_q": {
"min": 0,