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:
@@ -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");
|
||||
|
||||
@@ -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
@@ -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(¶ms),
|
||||
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
@@ -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 (0–255) 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 (0–255).
|
||||
/// 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 80–500 m for AlluvialPlain.
|
||||
/// Derived at district scale; typically 80–500 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. 0–255.
|
||||
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. 0–255.
|
||||
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: 80–500 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: 3–15 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", ®ion, (10, 20));
|
||||
let b = derive_chunk_context(42, "GJ1c", ®ion, (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", ®ion, (0, 0));
|
||||
let b = derive_chunk_context(42, "GJ1c", ®ion, (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", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, 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", ®ion);
|
||||
(pos, derive_chunk_context(42, "GJ1c", ®ion, 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", ®ion, 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", ®ion, (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", ®ion, 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", ®ion, (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", ®ion, (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 3–15 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", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, 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", ®ion, (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 (0–100 scale, units: 1 = ~0.45 °).
|
||||
/// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this region.
|
||||
/// Integer-quantized slope summary for the district (0–100 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 (0–100 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 (0–100 scale, integer).
|
||||
/// Ocean fraction for this district (0–100 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`, 0–100) 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, 0–100) 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, 0–100) 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, ®ion_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(), ¶ms, &ta, 8);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &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(), ¶ms, &ta, pos, 8, &climate);
|
||||
let p2 = derive_region_profile(test_seed(), ¶ms, &ta, pos, 8, &climate);
|
||||
let p1 = derive_district_profile(test_seed(), ¶ms, &ta, pos, 8, &climate);
|
||||
let p2 = derive_district_profile(test_seed(), ¶ms, &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(), ¶ms, &ta, 8);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &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 60–79 → 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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
+211
-182
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user