refactor(simulation): rename carrier RegionProfile->DistrictProfile + re-scale to the D-243 ladder (T-1077)

Phase 2 of the D-243 re-scale. The ~1km carrier is renamed and re-scaled to
the 2km district, and every consumer reads scale from the canonical scale.rs
instead of a local literal.

- region_profile.rs -> district_profile.rs; RegionProfile -> DistrictProfile,
  RegionPos -> DistrictPos (re-exported from scale::DistrictPos), derive_*_regions
  -> derive_*_districts, across all 10 consumers + the derivation harness.
- chunk_context now references scale:: for the carrier cell: district 1024m -> 2048m,
  shift 4 -> 5 (CHUNK_DISTRICT_SHIFT). This caught a real latent bug: the seed-district
  index used a literal '>> 4' while the anchor used the constant, so same-district
  chunks could derive different anchors — now both use scale::CHUNK_DISTRICT_SHIFT.
- derivation_harness scale literals ('>> 4', '* 1024', '0..16') converted to
  scale:: constants (the same drift the canonical source eliminates). Believability
  tests pass at 2km; the voxel determinism golden re-pinned (anchors moved with the
  re-scale — deterministic, intended).

Full suite + clippy --all-targets -D warnings green. The district DERIVATION still
tiles the heightmap at 8 cells (the old coarse path); replacing it with on-demand
heightmap interpolation + detail-scatter is phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 22:53:18 +02:00
co-authored by Claude Opus 4.8
parent 38054d4b12
commit b3b5bfc2e3
12 changed files with 945 additions and 886 deletions
+23 -11
View File
@@ -1,9 +1,9 @@
//! Pre-dispatch reader for body physical parameters (T-1032, D-239 §1).
//!
//! Reads the columns needed to construct a [`BodyParams`] for the RegionProfile
//! Reads the columns needed to construct a [`BodyParams`] for the DistrictProfile
//! carrier layer from `systems.db` at `AnalyzeBody` enqueue time. This keeps
//! the Rayon work item DB-free while supplying the climate/tectonic inputs
//! required by `derive_all_regions`.
//! required by `derive_all_districts`.
//!
//! **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`,
//! `spectral_class`, `star_type`) are non-canonical placeholder data — they are
@@ -23,8 +23,8 @@
//!
//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity`
//! is left `None` so the derivation falls back to `planet_class` as documented
//! on the struct. The per-region fields `region_latitude_deg` and `elevation_km`
//! are set by `derive_all_regions` / `derive_region_profile`, not here; they
//! on the struct. The per-district fields `district_latitude_deg` and `elevation_km`
//! are set by `derive_all_districts` / `derive_district_profile`, not here; they
//! remain at their struct defaults (0.0) from this reader.
//!
//! Read-only `systems.db` access follows the same pattern as
@@ -37,7 +37,7 @@ use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OpenFlags};
use thiserror::Error;
use crate::atlas::region_profile::BodyParams;
use crate::atlas::district_profile::BodyParams;
// ---------------------------------------------------------------------------
// Error type
@@ -119,9 +119,9 @@ impl BodyParamsReader {
planet_class,
// tectonic_activity not in schema — leave None.
tectonic_activity: None,
// Per-region fields are set by derive_all_regions / derive_region_profile,
// Per-district fields are set by derive_all_districts / derive_district_profile,
// not at the body level. Leave at struct defaults (0.0).
region_latitude_deg: 0.0,
district_latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km,
}),
@@ -185,7 +185,13 @@ mod tests {
conn.execute(
"INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class, body_radius_km)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![body_id, hydrosphere, atmosphere, planet_class, body_radius_km],
rusqlite::params![
body_id,
hydrosphere,
atmosphere,
planet_class,
body_radius_km
],
)
.expect("insert body");
@@ -209,8 +215,8 @@ mod tests {
assert_eq!(params.atmosphere.as_deref(), Some("breathable"));
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
assert_eq!(params.body_radius_km, Some(6371.0));
// Per-region fields always start at 0.0 from the reader.
assert_eq!(params.region_latitude_deg, 0.0);
// Per-district fields always start at 0.0 from the reader.
assert_eq!(params.district_latitude_deg, 0.0);
assert_eq!(params.elevation_km, 0.0);
// tectonic_activity not in schema → None.
assert!(params.tectonic_activity.is_none());
@@ -238,7 +244,13 @@ mod tests {
#[test]
fn read_is_deterministic() {
let db = make_test_db("GJ4d", Some("ice"), Some("thin"), Some("frozen"), Some(3389.5));
let db = make_test_db(
"GJ4d",
Some("ice"),
Some("thin"),
Some("frozen"),
Some(3389.5),
);
let reader = BodyParamsReader::open(&db).expect("open");
let p1 = reader.read_body_params("GJ4d").expect("first read");
let p2 = reader.read_body_params("GJ4d").expect("second read");
+6 -6
View File
@@ -14,7 +14,7 @@ use bevy_ecs::prelude::Resource;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::region_profile::{RegionPos, RegionProfile};
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
use crate::atlas::road_graph::RoadGraph;
use crate::simulation::generator::{
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
@@ -94,12 +94,12 @@ pub struct BodyWorldState {
/// Populated by `GenCompletion::SkeletonGenerated` after the plan phase
/// completes for each city. `BTreeMap` for D-010 determinism.
pub quarters: BTreeMap<QuarterId, QuarterWorldState>,
/// Per-region (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1).
/// Per-district (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1).
///
/// Populated by the background cascade after Layer 1 completes.
/// `BTreeMap` keyed by `RegionPos` for D-010 determinism.
/// Empty until the RegionProfile layer has run.
pub regions: BTreeMap<RegionPos, RegionProfile>,
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
/// Empty until the DistrictProfile layer has run.
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
/// Last sim tick this entry was read. Used for LRU eviction.
pub last_accessed: SimTick,
}
@@ -223,7 +223,7 @@ mod tests {
placements: vec![],
road_graph: RoadGraph::default(),
quarters: BTreeMap::new(),
regions: BTreeMap::new(),
districts: BTreeMap::new(),
last_accessed: tick,
}
}
+42 -39
View File
@@ -24,10 +24,10 @@ use crate::atlas::attractor_matching::{
match_cities, territorial_status_from_faction, CityPlacement, CityRecord,
};
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProfile};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile};
use crate::atlas::road_graph::{self, RoadGraph};
use crate::seed::SeedChain;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
@@ -46,15 +46,15 @@ pub enum CascadeLayer {
/// `match_cities`. The carried `SeedChain` is unused here; later stochastic
/// layers (Layer 4+) will consume it.
Settlement,
/// Layer — RegionProfile (~1 km carriers, D-239 §1, T-1023). Pure function of
/// Layer — DistrictProfile (~1 km carriers, D-239 §1, T-1023). Pure function of
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
/// declaration order (= Ord) is preserved — never reorder (D-010).
RegionProfile,
DistrictProfile,
/// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function
/// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer
/// 2", but it depends only on Settlement + Topography, so it is **appended
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
/// the RegionProfile layer; requesting it runs RegionProfile first, harmlessly).
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
RoadGraph,
}
@@ -74,19 +74,19 @@ pub struct CascadeSnapshot {
pub layer1: Option<Layer1Output>,
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
pub layer3: Option<Layer3Output>,
/// RegionProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
pub layer_region: Option<LayerRegionOutput>,
/// DistrictProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::DistrictProfile`] has run (T-1023, D-239 §1).
pub layer_district: Option<LayerDistrictOutput>,
/// Layer 2 — inter-settlement road/rail graph. `Some` once
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
pub road_graph: Option<RoadGraph>,
}
/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain
/// profiles covering the whole body. Stored in `BodyWorldState.regions`.
/// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain
/// profiles covering the whole body. Stored in `BodyWorldState.districts`.
#[derive(Debug, Clone, Default)]
pub struct LayerRegionOutput {
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
pub struct LayerDistrictOutput {
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
}
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
@@ -107,7 +107,10 @@ impl CascadeSnapshot {
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
let districts = self
.layer_district
.map(|lr| lr.districts)
.unwrap_or_default();
let road_graph = self.road_graph.unwrap_or_default();
BodyWorldState {
body_id: self.body_id,
@@ -120,7 +123,7 @@ impl CascadeSnapshot {
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
regions,
districts,
last_accessed: 0,
}
}
@@ -168,8 +171,8 @@ fn run_layer3(
/// `dominant_faction` is the body's authored system faction (D-237); it drives
/// the `TerritorialStatus` on each province and the per-settlement spatial
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the RegionProfile
/// layer (T-1023); `None` → region layer skips (empty `regions` map).
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
@@ -184,7 +187,7 @@ pub fn run_cascade_from_heightmap(
heightmap,
layer1: None,
layer3: None,
layer_region: None,
layer_district: None,
road_graph: None,
};
@@ -223,11 +226,11 @@ pub fn run_cascade_from_heightmap(
snapshot.layer3 = Some(l3);
}
// RegionProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a
// DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a
// TerrainAnalysis, which needs a drainage pass. Layer 1 already ran drainage
// inside run_layer1, but neither result is stored on Layer1Output, so we re-run
// both here once and share them. Pure → determinism preserved, but the drainage
// re-run is NOT free at the ~6 000-regions/body working scale (D-203).
// re-run is NOT free at the ~6 000-districts/body working scale (D-203).
// PERF/TODO(T-1044): cache TerrainAnalysis on Layer1Output to drop this
// redundant drainage pass, and validate the combined cost against the D-239 §10
// ~45 ms/body budget in the T-1031 verification harness. This is a LIVE
@@ -235,10 +238,10 @@ pub fn run_cascade_from_heightmap(
// body runs this path.
//
// The terrain analysis is computed only when it will actually be used:
// body_params present (RegionProfile) or RoadGraph requested. RegionProfile
// body_params present (DistrictProfile) or RoadGraph requested. DistrictProfile
// skips silently without body_params (e.g. unit tests without DB), but the
// road graph needs no body params, so RoadGraph runs regardless.
if up_to >= CascadeLayer::RegionProfile
if up_to >= CascadeLayer::DistrictProfile
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
{
use crate::atlas::drainage;
@@ -250,14 +253,14 @@ pub fn run_cascade_from_heightmap(
);
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
// RegionProfile layer — pure derivation from body params + terrain.
// DistrictProfile layer — pure derivation from body params + terrain.
if let Some(params) = body_params {
// ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions;
// ~8 cells per district on a 128×64 working grid → ~80×32 = ~2 560 districts;
// at full working resolution the budget is ~6 000/body (D-203).
const CELLS_PER_REGION: usize = 8;
let regions =
region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION);
snapshot.layer_region = Some(LayerRegionOutput { regions });
let districts =
district_profile::derive_all_districts(body_seed, params, &ta, CELLS_PER_REGION);
snapshot.layer_district = Some(LayerDistrictOutput { districts });
}
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
@@ -417,8 +420,8 @@ mod tests {
// breaking which layers run.
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile);
assert!(CascadeLayer::RegionProfile < CascadeLayer::RoadGraph);
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
}
#[test]
@@ -437,10 +440,10 @@ mod tests {
assert!(res.is_err(), "missing heightmap must Err, not panic");
}
/// RegionProfile layer runs, produces regions, and is deterministic (T-1023).
/// DistrictProfile layer runs, produces districts, and is deterministic (T-1023).
#[test]
fn region_profile_layer_runs_and_is_deterministic() {
use crate::atlas::region_profile::BodyParams;
fn district_profile_layer_runs_and_is_deterministic() {
use crate::atlas::district_profile::BodyParams;
let params = BodyParams {
hydrosphere: Some("ocean".into()),
@@ -455,22 +458,22 @@ mod tests {
&[],
None,
Some(&params),
CascadeLayer::RegionProfile,
CascadeLayer::DistrictProfile,
)
};
let snap1 = run();
let snap2 = run();
let lr1 = snap1.layer_region.expect("layer_region should be Some");
let lr2 = snap2.layer_region.expect("layer_region should be Some");
assert!(!lr1.regions.is_empty(), "regions map must not be empty");
let lr1 = snap1.layer_district.expect("layer_district should be Some");
let lr2 = snap2.layer_district.expect("layer_district should be Some");
assert!(!lr1.districts.is_empty(), "districts map must not be empty");
assert_eq!(
lr1.regions.len(),
lr2.regions.len(),
"region count deterministic"
lr1.districts.len(),
lr2.districts.len(),
"district count deterministic"
);
// BTreeMap iteration order is deterministic — compare all entries.
for (pos, p1) in &lr1.regions {
let p2 = lr2.regions.get(pos).expect("matching pos in second run");
for (pos, p1) in &lr1.districts {
let p2 = lr2.districts.get(pos).expect("matching pos in second run");
assert_eq!(p1.river_threshold, p2.river_threshold);
assert_eq!(p1.tectonic_class, p2.tectonic_class);
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
+153 -148
View File
@@ -1,7 +1,7 @@
//! ChunkContext — 64 m carrier of the D-239 three-tier refinement chain (T-1028).
//!
//! `ChunkContext` is the middle tier: coarser than a voxel (1 m), finer than a
//! region (~1 km). It is derived purely from the covering `RegionProfile`(s) and
//! district (2 km, D-243). It is derived purely from the covering `DistrictProfile`(s) and
//! the world seed — no stored state, no side effects.
//!
//! ## Scale contract (D-239 §10)
@@ -10,18 +10,18 @@
//! **coarser than a chunk** — so there is no per-tile `flow_direction[64×64]`
//! here. Instead the chunk carries:
//! - **`basin_direction`** — a cardinal basin-flow direction (N/E/S/W, 4-way)
//! derived from the dominant D8 thalweg in the covering region.
//! derived from the dominant D8 thalweg in the covering district.
//! - **`meander_phase`** and **`meander_wavelength_m`** — global meander-curve
//! params for the MeanderReach and AlluvialPlain families.
//!
//! Features with wavelength > 64 m seed from **Region-or-higher** (the covering
//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure:
//! Features with wavelength > 64 m seed from **District-or-higher** (the covering
//! `DistrictProfile`), not from the chunk-local seed. This is enforced by structure:
//! the chunk seed is only used for sub-chunk (<64 m) scatter.
//!
//! The same rule places the feature axes themselves (T-1040/T-1041): the channel
//! centreline and landform axis (`channel_anchor_m`) and the coast-face line
//! (`coast_anchor_m`) are **world-metre coordinates derived once per region**
//! from the region-scale seed. Voxel generators measure distance to these
//! (`coast_anchor_m`) are **world-metre coordinates derived once per district**
//! from the district-scale seed. Voxel generators measure distance to these
//! anchors in continuous world coordinates — never from the world origin and
//! never folded into the 64 m chunk frame.
//!
@@ -39,7 +39,8 @@
use serde::{Deserialize, Serialize};
use crate::atlas::region_profile::RegionProfile;
use crate::atlas::district_profile::DistrictProfile;
use crate::atlas::scale;
use crate::seed::{SeedChain, SeedDomain};
// ---------------------------------------------------------------------------
@@ -47,7 +48,7 @@ use crate::seed::{SeedChain, SeedDomain};
// ---------------------------------------------------------------------------
/// Cardinal basin-flow direction — the dominant D8 thalweg direction in the
/// covering region. Coarser than a chunk (D-239 §10); derived from region slope
/// covering district. Coarser than a chunk (D-239 §10); derived from district slope
/// and morphology, NOT from a per-tile D8 grid.
///
/// Integer-discriminant, append-only (D-010).
@@ -69,30 +70,21 @@ pub enum BasinDirection {
// ChunkPos — position on the 64 m chunk grid
// ---------------------------------------------------------------------------
/// Position of a chunk on the 64 m grid, in chunk-units (not metres).
///
/// A body at ~6 000 regions, each region ~1 km², gets ~1 000 × 1 000 chunks.
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
pub type ChunkPos = (i32, i32);
/// Chunk grid position + the chunk edge metres come from the canonical ladder
/// ([`crate::atlas::scale`], D-243) — chunk_context no longer defines its own
/// scale (the Q-110 failure mode). The covering district is `scale::DISTRICT_M`
/// = 2 048 m = `scale::CHUNKS_PER_DISTRICT` (32) chunks; the district-index
/// mapping is `chunk >> scale::CHUNK_DISTRICT_SHIFT`.
pub use crate::atlas::scale::{ChunkPos, CHUNK_M};
/// Chunk edge length in metres.
pub const CHUNK_M: i32 = 64;
/// Region edge length in chunks (region ≈ 1 km = 16 chunks). Must match the
/// `>> REGION_CHUNKS_SHIFT` region-index mapping used for the region-scale seed.
const REGION_CHUNKS_SHIFT: u32 = 4;
/// Region edge length in metres (1 024 m).
const REGION_M: i32 = CHUNK_M << REGION_CHUNKS_SHIFT;
/// Margin keeping a region's feature anchor away from the region edge, so the
/// Margin keeping a district's feature anchor away from the district edge, so the
/// channel's full swept band (max meander amplitude wavelength/4 ≈ 162 m +
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the region.
/// Cross-region feature continuity is the stage-2 Voronoi model (T-1040).
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the district.
/// Cross-district feature continuity is the stage-2 Voronoi model (T-1040).
const ANCHOR_MARGIN_M: i32 = 192;
/// Seed-addressable anchor span within a region (REGION_M 2 × margin).
const ANCHOR_SPAN_M: i32 = REGION_M - 2 * ANCHOR_MARGIN_M;
/// Seed-addressable anchor span within a district (`scale::DISTRICT_M` 2 × margin).
const ANCHOR_SPAN_M: i32 = scale::DISTRICT_M - 2 * ANCHOR_MARGIN_M;
/// Maximum levee band width in metres (`voxel::in_levee_band`: 4 + 3 jitter).
const LEVEE_BAND_MAX_M: i32 = 7;
@@ -105,9 +97,9 @@ const WARP_BOUND_M: i32 = 8;
// ChunkContext
// ---------------------------------------------------------------------------
/// 64 m carrier derived from `RegionProfile`(s) — the second tier of D-239 §1.
/// 64 m carrier derived from `DistrictProfile`(s) — the second tier of D-239 §1.
///
/// Pure deterministic function of `(seed, body_id, region, chunk_pos)`.
/// Pure deterministic function of `(seed, body_id, district, chunk_pos)`.
/// Never stored; derived on demand and cached (D-227).
///
/// ## Fields
@@ -116,15 +108,15 @@ const WARP_BOUND_M: i32 = 8;
/// - `meander_phase` — integer phase offset (0255) for the meander curve.
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
/// region-level morphology (slope, moisture), seeded at region scale (> 64 m).
/// district-level morphology (slope, moisture), seeded at district scale (> 64 m).
/// f64 for positional physics (D-239 §4); structural decisions consume it
/// only via deterministic i32 truncation (the `has_active_channel` band).
/// - `has_active_channel` — whether a water channel is present in this chunk:
/// the region has water presence AND the channel's swept band around
/// the district has water presence AND the channel's swept band around
/// `channel_anchor_m` crosses this chunk (T-1040).
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
/// active channel.
/// - `channel_anchor_m` / `coast_anchor_m` — region-anchored feature axes in
/// - `channel_anchor_m` / `coast_anchor_m` — district-anchored feature axes in
/// world metres (T-1040/T-1041, D-239 §10).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkContext {
@@ -132,34 +124,34 @@ pub struct ChunkContext {
pub basin_direction: BasinDirection,
/// Integer phase offset for the meander curve (0255).
/// Derived at region scale (wavelength > 64 m), NOT from the chunk seed.
/// Derived at district scale (wavelength > 64 m), NOT from the chunk seed.
pub meander_phase: u8,
/// Meander wavelength in metres. Positional physics value (f64 — D-239 §4).
/// Derived at region scale; typically 80500 m for AlluvialPlain.
/// Derived at district scale; typically 80500 m for AlluvialPlain.
pub meander_wavelength_m: f64,
/// Whether this chunk contains an active water channel.
/// True when the region has water presence (`ocean_fraction_q` ≥ 10) AND
/// True when the district has water presence (`ocean_fraction_q` ≥ 10) AND
/// the channel's swept band around `channel_anchor_m` crosses this chunk's
/// cross-axis range (T-1040 — channels exist where the region says, not
/// region-wide and not only at the world origin).
/// cross-axis range (T-1040 — channels exist where the district says, not
/// district-wide and not only at the world origin).
pub has_active_channel: bool,
/// Active channel width in metres (integer; D-010). 0 if no active channel.
/// Derived from region morphology and slope.
/// Derived from district morphology and slope.
pub channel_width_m: i32,
/// Cross-axis world-metre coordinate of the region's feature centreline:
/// Cross-axis world-metre coordinate of the district's feature centreline:
/// channel/meander axis, fjord trough, gorge floor, braid-fan axis.
/// Derived once per region from the region-scale seed (T-1040/T-1041,
/// D-239 §10) — constant across all chunks of a region, so the feature is
/// Derived once per district from the district-scale seed (T-1040/T-1041,
/// D-239 §10) — constant across all chunks of a district, so the feature is
/// continuous across chunk boundaries. Cross axis = x for N/S basins,
/// y for E/W basins.
pub channel_anchor_m: i32,
/// Along-axis (basin-axis) world-metre coordinate of the CliffCoast face
/// line. Region-scale (T-1041): one continuous coast per region, not a
/// line. District-scale (T-1041): one continuous coast per district, not a
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
pub coast_anchor_m: i32,
}
@@ -170,59 +162,65 @@ pub struct ChunkContext {
/// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid.
///
/// Pure function of `(seed, body_id, region, chunk_pos)`. Takes the covering
/// region's `RegionProfile`; in the future a blend of adjacent profiles will
/// handle cross-region chunk seams, but for the walking skeleton one profile
/// Pure function of `(seed, body_id, district, chunk_pos)`. Takes the covering
/// district's `DistrictProfile`; in the future a blend of adjacent profiles will
/// handle cross-district chunk seams, but for the walking skeleton one profile
/// is sufficient.
///
/// ## Seed usage
///
/// The **meander phase** and **basin direction** are seeded at region scale so
/// The **meander phase** and **basin direction** are seeded at district scale so
/// features with wavelength > 64 m are consistent across chunk boundaries.
/// The chunk-local seed (keyed on `chunk_pos`) is reserved for sub-chunk scatter
/// in the voxel pass — not consumed here.
pub fn derive_chunk_context(
world_seed: u64,
body_id: &str,
region: &RegionProfile,
district: &DistrictProfile,
chunk_pos: ChunkPos,
) -> ChunkContext {
// Region-scale seed — features with wavelength > 64 m derive from here.
// Keyed on the chunk position mapped to region-scale units (>> 4 gives
// the ~1 km region index if a region is ~16 chunks wide).
let region_scale_id = pos_to_id((chunk_pos.0 >> 4, chunk_pos.1 >> 4));
let region_seed =
SeedChain::for_body(world_seed, body_id).derive(SeedDomain::ChunkContext, region_scale_id);
// District-scale seed — features with wavelength > 64 m derive from here.
// Keyed on the chunk position mapped to district-scale units: `>>
// scale::CHUNK_DISTRICT_SHIFT` gives the 2 km district index (32 chunks).
// MUST match the same shift in `derive_district_anchor` so the seed-district
// and the anchor-district are the same cell (D-243; the canonical ladder).
let district_scale_id = pos_to_id((
chunk_pos.0 >> scale::CHUNK_DISTRICT_SHIFT,
chunk_pos.1 >> scale::CHUNK_DISTRICT_SHIFT,
));
let district_seed = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::ChunkContext, district_scale_id);
// Basin direction — derived from region slope_q (which encodes the
// dominant terrain gradient). We use the region's `elev_q` gradient
// Basin direction — derived from district slope_q (which encodes the
// dominant terrain gradient). We use the district's `elev_q` gradient
// direction as a proxy for the D8 thalweg direction.
// All integer arithmetic (D-010).
let basin_direction = derive_basin_direction(region, region_seed.seed());
let basin_direction = derive_basin_direction(district, district_seed.seed());
// Meander phase — region-scale integer offset so the channel is consistent
// across all chunks in the same region. 0255.
let meander_phase = (region_seed.seed() >> 8) as u8;
// Meander phase — district-scale integer offset so the channel is consistent
// across all chunks in the same district. 0255.
let meander_phase = (district_seed.seed() >> 8) as u8;
// Meander wavelength — derived from slope and morphology, region-scale.
// Meander wavelength — derived from slope and morphology, district-scale.
// Lower slope → longer wavelength (wider meanders); integer inputs, f64 result
// is positional physics (D-239 §4, not a gate comparison).
let meander_wavelength_m = derive_meander_wavelength(region);
let meander_wavelength_m = derive_meander_wavelength(district);
// Region-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
// District-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
// landform centrelines have wavelength > 64 m, so their position derives
// from the region-scale seed — never the chunk frame or the world origin.
// from the district-scale seed — never the chunk frame or the world origin.
// Cross axis ⊥ basin_direction (x for N/S, y for E/W); along axis ∥ basin.
let (cross_chunk, along_chunk) = match basin_direction {
BasinDirection::North | BasinDirection::South => (chunk_pos.0, chunk_pos.1),
BasinDirection::East | BasinDirection::West => (chunk_pos.1, chunk_pos.0),
};
let channel_anchor_m = derive_region_anchor(cross_chunk, (region_seed.seed() >> 16) & 0xFFFF);
let coast_anchor_m = derive_region_anchor(along_chunk, (region_seed.seed() >> 32) & 0xFFFF);
let channel_anchor_m =
derive_district_anchor(cross_chunk, (district_seed.seed() >> 16) & 0xFFFF);
let coast_anchor_m = derive_district_anchor(along_chunk, (district_seed.seed() >> 32) & 0xFFFF);
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
// perennial waterway or water body covers at least 10% of the region) AND
// the channel's swept band around the region anchor crosses this chunk
// perennial waterway or water body covers at least 10% of the district) AND
// the channel's swept band around the district anchor crosses this chunk
// (T-1040). The river_threshold governs D8 drainage accumulation at a finer
// scale; at the chunk level ocean_fraction_q is the direct proxy for water
// presence (D-239 §10: one basin-direction, not a per-tile flow grid).
@@ -230,8 +228,8 @@ pub fn derive_chunk_context(
// The band is generous (it must cover every chunk that can contain channel,
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
let channel_width = derive_channel_width(region);
let has_active_channel = region.ocean_fraction_q >= 10 && {
let channel_width = derive_channel_width(district);
let has_active_channel = district.ocean_fraction_q >= 10 && {
let wavelength_i = (meander_wavelength_m as i32).max(10);
let amplitude_max = (wavelength_i / 4).max(3);
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
@@ -259,18 +257,18 @@ pub fn derive_chunk_context(
}
}
/// World-metre anchor coordinate for a region-scale feature axis on one axis.
/// World-metre anchor coordinate for a district-scale feature axis on one axis.
///
/// The anchor sits in `[region_origin + ANCHOR_MARGIN_M, region_origin +
/// REGION_M ANCHOR_MARGIN_M)` so the feature's full swept band stays inside
/// its region (no cross-region band spill; region-seam continuity is the
/// stage-2 Voronoi model, T-1040). Same value for every chunk of the region:
/// the region index is `axis_chunk >> REGION_CHUNKS_SHIFT` (arithmetic shift =
/// floor division, correct for negative chunks) and `seed_bits` comes from the
/// shared region-scale seed. Integer arithmetic (D-010).
fn derive_region_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
let region_idx = axis_chunk >> REGION_CHUNKS_SHIFT;
let origin_m = region_idx * REGION_M;
/// The anchor sits in `[district_origin + ANCHOR_MARGIN_M, district_origin +
/// scale::DISTRICT_M ANCHOR_MARGIN_M)` so the feature's full swept band stays
/// inside its district (no cross-district band spill; district-seam continuity is
/// the stage-2 Voronoi model, T-1040). Same value for every chunk of the district:
/// the district index is `axis_chunk >> scale::CHUNK_DISTRICT_SHIFT` (arithmetic
/// shift = floor division, correct for negative chunks) and `seed_bits` comes from
/// the shared district-scale seed. Integer arithmetic (D-010).
fn derive_district_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
let district_idx = axis_chunk >> scale::CHUNK_DISTRICT_SHIFT;
let origin_m = district_idx * scale::DISTRICT_M;
origin_m + ANCHOR_MARGIN_M + (seed_bits % ANCHOR_SPAN_M as u64) as i32
}
@@ -292,22 +290,22 @@ pub(crate) fn pos_to_id(pos: (i32, i32)) -> u64 {
.wrapping_add(y)
}
/// Derive basin direction from the region profile and a region-scale seed.
/// Derive basin direction from the district profile and a district-scale seed.
///
/// Uses `elev_q` and `slope_q` as a proxy for the dominant D8 gradient
/// direction. In lieu of a full D8 computation at this scale, the basin
/// direction is derived from the region's terrain characteristics:
/// - Coastal regions (high `ocean_fraction_q`) flow toward ocean (West fallback)
/// - High-elevation regions flow away from ridges (seed-derived direction)
/// - Low-slope regions use the seed for unbiased direction
/// direction is derived from the district's terrain characteristics:
/// - Coastal districts (high `ocean_fraction_q`) flow toward ocean (West fallback)
/// - High-elevation districts flow away from ridges (seed-derived direction)
/// - Low-slope districts use the seed for unbiased direction
///
/// All integer arithmetic (D-010).
fn derive_basin_direction(region: &RegionProfile, region_seed: u64) -> BasinDirection {
fn derive_basin_direction(district: &DistrictProfile, district_seed: u64) -> BasinDirection {
// Coastal: flow toward the ocean (use seed to pick E/W/N/S with coastal bias).
if region.ocean_fraction_q >= 15 {
if district.ocean_fraction_q >= 15 {
// The low 2 bits of seed give 4 directions; bias toward the most common
// coastal configurations (N or S for equatorial coasts, E/W for shelf).
return match (region_seed >> 2) & 0x3 {
return match (district_seed >> 2) & 0x3 {
0 => BasinDirection::South,
1 => BasinDirection::East,
2 => BasinDirection::North,
@@ -316,7 +314,7 @@ fn derive_basin_direction(region: &RegionProfile, region_seed: u64) -> BasinDire
}
// Interior: pure seed-derived direction (unbiased).
match region_seed & 0x3 {
match district_seed & 0x3 {
0 => BasinDirection::North,
1 => BasinDirection::East,
2 => BasinDirection::South,
@@ -324,31 +322,31 @@ fn derive_basin_direction(region: &RegionProfile, region_seed: u64) -> BasinDire
}
}
/// Derive meander wavelength in metres from region morphology.
/// Derive meander wavelength in metres from district morphology.
///
/// Low slope + high moisture → longer wavelength (wide meanders).
/// High slope → short wavelength (confined/straight channels).
/// Result is f64 positional physics (D-239 §4), not used in gate comparisons.
fn derive_meander_wavelength(region: &RegionProfile) -> f64 {
fn derive_meander_wavelength(district: &DistrictProfile) -> f64 {
// Base wavelength range: 80500 m for AlluvialPlain.
// slope_q 0 → 500 m; slope_q 100 → 80 m. Linear interpolation.
let slope_clamped = region.slope_q.clamp(0, 100) as f64;
let slope_clamped = district.slope_q.clamp(0, 100) as f64;
let base = 500.0 - (slope_clamped / 100.0) * 420.0;
// Moisture boost: high moisture → slightly longer wavelength (more sinuous).
let moisture_factor = 1.0 + (region.moisture_q.clamp(0, 100) as f64 / 100.0) * 0.3;
let moisture_factor = 1.0 + (district.moisture_q.clamp(0, 100) as f64 / 100.0) * 0.3;
base * moisture_factor
}
/// Derive active channel width in metres from region morphology.
/// Derive active channel width in metres from district morphology.
///
/// Returns an integer metre value (D-010).
fn derive_channel_width(region: &RegionProfile) -> i32 {
fn derive_channel_width(district: &DistrictProfile) -> i32 {
// Water presence drives width; ocean_fraction_q is our proxy.
// Meander channels: 315 m (D-239 §9 game-feel constraint).
// We derive in that range from ocean_fraction_q.
let base = match region.ocean_fraction_q {
let base = match district.ocean_fraction_q {
0..=9 => 3,
10..=19 => 5,
20..=34 => 8,
@@ -356,7 +354,7 @@ fn derive_channel_width(region: &RegionProfile) -> i32 {
_ => 15,
};
// Slope modifier: high slope → narrower (gorge-like); low slope → wider.
let slope_penalty = (region.slope_q / 20).min(3);
let slope_penalty = (district.slope_q / 20).min(3);
(base - slope_penalty).max(3)
}
@@ -367,13 +365,13 @@ fn derive_channel_width(region: &RegionProfile) -> i32 {
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::region_profile::{
use crate::atlas::district_profile::{
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
};
use crate::simulation::generator::MorphologyZone;
fn alluvial_region() -> RegionProfile {
RegionProfile {
fn alluvial_district() -> DistrictProfile {
DistrictProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
@@ -388,10 +386,10 @@ mod tests {
}
}
/// The chunk of region (0, 0) whose cross-range contains the region's
/// The chunk of district (0, 0) whose cross-range contains the district's
/// channel anchor — guaranteed inside the T-1040 channel band.
fn anchor_chunk_pos(world_seed: u64, body_id: &str, region: &RegionProfile) -> ChunkPos {
let probe = derive_chunk_context(world_seed, body_id, region, (0, 0));
fn anchor_chunk_pos(world_seed: u64, body_id: &str, district: &DistrictProfile) -> ChunkPos {
let probe = derive_chunk_context(world_seed, body_id, district, (0, 0));
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (idx, 0),
@@ -401,9 +399,9 @@ mod tests {
#[test]
fn derive_chunk_context_is_deterministic() {
let region = alluvial_region();
let a = derive_chunk_context(42, "GJ1c", &region, (10, 20));
let b = derive_chunk_context(42, "GJ1c", &region, (10, 20));
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (10, 20));
let b = derive_chunk_context(42, "GJ1c", &district, (10, 20));
assert_eq!(a.basin_direction, b.basin_direction);
assert_eq!(a.meander_phase, b.meander_phase);
assert_eq!(a.meander_wavelength_m, b.meander_wavelength_m);
@@ -413,10 +411,10 @@ mod tests {
#[test]
fn different_positions_yield_different_phases() {
let region = alluvial_region();
let a = derive_chunk_context(42, "GJ1c", &region, (0, 0));
let b = derive_chunk_context(42, "GJ1c", &region, (200, 100));
// Different region-scale ids → different phases (high probability).
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (0, 0));
let b = derive_chunk_context(42, "GJ1c", &district, (200, 100));
// Different district-scale ids → different phases (high probability).
assert_ne!(
a.meander_phase, b.meander_phase,
"chunks far apart should differ in meander phase"
@@ -424,31 +422,31 @@ mod tests {
}
#[test]
fn alluvial_region_has_active_channel() {
let region = alluvial_region();
// T-1040: the channel is region-anchored — the chunk under the anchor
fn alluvial_district_has_active_channel() {
let district = alluvial_district();
// T-1040: the channel is district-anchored — the chunk under the anchor
// must claim it (ocean_fraction_q=15 → water present).
let pos = anchor_chunk_pos(42, "GJ1c", &region);
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
assert!(
ctx.has_active_channel,
"anchor-covering chunk of a watered region must have active channel"
"anchor-covering chunk of a watered district must have active channel"
);
}
#[test]
fn chunk_outside_channel_band_has_no_active_channel() {
// T-1040: channels are region-anchored, not region-wide. A chunk whose
// T-1040: channels are district-anchored, not district-wide. A chunk whose
// cross-range lies outside the channel's swept band (max reach < 192 m
// = 3 chunks) must not claim a channel — pre-fix every chunk of a
// watered region did, while voxels rendered dry floodplain.
let region = alluvial_region();
// watered district did, while voxels rendered dry floodplain.
let district = alluvial_district();
let (anchor_pos, probe) = {
let pos = anchor_chunk_pos(42, "GJ1c", &region);
(pos, derive_chunk_context(42, "GJ1c", &region, pos))
let pos = anchor_chunk_pos(42, "GJ1c", &district);
(pos, derive_chunk_context(42, "GJ1c", &district, pos))
};
// 8 cross-chunks away (512 m) is past any band reach but still inside
// region (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
// district (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
let anchor_idx = anchor_pos.0.max(anchor_pos.1);
let far_idx = if anchor_idx < 8 {
anchor_idx + 8
@@ -459,7 +457,7 @@ mod tests {
BasinDirection::North | BasinDirection::South => (far_idx, 0),
BasinDirection::East | BasinDirection::West => (0, far_idx),
};
let far_ctx = derive_chunk_context(42, "GJ1c", &region, far_pos);
let far_ctx = derive_chunk_context(42, "GJ1c", &district, far_pos);
assert!(
!far_ctx.has_active_channel,
"chunk {far_pos:?} outside the channel band must not claim a channel"
@@ -468,41 +466,48 @@ mod tests {
far_ctx.channel_width_m, 0,
"no active channel → channel_width_m must be 0"
);
// Region-scale params stay constant across the region's chunks.
// District-scale params stay constant across the district's chunks.
assert_eq!(far_ctx.channel_anchor_m, probe.channel_anchor_m);
assert_eq!(far_ctx.meander_phase, probe.meander_phase);
}
#[test]
fn anchors_constant_within_region_and_inside_it() {
// T-1040/T-1041: feature anchors are a region property — identical for
// every chunk of the region, and positioned inside the region's extent.
let region = alluvial_region();
let base = derive_chunk_context(42, "GJ1c", &region, (0, 0));
// T-1040/T-1041: feature anchors are a district property — identical for
// every chunk of the district, and positioned inside the district's extent.
let district = alluvial_district();
let base = derive_chunk_context(42, "GJ1c", &district, (0, 0));
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
assert_eq!(
ctx.channel_anchor_m, base.channel_anchor_m,
"channel anchor must be region-constant (chunk {pos:?})"
"channel anchor must be district-constant (chunk {pos:?})"
);
assert_eq!(
ctx.coast_anchor_m, base.coast_anchor_m,
"coast anchor must be region-constant (chunk {pos:?})"
"coast anchor must be district-constant (chunk {pos:?})"
);
}
// Region (0, 0) spans [0, 1024) m on both axes.
assert!((0..1024).contains(&base.channel_anchor_m));
assert!((0..1024).contains(&base.coast_anchor_m));
// A different region derives its anchors inside its own extent.
let far = derive_chunk_context(42, "GJ1c", &region, (1000, -750));
assert!((62 * 1024..63 * 1024).contains(&far.channel_anchor_m) || (-47 * 1024..-46 * 1024).contains(&far.channel_anchor_m),
"far region anchor {} must lie inside its region extent (cross axis depends on basin direction)",
far.channel_anchor_m);
// District (0, 0) spans [0, scale::DISTRICT_M) m on both axes.
assert!((0..scale::DISTRICT_M).contains(&base.channel_anchor_m));
assert!((0..scale::DISTRICT_M).contains(&base.coast_anchor_m));
// A different district derives its anchors inside its own extent. Chunk
// (1000, -750) → district (1000 >> 5, -750 >> 5) = (31, -24); the cross
// axis (and thus which district index the channel anchor sits in) depends
// on the basin direction.
let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750));
let dm = scale::DISTRICT_M;
assert!(
(31 * dm..32 * dm).contains(&far.channel_anchor_m)
|| (-24 * dm..-23 * dm).contains(&far.channel_anchor_m),
"far district anchor {} must lie inside its district extent (cross axis depends on basin direction)",
far.channel_anchor_m
);
}
#[test]
fn dry_region_has_no_active_channel() {
let region = RegionProfile {
let district = DistrictProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
@@ -515,10 +520,10 @@ mod tests {
moisture_q: 5,
vegetation_class: VegetationClass::Barren,
};
let ctx = derive_chunk_context(42, "dry_body", &region, (5, 5));
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5));
assert!(
!ctx.has_active_channel,
"arid region with ocean_fraction_q=0 must not have active channel"
"arid district with ocean_fraction_q=0 must not have active channel"
);
}
@@ -526,9 +531,9 @@ mod tests {
fn channel_width_in_game_feel_range() {
// D-239 §9: river crossings 315 m. Measured on a chunk that carries
// the channel (T-1040 gating zeroes the width elsewhere).
let region = alluvial_region();
let pos = anchor_chunk_pos(42, "GJ1c", &region);
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
let district = alluvial_district();
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
assert!(
(3..=15).contains(&ctx.channel_width_m),
"channel_width_m {} out of game-feel range [3, 15]",
@@ -538,8 +543,8 @@ mod tests {
#[test]
fn meander_wavelength_within_physics_range() {
let region = alluvial_region();
let ctx = derive_chunk_context(42, "GJ1c", &region, (5, 5));
let district = alluvial_district();
let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5));
// AlluvialPlain flat (slope_q=5): should be near max wavelength.
assert!(
ctx.meander_wavelength_m > 400.0 && ctx.meander_wavelength_m < 700.0,
@@ -1,8 +1,8 @@
//! RegionProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023).
//! DistrictProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023).
//!
//! A `RegionProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body,
//! A `DistrictProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body,
//! roughly 80 × 75), each a pure deterministic function of
//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.regions`
//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.districts`
//! so the Atlas can read zone labels without triggering voxel derivation (D-239 §10,
//! D-203).
//!
@@ -89,7 +89,7 @@ pub enum PrecipitationClass {
/// 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 region cannot jump from Forest to
/// 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
@@ -121,7 +121,7 @@ pub enum VegetationClass {
// Body parameters (input to derivation)
// ---------------------------------------------------------------------------
/// Body-level physical parameters needed to derive `RegionProfile`.
/// 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`.
@@ -147,10 +147,10 @@ pub struct BodyParams {
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// Latitude of the region's centre in the body's reference frame, in degrees.
/// Latitude of the district's centre in the body's reference frame, in degrees.
/// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient.
pub region_latitude_deg: f64,
/// Mean elevation of this region relative to sea level, in km. Used for lapse rate.
pub district_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`).
@@ -159,27 +159,26 @@ pub struct BodyParams {
}
// ---------------------------------------------------------------------------
// RegionPos — position on the ~1 km region grid
// DistrictPos — position on the ~1 km district grid
// ---------------------------------------------------------------------------
/// Position on the per-body ~1 km region grid.
///
/// Each axis is a cell index; a ~6 000-region body has ~80 columns × ~75 rows.
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
pub type RegionPos = (i32, i32);
/// 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;
// ---------------------------------------------------------------------------
// RegionProfile
// DistrictProfile
// ---------------------------------------------------------------------------
/// Per-region (~1 km) terrain classification derived from body params + heightmap.
/// 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.regions` (D-203, D-239 §10).
/// Stored in `BodyWorldState.districts` (D-203, D-239 §10).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionProfile {
/// Region morphology zone (D-239 §6). Uses the **existing** `MorphologyZone`
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,
@@ -192,20 +191,20 @@ pub struct RegionProfile {
/// Precipitation class, derived from hydrosphere + body params.
pub precipitation_class: PrecipitationClass,
/// Integer-quantized slope summary for the region (0100 scale, units: 1 = ~0.45 °).
/// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this region.
/// Integer-quantized slope summary for the district (0100 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 (0100 scale).
/// Average of `TerrainAnalysis.elev_pct` × 100 across region cells.
/// Average of `TerrainAnalysis.elev_pct` × 100 across district cells.
pub elev_q: i32,
/// Ocean fraction for this region (0100 scale, integer).
/// Ocean fraction for this district (0100 scale, integer).
pub ocean_fraction_q: i32,
/// Per-region river threshold (D-239 §1). Replaces the global
/// 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 `RegionProfile` carries downstream.
/// itself is unchanged — this value is what `DistrictProfile` carries downstream.
pub river_threshold: i32,
/// Nullable mean-annual temperature in °C (T-1024 forward declaration).
@@ -353,8 +352,8 @@ pub fn derive_glaciation_grade_from_climate(
/// 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 region is within the riparian
/// band (i.e. the region or an adjacent region carries a perennial waterway).
/// `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.
pub fn derive_vegetation(
@@ -446,7 +445,7 @@ pub fn derive_vegetation(
}
}
/// Derive per-region river threshold from tectonic class + precipitation (D-239 §1).
/// 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).
@@ -469,7 +468,7 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas
(200 + precip_factor + tectonic_bonus).clamp(20, 500)
}
/// Derive `MorphologyZone` from `RegionProfile` gating params (D-239 §5, §6, §7).
/// 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
@@ -497,7 +496,7 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas
/// - 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 region scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) RegionProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
/// - 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.
pub fn derive_morphology_zone(
@@ -511,7 +510,7 @@ pub fn derive_morphology_zone(
// ── 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 region level yet; treat all as OpenOcean.
// No body-scale salinity signal at district level yet; treat all as OpenOcean.
// Lake differentiation lives at ChunkContext (D-239 §10).
return MorphologyZone::OpenOcean;
}
@@ -613,7 +612,7 @@ pub fn derive_morphology_zone(
// Climate constants (T-1024, D-239 §2)
// ---------------------------------------------------------------------------
/// Maximum per-region elevation (km), used to scale a region's normalized
/// Maximum per-district elevation (km), used to scale a district's normalized
/// elevation (`elev_q`, 0100) 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).
@@ -813,7 +812,7 @@ pub fn derive_temperature_c(
let maritime = constants.maritime_factor(hydrosphere);
let mid = (cold + warm) * 0.5;
let half = band_width * 0.5 * maritime;
let lat_frac = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
let lat_frac = (params.district_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;
@@ -892,32 +891,32 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 {
// Public derivation function
// ---------------------------------------------------------------------------
/// Derive a `RegionProfile` for the region at `pos` on a `~1km` grid.
/// Derive a `DistrictProfile` for the district at `pos` on a `~1km` grid.
///
/// Pure (no I/O, no side effects). Inputs are the body's params and the
/// pre-computed `TerrainAnalysis` from Layer 1.
///
/// `grid_cells_per_region` controls how many heightmap cells map to one region
/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 regions ≈
/// ~5 000 regions/body, within the D-203 ~6 000/body budget).
/// `grid_cells_per_district` controls how many heightmap cells map to one district
/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 districts ≈
/// ~5 000 districts/body, within the D-203 ~6 000/body budget).
///
/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate
/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants.
/// The seed chain provides the body-scoped seed for the D-240 temperature nudge.
pub fn derive_region_profile(
pub fn derive_district_profile(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
pos: RegionPos,
grid_cells_per_region: usize,
pos: DistrictPos,
grid_cells_per_district: usize,
climate: &ClimateConstants,
) -> RegionProfile {
) -> DistrictProfile {
let (rx, ry) = pos;
let w = ta.w;
let h = ta.h;
let gcpr = grid_cells_per_region.max(1);
let gcpr = grid_cells_per_district.max(1);
// Compute aggregate terrain statistics over the cells in this region.
// Compute aggregate terrain statistics over the cells in this district.
// All arithmetic is integer or quantized-integer (D-010).
let mut slope_sum: i64 = 0;
let mut elev_sum: i64 = 0;
@@ -954,12 +953,12 @@ pub fn derive_region_profile(
let tectonic_class = derive_tectonic_class(body_params);
// Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS
// region's elevation — otherwise every region on a body shares one body-level
// district's elevation — otherwise every district on a body shares one body-level
// elevation and gets an identical lapse, defeating the per-district temperature
// primitive. Build a region-local BodyParams whose elevation_km comes from the
// region's own normalized elevation (elev_q, 0100) scaled to the body's
// elevation span. region_latitude_deg is already per-region (set by the caller
// / derive_all_regions). Per-cell refinement happens later at ChunkContext.
// primitive. Build a district-local BodyParams whose elevation_km comes from the
// district's own normalized elevation (elev_q, 0100) scaled to the body's
// elevation span. district_latitude_deg is already per-district (set by the caller
// / derive_all_districts). Per-cell refinement happens later at ChunkContext.
let region_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
@@ -976,7 +975,7 @@ pub fn derive_region_profile(
let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q);
let river_threshold = derive_river_threshold(tectonic_class, precipitation_class);
// Vegetation class (T-1025, D-239 §8). No riparian signal at region scale yet
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
// (requires perennial waterway map from L2+); default to false for now.
// L2 ChunkContext will override per-tile once drainage data is threaded through.
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false);
@@ -990,7 +989,7 @@ pub fn derive_region_profile(
moisture_q,
);
RegionProfile {
DistrictProfile {
morphology_zone,
tectonic_class,
glaciation_grade,
@@ -1005,47 +1004,47 @@ pub fn derive_region_profile(
}
}
/// Derive region profiles for all regions covering the body.
/// Derive district profiles for all districts covering the body.
///
/// Returns a `BTreeMap<RegionPos, RegionProfile>` covering the full
/// heightmap at the given region-grid resolution.
/// Returns a `BTreeMap<DistrictPos, DistrictProfile>` covering the full
/// heightmap at the given district-grid resolution.
///
/// `grid_cells_per_region = 8` means each region is 8×8 heightmap cells.
/// `grid_cells_per_district = 8` means each district is 8×8 heightmap cells.
///
/// Region latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=region_rows-1 maps to the south pole (-90°). This is a linear
/// District latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=district_rows-1 maps to the south pole (-90°). This is a linear
/// mapping across the equirectangular heightmap.
pub fn derive_all_regions(
pub fn derive_all_districts(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_region: usize,
) -> BTreeMap<RegionPos, RegionProfile> {
grid_cells_per_district: usize,
) -> BTreeMap<DistrictPos, DistrictProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_region.max(1);
let region_cols = ta.w.div_ceil(gcpr) as i32;
let region_rows = ta.h.div_ceil(gcpr) as i32;
let gcpr = grid_cells_per_district.max(1);
let district_cols = ta.w.div_ceil(gcpr) as i32;
let district_rows = ta.h.div_ceil(gcpr) as i32;
let mut out = BTreeMap::new();
for ry in 0..region_rows {
for ry in 0..district_rows {
// Map ry to latitude: row 0 → +90°, row (rows-1) → -90°.
// For a single-row grid, latitude is 0°.
let lat_deg = if region_rows > 1 {
90.0 - (ry as f64 / (region_rows - 1) as f64) * 180.0
let lat_deg = if district_rows > 1 {
90.0 - (ry as f64 / (district_rows - 1) as f64) * 180.0
} else {
0.0
};
for rx in 0..region_cols {
for rx in 0..district_cols {
let pos = (rx, ry);
// Build per-region params: body-level params + this region's latitude.
// Per-region elevation is NOT set here — derive_region_profile owns it,
// deriving elevation_km from the region's own elev_q (not the caller's
// Build per-district params: body-level params + this district's latitude.
// Per-district elevation is NOT set here — derive_district_profile owns it,
// deriving elevation_km from the district's own elev_q (not the caller's
// body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239).
let region_params = BodyParams {
region_latitude_deg: lat_deg,
let district_params = BodyParams {
district_latitude_deg: lat_deg,
..body_params.clone()
};
let profile = derive_region_profile(seed, &region_params, ta, pos, gcpr, &climate);
let profile = derive_district_profile(seed, &district_params, ta, pos, gcpr, &climate);
out.insert(pos, profile);
}
}
@@ -1092,18 +1091,18 @@ mod tests {
}
#[test]
fn derive_all_regions_covers_full_heightmap() {
fn derive_all_districts_covers_full_heightmap() {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let regions = derive_all_regions(test_seed(), &params, &ta, 8);
let districts = derive_all_districts(test_seed(), &params, &ta, 8);
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 regions.
assert_eq!(regions.len(), 32, "region count mismatch");
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq!(districts.len(), 32, "district count mismatch");
}
#[test]
fn derive_region_profile_is_deterministic() {
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.
@@ -1115,8 +1114,8 @@ mod tests {
};
let pos = (2, 1);
let climate = ClimateConstants::default();
let p1 = derive_region_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p2 = derive_region_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p1 = derive_district_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p2 = derive_district_profile(test_seed(), &params, &ta, pos, 8, &climate);
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq!(
format!("{:?}", p1.morphology_zone),
@@ -1210,10 +1209,10 @@ mod tests {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let regions = derive_all_regions(test_seed(), &params, &ta, 8);
let districts = derive_all_districts(test_seed(), &params, &ta, 8);
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = regions.keys().next().expect("at least one region");
assert_eq!(*first, (0, 0), "first region must be at origin");
let first = districts.keys().next().expect("at least one district");
assert_eq!(*first, (0, 0), "first district must be at origin");
}
#[test]
@@ -1241,7 +1240,7 @@ mod tests {
let params = BodyParams {
planet_class: Some(planet_class.into()),
atmosphere: Some(atmosphere.into()),
region_latitude_deg: lat,
district_latitude_deg: lat,
elevation_km: elev_km,
..Default::default()
};
@@ -1317,7 +1316,7 @@ mod tests {
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("arid".into()),
region_latitude_deg: 45.0,
district_latitude_deg: 45.0,
elevation_km: 1.5,
..Default::default()
};
@@ -1334,7 +1333,7 @@ mod tests {
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
region_latitude_deg: 30.0,
district_latitude_deg: 30.0,
elevation_km: 0.0,
..Default::default()
};
@@ -1389,7 +1388,7 @@ mod tests {
planet_class: Some((*class).into()),
atmosphere: Some((*atmo).into()),
hydrosphere: Some(hydro.into()),
region_latitude_deg: lat,
district_latitude_deg: lat,
elevation_km: elev,
..Default::default()
};
@@ -1418,7 +1417,7 @@ mod tests {
planet_class: Some("temperate".into()),
atmosphere: Some("standard".into()),
hydrosphere: Some(hydro.into()),
region_latitude_deg: lat,
district_latitude_deg: lat,
elevation_km: 0.0,
..Default::default()
};
@@ -1444,7 +1443,7 @@ mod tests {
let params = BodyParams {
planet_class: Some("unknown_alien_class".into()),
atmosphere: Some("breathable".into()),
region_latitude_deg: 0.0,
district_latitude_deg: 0.0,
elevation_km: 0.0,
..Default::default()
};
@@ -1739,14 +1738,14 @@ mod tests {
#[test]
fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() {
// A scrub-zone region near perennial water → RiparianScrub.
// A scrub-zone district near perennial water → RiparianScrub.
let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone
assert_eq!(v, VegetationClass::RiparianScrub);
}
#[test]
fn vegetation_riparian_upgrades_forest_to_riparian_thicket() {
// A forest-zone region near perennial water → RiparianThicket.
// A forest-zone district near perennial water → RiparianThicket.
let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone
assert_eq!(v, VegetationClass::RiparianThicket);
}
@@ -1839,7 +1838,7 @@ mod tests {
#[test]
fn morphology_zone_region_scale_emits_16_of_17() {
// Enumerate the zones the region-scale classifier can emit across a
// 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;
@@ -1866,15 +1865,15 @@ mod tests {
}
}
}
// BraidedPlain (discriminant 11) is the ONE frozen zone the region-scale
// 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 RegionProfile doesn't carry yet, so it is
// 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 6079 → Lake, ≥80 → OpenOcean.)
assert_eq!(
seen.len(),
16,
"expected exactly 16/17 region-reachable zones (BraidedPlain deferred), got: {seen:?}"
"expected exactly 16/17 district-reachable zones (BraidedPlain deferred), got: {seen:?}"
);
}
+5 -5
View File
@@ -30,8 +30,8 @@ use crossbeam_channel::{Receiver, Sender};
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::body_world_state::BodyWorldState;
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::district_profile::BodyParams;
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::region_profile::BodyParams;
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
use crate::seed::SeedChain;
use crate::simulation::generator::{CityGenerationContext, QuarterWorldState};
@@ -77,8 +77,8 @@ pub enum GenWorkItem {
/// time. Drives Layer-3 TerritorialStatus + spatial character (#956,
/// D-212/214/215). `None` → `FrontierUnclaimed`.
dominant_faction: Option<String>,
/// Body physical parameters for the RegionProfile layer (T-1023, D-239 §1).
/// Pre-resolved at dispatch time. `None` → region layer skipped for this body.
/// Body physical parameters for the DistrictProfile layer (T-1023, D-239 §1).
/// Pre-resolved at dispatch time. `None` → district layer skipped for this body.
/// Boxed: `BodyParams` is large relative to other variants (clippy
/// large_enum_variant) — boxing keeps `GenWorkItem` compact.
body_params: Option<Box<BodyParams>>,
@@ -375,8 +375,8 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
hm
};
// Run the full cascade through RoadGraph (Layer 2, T-1038), the
// terminal layer. It subsumes Settlement, RegionProfile (T-1023),
// and all prior layers. RegionProfile derivation still gates on
// terminal layer. It subsumes Settlement, DistrictProfile (T-1023),
// and all prior layers. DistrictProfile derivation still gates on
// body_params internally (skipped when absent — e.g. a body with no
// params row), but the road graph needs no body params, so it runs
// for every analyzed body.
+13 -12
View File
@@ -67,11 +67,11 @@ pub struct AtlasLayerResponse {
/// cascade still runs Layer 1; the body just gets no settlement placements.
///
/// `body_params_reader` supplies the body's physical parameters for the
/// RegionProfile carrier layer (T-1032, D-239 §1), read on a cache miss.
/// DistrictProfile carrier layer (T-1032, D-239 §1), read on a cache miss.
/// `None` (or a read failure) passes `body_params: None` to the work item,
/// causing the cascade to stop at `CascadeLayer::Settlement` (pre-T-1032
/// behaviour). A successful read passes `Some(Box::new(params))`, enabling
/// the full `CascadeLayer::RegionProfile` path.
/// the full `CascadeLayer::DistrictProfile` path.
pub fn handle_atlas_request(
req: &AtlasLayerRequest,
cache: &mut BodyWorldStateCache,
@@ -145,7 +145,7 @@ pub fn handle_atlas_request(
tracing::warn!(
body_id = %req.body_id,
error = %e,
"body_params read failed; region layer skipped"
"body_params read failed; district layer skipped"
);
None
}),
@@ -272,7 +272,7 @@ mod tests {
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
regions: std::collections::BTreeMap::new(),
districts: std::collections::BTreeMap::new(),
last_accessed: 0,
});
let (_db, resolver) = empty_resolver();
@@ -371,6 +371,7 @@ mod tests {
hydrosphere TEXT,
atmosphere TEXT,
planet_class TEXT,
body_radius_km REAL,
orbital_period_days REAL,
axial_tilt_deg REAL
);",
@@ -383,8 +384,8 @@ mod tests {
)
.unwrap();
conn.execute(
"INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg)
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 365.25, 23.5)",
"INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, body_radius_km, orbital_period_days, axial_tilt_deg)
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 6371.0, 365.25, 23.5)",
rusqlite::params![body_id, REL],
)
.unwrap();
@@ -400,7 +401,7 @@ mod tests {
}
/// With body_params_reader wired, a cache miss enqueues an AnalyzeBody that
/// completes with populated `regions` (RegionProfile layer ran).
/// completes with populated `districts` (DistrictProfile layer ran).
#[test]
fn body_params_reader_wired_produces_populated_regions() {
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
@@ -435,12 +436,12 @@ mod tests {
.expect("AnalyzeBody must complete for GJ1c");
assert!(
!body_state.regions.is_empty(),
"regions must be populated when body_params_reader is wired (T-1032 dispatch path)"
!body_state.districts.is_empty(),
"districts must be populated when body_params_reader is wired (T-1032 dispatch path)"
);
}
/// Without body_params_reader (None), regions is empty — pre-T-1032 behaviour.
/// Without body_params_reader (None), districts is empty — pre-T-1032 behaviour.
#[test]
fn no_body_params_reader_leaves_regions_empty() {
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
@@ -474,8 +475,8 @@ mod tests {
.expect("AnalyzeBody must complete for GJ1c");
assert!(
body_state.regions.is_empty(),
"regions must remain empty when no body_params_reader is wired"
body_state.districts.is_empty(),
"districts must remain empty when no body_params_reader is wired"
);
}
}
+1 -1
View File
@@ -11,6 +11,7 @@ pub mod cascade;
pub mod chunk_context;
pub mod city_context_reader;
pub mod district_mix;
pub mod district_profile;
pub mod domain_warp;
pub mod drainage;
pub mod features;
@@ -19,7 +20,6 @@ pub mod heightmap;
pub mod layer1;
pub mod layer_proxy;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod scale;
pub mod skeleton_gen;
+390 -380
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -216,7 +216,7 @@ fn main() {
),
}
// Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1, D-240):
// Body physical params reader for DistrictProfile carrier layer (T-1032, D-239 §1, D-240):
// reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon
// cascade work item stays DB-free (D-225 pattern).
// D-240: orbit/star fields are non-canonical and are no longer read.
@@ -229,7 +229,7 @@ fn main() {
);
}
Err(e) => tracing::warn!(
"Body params reader unavailable ({}). RegionProfile layer will be skipped.",
"Body params reader unavailable ({}). DistrictProfile layer will be skipped.",
e
),
}
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -3,12 +3,12 @@
"label": "alluvial_forest_active_channel",
"seed": 16045690984503098046,
"body_id": "GJ144d",
"tile_x": 223,
"tile_x": 1375,
"tile_y": 800,
"terrain": 0,
"vegetation": 4,
"water": 1,
"elevation_m": 8,
"water": 0,
"elevation_m": 14,
"cover": 0
},
{
@@ -27,12 +27,12 @@
"label": "fjord_wall_glaciated",
"seed": 18369614217980264670,
"body_id": "GJ447c",
"tile_x": 228,
"tile_x": 1508,
"tile_y": 352,
"terrain": 3,
"vegetation": 0,
"water": 2,
"elevation_m": 0,
"water": 0,
"elevation_m": 13,
"cover": 1
}
]