From bb5069aa13ac4dfa8544a9974700fc6f29648e5f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 17:25:18 +0200 Subject: [PATCH] feat(simulation): district moisture spatial gradient (T-1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 22 +++++ .pql/changelog/tickets/2026-06.sql | 22 +++++ governance/decisions/architecture.md | 1 + server/data/climate_constants.toml | 13 +++ server/src/atlas/district_profile.rs | 114 +++++++++++++++++++--- server/src/atlas/region_profile.rs | 8 +- server/tests/golden/believability.json | 14 +-- 7 files changed, 171 insertions(+), 23 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 3b2602abd..e524e0934 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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 FAIL→PASS; 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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index bfb0aa5eb..db49e13a4 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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 FAIL→PASS; 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); diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index c8a025f60..4197cdc63 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -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`, 0–100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded). - **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025). - **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) --- diff --git a/server/data/climate_constants.toml b/server/data/climate_constants.toml index 864eed01d..5e9a8f5e0 100644 --- a/server/data/climate_constants.toml +++ b/server/data/climate_constants.toml @@ -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) diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 9affc8b53..2ab9b3045 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -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, + + // ── 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. diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 821e1de01..b05e12317 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -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, diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json index 08bb99dda..2cf428d91 100644 --- a/server/tests/golden/believability.json +++ b/server/tests/golden/believability.json @@ -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,