fix(simulation): one absolute-metre derive core (D-256, T-1174)
derive_district_profile is now a thin wrapper over the shared derive_at_metres_with_riparian core at survey-cell-centre world metres — one derive core, two position sets. The batch pseudo-grid and the true D-243 district grid were two coordinate systems sharing one bare (i32,i32) type; the new SurveyCellPos newtype re-keys every batch product (BodyWorldState.districts, Layer1Output.survey_basin_dirs) so the compiler rejects cross-namespace passing. Fixes two latent same-position divergences the T-1174 investigation surfaced: three inconsistent latitude conventions collapse into the core's single inverse mapping, and the region-climate baseline now floor-divides true world metres instead of collapsing the whole body onto region (0,0)'s baseline — batch climate becomes latitude/region graded (D-245 direction: every changed believability metric increased). Binding preservations per D-256(c): basin_direction rides a post-call override with the true L1 D8 survey-cell aggregate (layer1's map re-keyed to SurveyCellPos, identity lookup — a floor-divide lookup against the pseudo-keyed map would have silently defaulted every cell North); the riparian verdict comes from near_perennial_water_at, never the empty-slice default (which would have flipped riverside vegetation_class). Quarter-skeleton morphology_zone now resolves at the settlement's exact world position via derive_at_metres at work-item execution (where TerrainAnalysisCache lives), replacing the survey-cell-centre map lookup (D-256(d)); settlement_district_pos fixed to true-district floor-division in passing (same doc/impl mismatch class). Second pixel-vs-metre conflation fixed in aliveness_probe's anchor-walk math. Window path byte-unchanged (window_derivation_golden 6/6 byte- identical); derivation_harness golden untouched; believability golden regenerated. Full lib + integration suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,18 +24,23 @@
|
||||
//!
|
||||
//! ## District tier (today)
|
||||
//!
|
||||
//! Per T-1083, analysis is addressed in **district** space: no production code maps a
|
||||
//! world chunk → its covering [`DistrictProfile`] yet (the voxel layer is a walking
|
||||
//! skeleton). The `DistrictProfile` (2 km) is the finest *authoritative* nature unit the
|
||||
//! cascade produces; the voxel-derived metrics sample a representative chunk per district.
|
||||
//! Per T-1083, analysis is addressed in **survey-cell** space (D-256(b)): no production
|
||||
//! code maps a world chunk → its covering [`DistrictProfile`] yet (the voxel layer is a
|
||||
//! walking skeleton). Each survey cell's `DistrictProfile` is derived at its D-256(b)
|
||||
//! centre world metres via the same [`crate::atlas::district_profile::derive_at_metres`]
|
||||
//! family every other rung uses — the finest *authoritative* nature unit the cascade
|
||||
//! produces; the voxel-derived metrics sample a representative chunk at that SAME centre
|
||||
//! position (`district_profile::survey_cell_centre_world_m`), never the survey cell's own
|
||||
//! pseudo-grid coordinates scaled as if they were a true district.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts)`:
|
||||
//! scalar/categorical contrast is computed over **all** districts; the voxel-derived
|
||||
//! metrics sample a **seeded spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body
|
||||
//! (deterministic from the world seed — D-245's "randomly-sampled locations", and
|
||||
//! unbiased unlike a contiguous corner). Suitable for golden-snapshot regression.
|
||||
//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts,
|
||||
//! heightmap_width, heightmap_height, body_radius_km)`: scalar/categorical contrast is
|
||||
//! computed over **all** survey cells; the voxel-derived metrics sample a **seeded
|
||||
//! spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body (deterministic from the world
|
||||
//! seed — D-245's "randomly-sampled locations", and unbiased unlike a contiguous corner).
|
||||
//! Suitable for golden-snapshot regression.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
@@ -47,9 +52,9 @@ use crate::atlas::body_params_reader::BodyParamsReader;
|
||||
use crate::atlas::body_world_state::BodyWorldState;
|
||||
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer, CascadeSnapshot};
|
||||
use crate::atlas::chunk_context::derive_chunk_context;
|
||||
use crate::atlas::district_profile::{BodyParams, DistrictProfile};
|
||||
use crate::atlas::district_profile::{self, BodyParams, DistrictProfile};
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::scale::{ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M};
|
||||
use crate::atlas::scale::{self, ChunkPos, SurveyCellPos, CHUNKS_PER_DISTRICT, CHUNK_M};
|
||||
use crate::atlas::voxel::{derive_voxel_column, Vegetation, Water};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::SettlementClass;
|
||||
@@ -169,13 +174,27 @@ pub struct Criterion {
|
||||
// Analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the [`BelievabilityReport`] for a body's per-district cascade output.
|
||||
/// Compute the [`BelievabilityReport`] for a body's per-survey-cell cascade
|
||||
/// output.
|
||||
///
|
||||
/// Pure + deterministic (see module docs). `districts` is `BodyWorldState.districts`.
|
||||
/// Pure + deterministic (see module docs). `districts` is
|
||||
/// `BodyWorldState.districts` — keyed by [`SurveyCellPos`] (D-256(b)), NOT the
|
||||
/// true D-243 district grid. `heightmap_width`/`heightmap_height`/
|
||||
/// `body_radius_km` are needed to resolve each sampled survey cell's centre
|
||||
/// world metres (`district_profile::pixel_to_world_m`, the same D-256(b)
|
||||
/// bridge the survey raster itself uses) into the TRUE chunk position the
|
||||
/// voxel-derived metrics sample — a survey cell's own pseudo-grid coordinates
|
||||
/// are NOT a district position and must never be scaled by
|
||||
/// `CHUNKS_PER_DISTRICT` directly (the class of bug D-256's `SurveyCellPos`
|
||||
/// newtype exists to make the compiler reject).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn analyze(
|
||||
world_seed: u64,
|
||||
body_id: &str,
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
districts: &BTreeMap<SurveyCellPos, DistrictProfile>,
|
||||
heightmap_width: u32,
|
||||
heightmap_height: u32,
|
||||
body_radius_km: Option<f64>,
|
||||
) -> BelievabilityReport {
|
||||
// ── Contrast: scalar + categorical over ALL districts (cheap, no voxels) ──
|
||||
let contrast_scalar = ContrastMetrics {
|
||||
@@ -213,12 +232,23 @@ pub fn analyze(
|
||||
// sampled vegetated-land districts — the worst patch, per the D-245 per-patch floor.
|
||||
let mut min_habitat_distinct = usize::MAX;
|
||||
|
||||
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
|
||||
let keys: Vec<SurveyCellPos> = districts.keys().copied().collect();
|
||||
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
|
||||
let dp = keys[idx];
|
||||
let prof = &districts[&dp];
|
||||
sampled += 1;
|
||||
let chunk = district_centre_chunk(dp);
|
||||
// D-256(d): resolve this survey cell's OWN centre world metres (the
|
||||
// same D-256(b) bridge that derived `prof` in the first place), then
|
||||
// convert to the TRUE chunk covering that position — never scale the
|
||||
// survey pseudo-grid coordinates directly.
|
||||
let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m(
|
||||
dp,
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
heightmap_width as usize,
|
||||
heightmap_height as usize,
|
||||
body_radius_km,
|
||||
);
|
||||
let chunk = world_m_to_chunk(world_x_m, world_y_m);
|
||||
let ctx = derive_chunk_context(world_seed, body_id, prof, chunk, None);
|
||||
|
||||
let mut any_wet = false;
|
||||
@@ -294,11 +324,21 @@ pub fn analyze(
|
||||
// districts with one stray dry point would otherwise contribute a 0 range and
|
||||
// bias the mean toward "flat" on an ocean world (a measurement artifact, not
|
||||
// flat land).
|
||||
//
|
||||
// D-256(d): the transect origin is the TRUE district (not the survey
|
||||
// cell) containing `chunk` — `chunk` was resolved from the survey
|
||||
// cell's own centre world metres above, so flooring it to its
|
||||
// covering district's chunk-grid corner keeps the transect inside the
|
||||
// SAME district the sample chunk itself falls in.
|
||||
let district_origin_chunk = (
|
||||
chunk.0.div_euclid(CHUNKS_PER_DISTRICT) * CHUNKS_PER_DISTRICT,
|
||||
chunk.1.div_euclid(CHUNKS_PER_DISTRICT) * CHUNKS_PER_DISTRICT,
|
||||
);
|
||||
let (mut relief_lo, mut relief_hi, mut relief_dry) = (i32::MAX, i32::MIN, 0i32);
|
||||
for &co in &RELIEF_TRANSECT_CHUNK_OFFSETS {
|
||||
let cpos = (
|
||||
dp.0 * CHUNKS_PER_DISTRICT + co,
|
||||
dp.1 * CHUNKS_PER_DISTRICT + co,
|
||||
district_origin_chunk.0 + co,
|
||||
district_origin_chunk.1 + co,
|
||||
);
|
||||
let cctx = derive_chunk_context(world_seed, body_id, prof, cpos, None);
|
||||
let tx = cpos.0 * CHUNK_M + CHUNK_M / 2;
|
||||
@@ -441,12 +481,19 @@ fn distinct(vals: impl Iterator<Item = String>) -> usize {
|
||||
vals.collect::<BTreeSet<String>>().len()
|
||||
}
|
||||
|
||||
/// The chunk at the centre of a district (32 chunks/district) — the representative
|
||||
/// chunk the voxel-derived metrics sample.
|
||||
fn district_centre_chunk(dp: DistrictPos) -> ChunkPos {
|
||||
/// The chunk covering a world-metres position (D-256(d)) — the representative
|
||||
/// chunk the voxel-derived metrics sample. `world_x_m`/`world_y_m` is the
|
||||
/// sampled survey cell's OWN centre position (`district_profile::pixel_to_world_m`
|
||||
/// applied to its covering pixel block — the same D-256(b) bridge the survey
|
||||
/// raster itself uses to derive the cell's `DistrictProfile`), never the
|
||||
/// pseudo-grid coordinates scaled directly: a `SurveyCellPos` is not a
|
||||
/// district position and multiplying it by `CHUNKS_PER_DISTRICT` would sample
|
||||
/// a chunk that has nothing to do with the profile actually being probed.
|
||||
fn world_m_to_chunk(world_x_m: f64, world_y_m: f64) -> ChunkPos {
|
||||
let cm = crate::atlas::scale::CHUNK_M as f64;
|
||||
(
|
||||
dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
(world_x_m / cm).floor() as i32,
|
||||
(world_y_m / cm).floor() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -679,11 +726,11 @@ mod tests {
|
||||
/// This is the case the naive per-tile metric called ALIVE.
|
||||
#[test]
|
||||
fn uniform_world_fails_contrast_criteria() {
|
||||
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
|
||||
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = BTreeMap::new();
|
||||
for x in 0..8 {
|
||||
for y in 0..8 {
|
||||
districts.insert(
|
||||
(x, y),
|
||||
SurveyCellPos(x, y),
|
||||
district(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
20,
|
||||
@@ -695,7 +742,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
let report = analyze(42, "uniform", &districts);
|
||||
let report = analyze(42, "uniform", &districts, 64, 64, None);
|
||||
assert_eq!(report.contrast.moisture_q.distinct, 1, "moisture is flat");
|
||||
assert_eq!(report.contrast.morphology_zones, 1);
|
||||
assert_eq!(report.contrast.elev_q.spread(), 0);
|
||||
@@ -727,12 +774,12 @@ mod tests {
|
||||
VegetationClass::Forest,
|
||||
VegetationClass::RiparianThicket,
|
||||
];
|
||||
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
|
||||
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = BTreeMap::new();
|
||||
for x in 0..8 {
|
||||
for y in 0..8 {
|
||||
let i = (x * 8 + y) as usize;
|
||||
districts.insert(
|
||||
(x, y),
|
||||
SurveyCellPos(x, y),
|
||||
district(
|
||||
zones[i % zones.len()],
|
||||
(i as i32 * 7) % 100, // varied elevation
|
||||
@@ -744,7 +791,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
let report = analyze(42, "varied", &districts);
|
||||
let report = analyze(42, "varied", &districts, 64, 64, None);
|
||||
assert!(report.contrast.moisture_q.distinct >= 3);
|
||||
assert!(report.contrast.morphology_zones >= 2);
|
||||
assert!(report.contrast.elev_q.spread() >= 10);
|
||||
@@ -758,9 +805,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn analyze_is_deterministic() {
|
||||
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
|
||||
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = BTreeMap::new();
|
||||
districts.insert(
|
||||
(0, 0),
|
||||
SurveyCellPos(0, 0),
|
||||
district(
|
||||
MorphologyZone::MeanderReach,
|
||||
30,
|
||||
@@ -771,7 +818,7 @@ mod tests {
|
||||
),
|
||||
);
|
||||
districts.insert(
|
||||
(1, 0),
|
||||
SurveyCellPos(1, 0),
|
||||
district(
|
||||
MorphologyZone::CliffCoast,
|
||||
70,
|
||||
@@ -781,8 +828,8 @@ mod tests {
|
||||
VegetationClass::Scrub,
|
||||
),
|
||||
);
|
||||
let a = analyze(7, "GJ1c", &districts);
|
||||
let b = analyze(7, "GJ1c", &districts);
|
||||
let a = analyze(7, "GJ1c", &districts, 64, 64, Some(6371.0));
|
||||
let b = analyze(7, "GJ1c", &districts, 64, 64, Some(6371.0));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ use bevy_ecs::prelude::Resource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
||||
use crate::atlas::district_profile::DistrictProfile;
|
||||
use crate::atlas::region_profile::RegionProfile;
|
||||
use crate::atlas::road_graph::RoadGraph;
|
||||
use crate::atlas::scale::RegionPos;
|
||||
use crate::atlas::scale::{RegionPos, SurveyCellPos};
|
||||
use crate::simulation::generator::{
|
||||
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
|
||||
};
|
||||
@@ -169,6 +169,13 @@ pub struct BodyWorldState {
|
||||
pub heightmap: Vec<f32>,
|
||||
pub heightmap_width: u32,
|
||||
pub heightmap_height: u32,
|
||||
/// Elevation fraction below which terrain is ocean/sea (T-1174/D-256) —
|
||||
/// carried alongside the working-grid data above so a `BodyHeightmap` can
|
||||
/// be reconstructed in-memory (no disk re-read) wherever a `TerrainAnalysis`
|
||||
/// needs re-deriving from this cached body (e.g.
|
||||
/// `TerrainAnalysisCache::get_or_derive` for an exact-position skeleton
|
||||
/// judgment, D-256(d)). Sourced from `CascadeSnapshot.heightmap.sea_level`.
|
||||
pub sea_level: f32,
|
||||
/// D8 drainage analysis output (D-208). Empty until drainage task completes.
|
||||
pub river_network: RiverNetwork,
|
||||
/// Drainage basins from watershed analysis (D-205).
|
||||
@@ -187,12 +194,14 @@ 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-district (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1).
|
||||
/// Per-survey-cell profiles derived from body params + terrain (T-1023,
|
||||
/// D-239 §1) — the D-256(b) coarse planning raster, NOT the true D-243
|
||||
/// district grid (see [`SurveyCellPos`]).
|
||||
///
|
||||
/// Populated by the background cascade after Layer 1 completes.
|
||||
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
|
||||
/// `BTreeMap` keyed by `SurveyCellPos` for D-010 determinism.
|
||||
/// Empty until the DistrictProfile layer has run.
|
||||
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
|
||||
pub districts: BTreeMap<SurveyCellPos, DistrictProfile>,
|
||||
/// Per-region (~205 km) climate context — season/weather/temperature
|
||||
/// baseline cells (D-243 §3, T-1113).
|
||||
///
|
||||
@@ -319,6 +328,7 @@ mod tests {
|
||||
heightmap: vec![0.5; 16],
|
||||
heightmap_width: 4,
|
||||
heightmap_height: 4,
|
||||
sea_level: 0.3,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
|
||||
+33
-10
@@ -24,13 +24,13 @@ 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::district_profile::{self, BodyParams, 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, RegionProfile};
|
||||
use crate::atlas::road_graph::{self, RoadGraph};
|
||||
use crate::atlas::scale::{self, RegionPos};
|
||||
use crate::atlas::scale::{self, RegionPos, SurveyCellPos};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
||||
|
||||
@@ -105,11 +105,11 @@ pub struct CascadeSnapshot {
|
||||
pub terrain_analysis: Option<TerrainAnalysis>,
|
||||
}
|
||||
|
||||
/// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain
|
||||
/// profiles covering the whole body. Stored in `BodyWorldState.districts`.
|
||||
/// DistrictProfile layer output (T-1023, D-239 §1): per-survey-cell (D-256(b))
|
||||
/// terrain profiles covering the whole body. Stored in `BodyWorldState.districts`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LayerDistrictOutput {
|
||||
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
|
||||
pub districts: std::collections::BTreeMap<SurveyCellPos, DistrictProfile>,
|
||||
}
|
||||
|
||||
/// Region climate layer output (T-1113, D-243 §3): per-region (~205 km) climate
|
||||
@@ -120,6 +120,12 @@ pub struct LayerDistrictOutput {
|
||||
/// cache pads a neighbour ring because its edge-fuzz blend samples across
|
||||
/// boundaries; that padding is a blend implementation detail, not part of the
|
||||
/// body's own region grid, and a dense Atlas wire encoding wants exact dims.)
|
||||
///
|
||||
/// **D-256(f):** the build still keys off the SurveyCellPos-shaped pseudo-grid
|
||||
/// dims rather than the true D-243 district grid — a fenced, deliberate defer
|
||||
/// to T-1181's rung-0 Global canvas, not a fix this ticket makes. Verified
|
||||
/// safe: the sole reader is the `region_grid` Atlas overlay, which reads no
|
||||
/// `DistrictProfile` climate to disagree with.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LayerRegionOutput {
|
||||
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
|
||||
@@ -154,11 +160,13 @@ impl CascadeSnapshot {
|
||||
let road_graph = self.road_graph.unwrap_or_default();
|
||||
// terrain_analysis (transient) is intentionally dropped here.
|
||||
let _ = self.terrain_analysis;
|
||||
let sea_level = self.heightmap.sea_level;
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
heightmap_width: self.heightmap.width,
|
||||
heightmap_height: self.heightmap.height,
|
||||
sea_level,
|
||||
river_network,
|
||||
drainage_basins,
|
||||
attractors,
|
||||
@@ -301,10 +309,13 @@ pub fn run_cascade_from_heightmap(
|
||||
// domain separation — derive_all_districts builds the region
|
||||
// cache internally.
|
||||
//
|
||||
// district_basin_dirs from Layer1Output threads the true D8
|
||||
// thalweg direction into each DistrictProfile.basin_direction
|
||||
// (T-1047). Pass the map through derive_all_districts.
|
||||
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs);
|
||||
// Layer1Output.survey_basin_dirs threads the true D8 thalweg
|
||||
// direction into each DistrictProfile.basin_direction (T-1047)
|
||||
// — the VALUES are true D8 aggregates, the KEYS are survey
|
||||
// cells (D-256(b); `derive_all_districts` looks it up by
|
||||
// identity, matching this map's own key space). Pass the map
|
||||
// through derive_all_districts.
|
||||
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.survey_basin_dirs);
|
||||
// T-1168 Ruling 4c: same `snapshot.layer1` source as
|
||||
// `basin_dirs`/`river_cells` (the `road_graph` precedent
|
||||
// below) — the river network for the batch-path riparian
|
||||
@@ -372,6 +383,18 @@ pub fn run_cascade_from_heightmap(
|
||||
// no TerrainAnalysis needed, so it runs outside the transient-borrow block
|
||||
// above. Gates on body_params like the DistrictProfile layer (no params →
|
||||
// no climate inputs → the layer skips, `regions` stays empty).
|
||||
//
|
||||
// D-256(f) FENCED, NOT FIXED BY THIS TICKET: this block still treats the
|
||||
// survey-raster dims (`district_cols`/`district_rows` below, really
|
||||
// SurveyCellPos counts) as if they were true district counts and maps
|
||||
// them straight through `scale::district_to_region` — the SAME pseudo-grid
|
||||
// collapse D-256(c) fixed for `DistrictProfile`'s own region baseline.
|
||||
// Deliberately deferred to T-1181's rung-0 Global canvas (D-256 ruling's
|
||||
// tripwire, verified: the sole production reader of `LayerRegionOutput`/
|
||||
// `regions` is the `region_grid` body-view overlay — no consumer reads it
|
||||
// against `DistrictProfile` climate, so this collapse never disagrees
|
||||
// with anything this ticket's scope touches). The overlay stays visibly
|
||||
// stale until T-1181 replaces it — accepted, noted, not silently ignored.
|
||||
if up_to >= CascadeLayer::Region {
|
||||
if let Some(params) = body_params {
|
||||
// The covering region grid: the same district dims the district
|
||||
@@ -589,7 +612,7 @@ mod tests {
|
||||
assert_eq!(p1.river_threshold, p2.river_threshold);
|
||||
assert_eq!(p1.tectonic_class, p2.tectonic_class);
|
||||
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
|
||||
// basin_direction threads run_layer1 -> district_basin_dirs -> here;
|
||||
// basin_direction threads run_layer1 -> survey_basin_dirs -> here;
|
||||
// guard the full chain's determinism (T-1047).
|
||||
assert_eq!(p1.basin_direction, p2.basin_direction);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::atlas::coast_invention;
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::river_course;
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos};
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos, SurveyCellPos};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
|
||||
@@ -247,8 +247,10 @@ pub struct DistrictProfile {
|
||||
///
|
||||
/// The **true** D8-computed dominant flow direction aggregated from the full
|
||||
/// `fdir` grid in `run_layer1` — not a seed-bit proxy. Threaded here from
|
||||
/// `Layer1Output.district_basin_dirs` so `derive_chunk_context` reads it
|
||||
/// directly instead of calling the formerly-false `derive_basin_direction`.
|
||||
/// `Layer1Output.survey_basin_dirs` (D-256(b): a survey-cell-keyed aggregate,
|
||||
/// looked up by identity in `derive_all_districts` — see that function's
|
||||
/// doc) so `derive_chunk_context` reads it directly instead of calling the
|
||||
/// formerly-false `derive_basin_direction`.
|
||||
///
|
||||
/// `#[serde(default)]` ensures backward compatibility when deserializing
|
||||
/// stored profiles that predate this field (T-1047).
|
||||
@@ -1228,9 +1230,11 @@ fn invent_primitives(
|
||||
}
|
||||
|
||||
/// Fractional working-grid position → absolute world metres — the inverse of
|
||||
/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam), used by
|
||||
/// the batch path so both derivation paths key the invention noise fields on
|
||||
/// the same world-metre convention. Radius-less bodies fall back to the
|
||||
/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam). Used by
|
||||
/// [`derive_district_profile`] (D-256(b)) to convert a survey cell's centre
|
||||
/// pixel to the world metres it hands to the shared [`derive_at_metres_with_riparian`]
|
||||
/// core — both derivation paths key their invention noise fields on the same
|
||||
/// world-metre convention this way. Radius-less bodies fall back to the
|
||||
/// 1-working-pixel = 1-district convention `derive_district` uses.
|
||||
///
|
||||
/// `pub(crate)` (T-1170): also used by the river course inventor
|
||||
@@ -1297,21 +1301,40 @@ pub(crate) fn world_m_to_pixel(
|
||||
// Public derivation function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive a `DistrictProfile` for the district at `pos` on a `~1km` grid.
|
||||
/// Derive a `DistrictProfile` for the survey cell at `pos` on the D-256(b)
|
||||
/// survey raster — a thin wrapper over the shared [`derive_at_metres_with_riparian`]
|
||||
/// core at the survey cell's centre world metres (D-256(c): "one derive core,
|
||||
/// two position sets").
|
||||
///
|
||||
/// Pure (no I/O, no side effects). Inputs are the body's params and the
|
||||
/// pre-computed `TerrainAnalysis` from Layer 1.
|
||||
///
|
||||
/// `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).
|
||||
/// `grid_cells_per_district` controls how many heightmap cells map to one survey
|
||||
/// cell; default is 8 (`scale::HEIGHTMAP_CELLS_PER_DISTRICT`, at the standard
|
||||
/// working grid `heightmap::GRID_W` × `heightmap::GRID_H`, T-1170) — several
|
||||
/// thousand cells/body, within the D-203 budget.
|
||||
///
|
||||
/// Temperature and moisture are derived via the D-243 §3/§4 two-phase stack:
|
||||
/// the edge-fuzz-blended region baseline (from `region_cache` / on-the-fly
|
||||
/// derivation via [`region_profile::region_baseline_at_district`]) feeds the
|
||||
/// district modulation ([`derive_district_temperature_c`]). Pass a
|
||||
/// `&ClimateConstants` to control the tuning constants. The seed chain provides
|
||||
/// the body-scoped seed.
|
||||
/// ## Survey-cell-centre position (D-256(b))
|
||||
///
|
||||
/// The centre pixel is the midpoint of the cell's covering pixel block,
|
||||
/// `8·rx + 3.5` in the interior (the geometrically correct centre of the
|
||||
/// 8-point bilinear sample lattice — kept exactly as before this ticket, NOT
|
||||
/// an error) — clamped at the grid edges where the block is truncated
|
||||
/// (`saturating_add`/`.min(w)`/`.min(h)`), matching this function's
|
||||
/// pre-D-256 pixel-range math exactly. That pixel is converted to world
|
||||
/// metres via [`pixel_to_world_m`], then handed to the shared core with
|
||||
/// `min_wavelength_m = 0.0` (no octave cutoff, matching [`derive_district`]'s
|
||||
/// own default).
|
||||
///
|
||||
/// ## Region baseline + latitude (D-256(c))
|
||||
///
|
||||
/// The core derives its own latitude and region-climate baseline from the
|
||||
/// survey-cell-centre world metres — this is what auto-fixes the two latent
|
||||
/// same-position divergences the D-256 investigation found: one inverse
|
||||
/// mapping computed once cannot disagree with itself (the former three
|
||||
/// inconsistent latitudes collapse to one), and the region baseline now
|
||||
/// floor-divides the TRUE world metres instead of keying off the pseudo-grid
|
||||
/// index (the former body-uniform region-(0,0) climate collapse).
|
||||
///
|
||||
/// ## Parameters
|
||||
///
|
||||
@@ -1320,9 +1343,14 @@ pub(crate) fn world_m_to_pixel(
|
||||
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
|
||||
/// if a neighbour region is missing it is derived on the fly. Build with
|
||||
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
|
||||
/// - `basin_direction` — the true L1 D8 thalweg direction for this cell.
|
||||
/// `basin_direction` is an inert pass-through field (nothing in the
|
||||
/// derivation reads it — the D-256 ruling's proof), so it is applied via a
|
||||
/// post-call field override on the core's returned profile rather than
|
||||
/// threaded through the core itself.
|
||||
/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c),
|
||||
/// consulted for the riparian point test via
|
||||
/// [`river_course::near_perennial_water_at`] (edges near this district
|
||||
/// [`river_course::near_perennial_water_at`] (edges near this cell
|
||||
/// invented on demand, the same pure function the window path uses).
|
||||
/// `None` when no river network is available (e.g. a body with no Layer-1
|
||||
/// drainage pass, or a caller that predates T-1168) — the riparian signal
|
||||
@@ -1332,7 +1360,7 @@ pub fn derive_district_profile(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
pos: DistrictPos,
|
||||
pos: SurveyCellPos,
|
||||
grid_cells_per_district: usize,
|
||||
climate: &ClimateConstants,
|
||||
body_id: &str,
|
||||
@@ -1340,19 +1368,14 @@ pub fn derive_district_profile(
|
||||
basin_direction: BasinDirection,
|
||||
river_network: Option<&RiverNetwork>,
|
||||
) -> DistrictProfile {
|
||||
let (rx, ry) = pos;
|
||||
let SurveyCellPos(rx, ry) = pos;
|
||||
let w = ta.w;
|
||||
let h = ta.h;
|
||||
let gcpr = grid_cells_per_district.max(1);
|
||||
|
||||
// T-1125: the batch path runs the SAME invention as the on-demand path — an
|
||||
// invented centre-point sample of the continuous field (warped coastline +
|
||||
// slope-independent scatter via `invent_primitives`) instead of the former
|
||||
// scatter-free cell-aggregate mean. One invention path, shared with
|
||||
// [`derive_district`], so the two can never silently diverge again; the
|
||||
// pseudo-grid samples the same truth the 2 km carrier does. (The former mean
|
||||
// smoothed away exactly the variance the believability contrast metrics
|
||||
// measure — and carried no invention at all.)
|
||||
// D-256(b): the survey cell's covering pixel block, clamped at the grid
|
||||
// edges — UNCHANGED from the pre-D-256 cell-aggregate-centre math (only
|
||||
// the position TYPE changed, not the arithmetic).
|
||||
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
|
||||
let row_end = row_start.saturating_add(gcpr).min(h);
|
||||
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
|
||||
@@ -1361,36 +1384,8 @@ pub fn derive_district_profile(
|
||||
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
|
||||
let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km);
|
||||
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district,
|
||||
// then pass it through build_district_profile so the two-phase derivation path runs.
|
||||
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
pos,
|
||||
body_params,
|
||||
climate,
|
||||
seed,
|
||||
Some(region_cache),
|
||||
);
|
||||
|
||||
let prims = invent_primitives(
|
||||
seed,
|
||||
body_params,
|
||||
climate,
|
||||
ta,
|
||||
px,
|
||||
py,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||||
);
|
||||
|
||||
// T-1168 Ruling 4b: batch-path riparian signal — edges near this
|
||||
// district invented on demand via the SAME pure function the window
|
||||
// cell invented on demand via the SAME pure function the window
|
||||
// path uses. `river_network.is_none()` degrades to `false` (see this
|
||||
// function's doc), never a panic.
|
||||
let near_perennial_water = river_network
|
||||
@@ -1407,26 +1402,37 @@ pub fn derive_district_profile(
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
build_district_profile(
|
||||
// D-256(c): the shared core, at the survey-cell-centre world metres, with
|
||||
// the pre-built region cache (batch performance — thousands of cells
|
||||
// sharing one derived region set).
|
||||
let mut profile = derive_at_metres_with_riparian(
|
||||
seed,
|
||||
body_id,
|
||||
body_params,
|
||||
climate,
|
||||
prims.slope_q,
|
||||
prims.elev_q,
|
||||
prims.ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
basin_direction,
|
||||
ta,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
climate,
|
||||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||||
near_perennial_water,
|
||||
)
|
||||
Some(region_cache),
|
||||
);
|
||||
|
||||
// D-256(c) binding requirement 1: basin_direction is inert to every other
|
||||
// field's derivation (proven in the D-256 ruling) — a post-call override
|
||||
// with the true L1 D8 value is exactly equivalent to threading it through
|
||||
// the core, and keeps the core itself free of a field only the batch
|
||||
// path can supply.
|
||||
profile.basin_direction = basin_direction;
|
||||
profile
|
||||
}
|
||||
|
||||
/// Build the climate + morphology fields of a `DistrictProfile` from its three
|
||||
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
|
||||
/// of every derivation path (cell-aggregate [`derive_district_profile`] and the
|
||||
/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240).
|
||||
/// every position-derivation caller reaches (D-256(c): `derive_district_profile`'s
|
||||
/// survey-cell-centre position and `derive_district`'s exact district position
|
||||
/// both route through it via [`derive_at_metres_with_riparian`]). Pure (T-1024,
|
||||
/// D-239 §2 / D-240).
|
||||
///
|
||||
/// ## Region baseline parameter (D-243 §3, T-1078)
|
||||
///
|
||||
@@ -1666,6 +1672,61 @@ pub fn derive_at_metres(
|
||||
climate: &ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
nearby_courses: &[river_course::InventedCourse],
|
||||
) -> DistrictProfile {
|
||||
// T-1168 Ruling 4a: the riparian point test against the caller-supplied
|
||||
// (already-culled) course slice — the SAME pure predicate the batch path
|
||||
// uses via `near_perennial_water_at`. Computed here, BEFORE the core call,
|
||||
// so the core itself never touches course geometry (D-256(c) binding
|
||||
// requirement 2: a naive wrapper passing `&[]` internally would silently
|
||||
// regress every riverside cell to `false` — this public signature and its
|
||||
// byte-behavior are unchanged by the D-256 extraction).
|
||||
let near_perennial_water = river_course::near_perennial_water((wx, wy), nearby_courses);
|
||||
derive_at_metres_with_riparian(
|
||||
seed,
|
||||
body_id,
|
||||
body_params,
|
||||
ta,
|
||||
wx,
|
||||
wy,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
near_perennial_water,
|
||||
None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction
|
||||
)
|
||||
}
|
||||
|
||||
/// The D-256(c) shared derive core — `derive_at_metres`'s former inline body,
|
||||
/// extracted so [`derive_district_profile`] can become a thin wrapper over the
|
||||
/// SAME metres-addressable derivation instead of re-implementing it. Private:
|
||||
/// the only two sanctioned callers are `derive_at_metres` (which computes
|
||||
/// `near_perennial_water` from its public `nearby_courses` slice exactly as
|
||||
/// before, and passes `None` for `region_cache` — an on-the-fly region
|
||||
/// baseline derivation, matching its pre-extraction behavior byte-for-byte)
|
||||
/// and `derive_district_profile` (which computes `near_perennial_water` via
|
||||
/// `near_perennial_water_at`, the on-demand course inventor, and passes its
|
||||
/// pre-built per-body `region_cache` for the same performance reason the
|
||||
/// batch path built one in the first place — thousands of district calls
|
||||
/// sharing one derived region set rather than each re-deriving up to 4
|
||||
/// baselines).
|
||||
///
|
||||
/// `basin_direction` on the returned profile is always [`BasinDirection::default`]
|
||||
/// (North) here — the caller-supplied true L1 D8 value, when available, is
|
||||
/// applied as a post-call field override (D-256(c) binding requirement 1: the
|
||||
/// field is inert to every other field's derivation, proven in the D-256
|
||||
/// ruling, so an override after the fact is exactly equivalent to threading it
|
||||
/// through).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn derive_at_metres_with_riparian(
|
||||
seed: SeedChain,
|
||||
body_id: &str,
|
||||
body_params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
climate: &ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
near_perennial_water: bool,
|
||||
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
|
||||
) -> DistrictProfile {
|
||||
// World metres -> fractional heightmap pixel + latitude. Mirrors
|
||||
// `derive_district`'s former inline mapping exactly, just keyed on
|
||||
@@ -1702,9 +1763,11 @@ pub fn derive_at_metres(
|
||||
..body_params.clone()
|
||||
};
|
||||
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for
|
||||
// this position. No pre-built cache here — the on-demand path derives the
|
||||
// four surrounding region baselines directly. Pure, deterministic, cheap.
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this
|
||||
// position. `region_cache` is `None` for the on-demand caller
|
||||
// (`derive_at_metres`, derives the four surrounding region baselines
|
||||
// directly — pure, deterministic, cheap) or `Some` for the batch caller
|
||||
// (`derive_district_profile`, reuses its pre-built per-body cache).
|
||||
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
@@ -1717,6 +1780,11 @@ pub fn derive_at_metres(
|
||||
// step at every district boundary at every rung, by construction — it does
|
||||
// not refine continuously the way elevation/slope do under a finer
|
||||
// min_wavelength_m.
|
||||
//
|
||||
// D-256(c): this is also what auto-fixes the batch path's former
|
||||
// region-(0,0) collapse — `derive_district_profile` now reaches this same
|
||||
// floor-divide on its own survey-cell-centre world metres instead of
|
||||
// keying off a pseudo-grid index.
|
||||
let district_pos: DistrictPos = (
|
||||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||||
@@ -1728,7 +1796,7 @@ pub fn derive_at_metres(
|
||||
¶ms,
|
||||
climate,
|
||||
seed,
|
||||
None, // no pre-built cache; derive on-the-fly
|
||||
region_cache,
|
||||
);
|
||||
|
||||
// T-1125: invented primitives — warped coastline (invented bays/capes) +
|
||||
@@ -1747,21 +1815,6 @@ pub fn derive_at_metres(
|
||||
min_wavelength_m,
|
||||
);
|
||||
|
||||
// derive_at_metres is the on-demand path (arbitrary world position, no L1
|
||||
// working grid). basin_direction is an ACCEPTED LIMITATION here: it
|
||||
// defaults to North (a fallback, not a computed value) because the D8
|
||||
// thalweg is only available from the L1 fdir grid the batch path holds.
|
||||
// Production voxel generation runs through the batch path
|
||||
// (derive_all_districts), which threads the true D8 direction from L1;
|
||||
// this on-demand path is the fallback for positions derived outside that
|
||||
// pass, where a meaningful basin_direction isn't available.
|
||||
//
|
||||
// T-1168 Ruling 4a: the riparian point test against the caller-supplied
|
||||
// (already-culled) course slice — the SAME pure predicate the batch path
|
||||
// uses via `near_perennial_water_at`.
|
||||
let near_perennial_water =
|
||||
river_course::near_perennial_water((world_x_m, world_y_m), nearby_courses);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1971,37 +2024,52 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
/// Eagerly derive a coarse profile grid covering the body, by direct heightmap
|
||||
/// tiling (`grid_cells_per_district` cells per cell).
|
||||
///
|
||||
/// **Scale note (D-243, T-1077):** this is the *coarse* eager grid — one cell per
|
||||
/// `gcpr` heightmap pixels (tens-to-hundreds of km) — kept as the Atlas zone
|
||||
/// overlay source. The **corrected 2 km carrier** the voxel chain consumes is the
|
||||
/// on-demand [`derive_district`] (heightmap interpolation + detail-scatter via the
|
||||
/// elastic seam). Swapping production from this eager grid to on-demand
|
||||
/// district/region surfacing is the chartered job of T-1046; T-1077 provides the
|
||||
/// correct on-demand derivation, not the eager/Atlas restructure.
|
||||
/// **Scale note (D-256(b)):** this is the *survey raster* — the coarse eager
|
||||
/// grid (one cell per `gcpr` heightmap pixels, tens-to-hundreds of km) kept as
|
||||
/// the Atlas zone overlay source and the L2/L3 planning input (settlement
|
||||
/// placement context, believability sampling, skeleton dispatch context). The
|
||||
/// **corrected 2 km carrier** the voxel chain consumes is the on-demand
|
||||
/// [`derive_district`] (heightmap interpolation + detail-scatter via the
|
||||
/// elastic seam). Both now route through the SAME [`derive_at_metres_with_riparian`]
|
||||
/// core (D-256(c)) — one derive core, two position sets.
|
||||
///
|
||||
/// Returns a `BTreeMap<DistrictPos, DistrictProfile>` covering the full
|
||||
/// heightmap at the given district-grid resolution.
|
||||
/// Returns a `BTreeMap<SurveyCellPos, DistrictProfile>` covering the full
|
||||
/// heightmap at the given survey-grid resolution.
|
||||
///
|
||||
/// `grid_cells_per_district = 8` means each district is 8×8 heightmap cells.
|
||||
/// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells.
|
||||
///
|
||||
/// 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.
|
||||
/// ## Region baseline (D-256(c))
|
||||
///
|
||||
/// ## Region cache (D-243 §3/§4, T-1078)
|
||||
///
|
||||
/// A [`RegionProfile`] cache is built once per body from all region positions
|
||||
/// that cover the district grid, then passed to each [`derive_district_profile`]
|
||||
/// call so the D-243 §4 edge-fuzz blend reads a consistent set of region
|
||||
/// baselines — all four corners of every blend come from the same derived set.
|
||||
/// No pre-built region cache here — the pre-D-256 pre-build keyed on the
|
||||
/// SURVEY grid's own pseudo-coordinates (`district_to_region((rx, ry))`),
|
||||
/// which does not correspond to the true region a survey cell's world-metres
|
||||
/// centre actually falls in (the survey grid can span up to ~2048 distinct
|
||||
/// true regions on a big body — it covers the whole body surface in metres,
|
||||
/// not a handful of degenerate cells). Since the shared core now floor-divides
|
||||
/// the survey cell's TRUE world metres for its own region lookup
|
||||
/// ([`derive_at_metres_with_riparian`]'s `district_pos`), the simplest
|
||||
/// deterministic option is to let each cell resolve its region baseline
|
||||
/// on-the-fly through [`derive_district_profile`]'s own `region_cache`
|
||||
/// parameter — passing an empty map here means every lookup misses and
|
||||
/// derives on the fly (pure, cheap, four `derive_region_baseline_c` calls per
|
||||
/// miss; D-227-legal). This also auto-fixes the former body-uniform
|
||||
/// region-(0,0) climate collapse: every survey cell now reads its OWN
|
||||
/// region's baseline instead of the pseudo-grid's degenerate region index.
|
||||
///
|
||||
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
|
||||
/// warp domain separation.
|
||||
/// `basin_dirs` is the per-district dominant D8 thalweg direction computed in
|
||||
/// `run_layer1` (T-1047). When `Some`, each district's `basin_direction` is read
|
||||
/// from the map; missing entries (edge districts with no land cells) default to
|
||||
///
|
||||
/// `basin_dirs` is the per-SURVEY-CELL dominant D8 thalweg direction computed
|
||||
/// in `run_layer1` (T-1047) — `Layer1Output::survey_basin_dirs`. It is
|
||||
/// **honestly a survey-cell aggregate**: each entry votes over exactly the
|
||||
/// 8×8 working-pixel block one `DistrictProfile` here summarizes, so
|
||||
/// [`SurveyCellPos`] is its correct key, not merely a convenient one, and the
|
||||
/// lookup below is identity (`m.get(&pos)`) — NOT a world-metres floor-divide
|
||||
/// into the true D-243 district grid (that would be the wrong map: the
|
||||
/// aggregate's own key space is the survey raster, never was the true grid).
|
||||
/// Missing entries (edge cells with no land cells) default to
|
||||
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
|
||||
/// every district gets `BasinDirection::North`.
|
||||
/// every cell gets `BasinDirection::North`.
|
||||
///
|
||||
/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every
|
||||
/// [`derive_district_profile`] call for the batch-path riparian signal — the
|
||||
@@ -2013,64 +2081,33 @@ pub fn derive_all_districts(
|
||||
ta: &TerrainAnalysis,
|
||||
grid_cells_per_district: usize,
|
||||
body_id: &str,
|
||||
basin_dirs: Option<&BTreeMap<DistrictPos, BasinDirection>>,
|
||||
basin_dirs: Option<&BTreeMap<SurveyCellPos, BasinDirection>>,
|
||||
river_network: Option<&RiverNetwork>,
|
||||
) -> BTreeMap<DistrictPos, DistrictProfile> {
|
||||
) -> BTreeMap<SurveyCellPos, DistrictProfile> {
|
||||
let climate = ClimateConstants::default();
|
||||
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 survey_cols = ta.w.div_ceil(gcpr) as i32;
|
||||
let survey_rows = ta.h.div_ceil(gcpr) as i32;
|
||||
|
||||
// Build the region cache once for the whole body before district derivation.
|
||||
// Collect all unique region positions that cover this district grid, plus
|
||||
// their immediate neighbours (the edge-fuzz blend samples up to one region
|
||||
// beyond the district's own region). Using a BTreeSet for determinism (D-010).
|
||||
let region_positions: std::collections::BTreeSet<RegionPos> = {
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
for ry in 0..district_rows {
|
||||
for rx in 0..district_cols {
|
||||
let district_pos = (rx, ry);
|
||||
let rpos = scale::district_to_region(district_pos);
|
||||
// The edge-fuzz blend samples the base region and one neighbour
|
||||
// in each axis direction (±1). Pre-populate all 9 candidates so
|
||||
// cache hits dominate and on-the-fly derivations are rare.
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
set.insert((rpos.0 + dx, rpos.1 + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
};
|
||||
let region_cache =
|
||||
region_profile::derive_regions_for_body(seed, body_params, &climate, region_positions);
|
||||
// D-256(c): no pre-built region cache (see this function's doc) — every
|
||||
// survey cell derives its region baseline on-the-fly through the empty
|
||||
// map below, keyed on the cell's TRUE world-metres region, not a
|
||||
// pseudo-grid index.
|
||||
let region_cache: BTreeMap<RegionPos, RegionProfile> = BTreeMap::new();
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
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 district_rows > 1 {
|
||||
90.0 - (ry as f64 / (district_rows - 1) as f64) * 180.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for rx in 0..district_cols {
|
||||
let pos = (rx, ry);
|
||||
// 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 district_params = BodyParams {
|
||||
latitude_deg: lat_deg,
|
||||
..body_params.clone()
|
||||
};
|
||||
for ry in 0..survey_rows {
|
||||
for rx in 0..survey_cols {
|
||||
let pos = SurveyCellPos(rx, ry);
|
||||
// Identity lookup: basin_dirs is keyed by SurveyCellPos (see this
|
||||
// function's doc) — the SAME survey cell this loop is deriving a
|
||||
// profile for, no position translation needed or correct.
|
||||
let basin_direction = basin_dirs
|
||||
.and_then(|m| m.get(&pos).copied())
|
||||
.unwrap_or_default();
|
||||
let profile = derive_district_profile(
|
||||
seed,
|
||||
&district_params,
|
||||
body_params,
|
||||
ta,
|
||||
pos,
|
||||
gcpr,
|
||||
@@ -2086,6 +2123,35 @@ pub fn derive_all_districts(
|
||||
out
|
||||
}
|
||||
|
||||
/// The survey cell's centre pixel → world metres, per [`derive_district_profile`]'s
|
||||
/// own D-256(b) `8·rx + 3.5` (clamped) convention — factored out so any
|
||||
/// caller resolving a `SurveyCellPos` to a world position (e.g.
|
||||
/// [`derive_all_districts`]'s `basin_dirs` lookup, or `believability::analyze`'s
|
||||
/// voxel-sample chunk resolution) reaches the SAME world position the profile
|
||||
/// itself is centred on, rather than a second, possibly-disagreeing mapping.
|
||||
///
|
||||
/// `pub` (D-256): `believability.rs` and `bin/aliveness_probe.rs` (a separate
|
||||
/// crate) both need this bridge and have no `TerrainAnalysis`/`BodyParams` in
|
||||
/// scope (they only see `BodyWorldState`'s cached dims + a body-params read),
|
||||
/// so this takes the primitive `w`/`h`/`body_radius_km` rather than the
|
||||
/// wrapper structs — the same primitives [`pixel_to_world_m`] itself takes.
|
||||
pub fn survey_cell_centre_world_m(
|
||||
pos: SurveyCellPos,
|
||||
gcpr: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
body_radius_km: Option<f64>,
|
||||
) -> (f64, f64) {
|
||||
let SurveyCellPos(rx, ry) = pos;
|
||||
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
|
||||
let row_end = row_start.saturating_add(gcpr).min(h);
|
||||
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
|
||||
let col_end = col_start.saturating_add(gcpr).min(w);
|
||||
let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0;
|
||||
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
|
||||
pixel_to_world_m(px, py, w, h, body_radius_km)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2136,10 +2202,16 @@ mod tests {
|
||||
assert_eq!(districts.len(), 32, "district count mismatch");
|
||||
}
|
||||
|
||||
/// T-1047: the `Some(basin_dirs)` threading path — supplied per-district D8
|
||||
/// directions propagate to `DistrictProfile.basin_direction`, and districts
|
||||
/// not in the map fall back to the default (North). Guards the path that the
|
||||
/// production cascade actually uses (the existing tests only exercise `None`).
|
||||
/// T-1047/D-256: the `Some(basin_dirs)` threading path — supplied
|
||||
/// per-SURVEY-CELL D8 directions propagate to
|
||||
/// `DistrictProfile.basin_direction` by IDENTITY lookup, and survey cells
|
||||
/// not in the map fall back to the default (North). `basin_dirs` mirrors
|
||||
/// the SHAPE the real producer (`Layer1Output::survey_basin_dirs`,
|
||||
/// `layer1::aggregate_survey_basin_dirs`) actually emits — keyed by
|
||||
/// [`SurveyCellPos`], the same key space `derive_all_districts` iterates
|
||||
/// — so this test exercises the production seam (survey-keyed producer →
|
||||
/// identity-lookup consumer), not a map the test invents to match its own
|
||||
/// lookup logic.
|
||||
#[test]
|
||||
fn derive_all_districts_threads_supplied_basin_directions() {
|
||||
use crate::atlas::scale::BasinDirection;
|
||||
@@ -2147,16 +2219,16 @@ mod tests {
|
||||
let ta = test_ta(&hm);
|
||||
let params = BodyParams::default();
|
||||
|
||||
// Real DistrictPos keys from a baseline (None) run.
|
||||
// Real SurveyCellPos keys from a baseline (None) run.
|
||||
let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||||
let mut keys = baseline.keys().copied();
|
||||
let pos_east = keys.next().expect("at least one district");
|
||||
let pos_south = keys.next().expect("at least two districts");
|
||||
let pos_unmapped = keys.next().expect("at least three districts");
|
||||
let cell_east = keys.next().expect("at least one survey cell");
|
||||
let cell_south = keys.next().expect("at least two survey cells");
|
||||
let cell_unmapped = keys.next().expect("at least three survey cells");
|
||||
|
||||
let mut basin_dirs: BTreeMap<DistrictPos, BasinDirection> = BTreeMap::new();
|
||||
basin_dirs.insert(pos_east, BasinDirection::East);
|
||||
basin_dirs.insert(pos_south, BasinDirection::South);
|
||||
let mut basin_dirs: BTreeMap<SurveyCellPos, BasinDirection> = BTreeMap::new();
|
||||
basin_dirs.insert(cell_east, BasinDirection::East);
|
||||
basin_dirs.insert(cell_south, BasinDirection::South);
|
||||
|
||||
let districts = derive_all_districts(
|
||||
test_seed(),
|
||||
@@ -2168,11 +2240,18 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East);
|
||||
assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South);
|
||||
// Unmapped districts fall back to the default direction (North).
|
||||
assert_eq!(
|
||||
districts[&pos_unmapped].basin_direction,
|
||||
districts[&cell_east].basin_direction,
|
||||
BasinDirection::East
|
||||
);
|
||||
assert_eq!(
|
||||
districts[&cell_south].basin_direction,
|
||||
BasinDirection::South
|
||||
);
|
||||
// A survey cell not present in basin_dirs falls back to the default
|
||||
// direction (North).
|
||||
assert_eq!(
|
||||
districts[&cell_unmapped].basin_direction,
|
||||
BasinDirection::North
|
||||
);
|
||||
}
|
||||
@@ -2946,7 +3025,7 @@ mod tests {
|
||||
planet_class: Some("temperate".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let pos = (2, 1);
|
||||
let pos = SurveyCellPos(2, 1);
|
||||
let climate = ClimateConstants::default();
|
||||
let p1 = derive_district_profile(
|
||||
test_seed(),
|
||||
@@ -3068,7 +3147,7 @@ mod tests {
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||||
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
|
||||
let first = districts.keys().next().expect("at least one district");
|
||||
assert_eq!(*first, (0, 0), "first district must be at origin");
|
||||
assert_eq!(*first, SurveyCellPos(0, 0), "first district must be at origin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,9 +71,10 @@ pub struct DrainageResult {
|
||||
/// (edge, flat peak, or ocean). Row-major, `w × h`.
|
||||
///
|
||||
/// **Transient — used within the Layer-1 pass only.** The caller aggregates a
|
||||
/// per-district dominant direction from this grid (T-1047) and carries that
|
||||
/// compact result on `Layer1Output.district_basin_dirs`; the full 131 KB
|
||||
/// grid is NOT persisted on `BodyWorldState` or the LRU cache (D-203).
|
||||
/// per-survey-cell dominant direction from this grid (T-1047, D-256(b)) and
|
||||
/// carries that compact result on `Layer1Output.survey_basin_dirs`; the
|
||||
/// full 131 KB grid is NOT persisted on `BodyWorldState` or the LRU cache
|
||||
/// (D-203).
|
||||
pub fdir: Vec<i8>,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,8 @@ pub enum GenWorkItem {
|
||||
/// `context` is the D-199 economic read-set pre-resolved at dispatch time.
|
||||
/// All 6 required fields must be populated before this item is submitted
|
||||
/// (D-199: "Missing fields abort the task … generation does not proceed with
|
||||
/// partial context").
|
||||
/// partial context") — EXCEPT `morphology_zone`, which is resolved during
|
||||
/// execution (see below, D-256(d)).
|
||||
///
|
||||
/// `body_id` routes the resulting `SkeletonGenerated` completion into the
|
||||
/// correct `BodyWorldState` cache entry (D-230).
|
||||
@@ -112,6 +113,11 @@ pub enum GenWorkItem {
|
||||
city_id: u64,
|
||||
body_id: String,
|
||||
/// D-199 economic read-set + all other context fields.
|
||||
///
|
||||
/// `context.morphology_zone` still starts life as
|
||||
/// `context_from_read_set`'s `AlluvialPlain` stub at dispatch time — it
|
||||
/// is overwritten during execution (D-256(d), see `settlement_world_m`
|
||||
/// below), not by `build_skeleton_work_item` as it was pre-D-256.
|
||||
context: Box<CityGenerationContext>,
|
||||
/// Stable content-addressable quarter id (D-194/D-230).
|
||||
quarter_id: u64,
|
||||
@@ -130,6 +136,33 @@ pub enum GenWorkItem {
|
||||
/// regardless of its contents' size (same reasoning already documented
|
||||
/// for `FillChunk.block_tags` below), so this needs no `Box`.
|
||||
exterior_catalog: ExteriorCatalog,
|
||||
/// D-256(d) exact-position `morphology_zone` judgment inputs — the
|
||||
/// settlement's own world metres (NOT the survey cell's centre, which
|
||||
/// can be hundreds of km off for a settlement near a survey-cell edge)
|
||||
/// and body params, resolved during `run_work_item` execution (where
|
||||
/// the terrain cache is reachable, unlike `BodyWorldState`'s
|
||||
/// D-203/T-1048 dropped `TerrainAnalysis`) via `derive_at_metres`.
|
||||
/// `None` body_params (no DB row for this body) skips the resolution
|
||||
/// and leaves `context.morphology_zone` at its `AlluvialPlain` stub —
|
||||
/// the same fallback the pre-D-256 dispatch-time lookup used for an
|
||||
/// empty district grid.
|
||||
settlement_world_m: (f64, f64),
|
||||
body_params: Option<Box<BodyParams>>,
|
||||
/// `SeedChain::for_body(world_seed, body_id)` — the SAME body-scoped
|
||||
/// chain `derive_all_districts`/`derive_district_profile` use, distinct
|
||||
/// from `chain` (the quarter-level chain derived further for skeleton
|
||||
/// RNG). Needed because `derive_at_metres`'s domain-separated warp
|
||||
/// fields must key on the same seed the rest of the cascade uses for
|
||||
/// this body, not a chain re-derived from the quarter seed.
|
||||
body_seed: SeedChain,
|
||||
/// Shared per-body working-grid heightmap (D-256(d)) — an `Arc` so
|
||||
/// every settlement dispatched for the same `BodyAnalyzed` completion
|
||||
/// clones a pointer, not the multi-hundred-KB `Vec<f32>`. Reconstructed
|
||||
/// once at dispatch time from `BodyWorldState.heightmap`/dims/`sea_level`
|
||||
/// (already in memory — no disk re-read) so `run_work_item` can feed
|
||||
/// [`TerrainAnalysisCache::get_or_derive`] without touching the
|
||||
/// filesystem on the Rayon worker thread.
|
||||
heightmap: std::sync::Arc<crate::atlas::heightmap::BodyHeightmap>,
|
||||
},
|
||||
/// Derive the building shell for one 64 m chunk of an existing quarter
|
||||
/// (D-230 derive phase, T-987).
|
||||
@@ -617,8 +650,12 @@ impl Default for GenerationQueue {
|
||||
/// itself is small (a `RiverNetwork` + basin list + attractor list, not the
|
||||
/// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell
|
||||
/// Vecs.
|
||||
/// `pub(crate)` (D-256(d), T-1174): `plugin.rs`'s tests construct one
|
||||
/// directly to unit-test `resolve_settlement_morphology_zone` without
|
||||
/// spinning up a full `GenerationQueue`. Otherwise entirely internal to this
|
||||
/// module's Rayon-thread execution path.
|
||||
#[derive(Debug)]
|
||||
struct TerrainAnalysisCache {
|
||||
pub(crate) struct TerrainAnalysisCache {
|
||||
entries: std::collections::BTreeMap<String, (Layer1Output, TerrainAnalysis, u64)>,
|
||||
/// Monotonic access counter (substitutes for `BodyWorldStateCache`'s
|
||||
/// `SimTick` — there is no tick concept on a background Rayon thread).
|
||||
@@ -639,6 +676,13 @@ impl TerrainAnalysisCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only constructor alias (D-256(d)) — `new` stays private-module
|
||||
/// idiomatic; this is the `pub(crate)` door for `plugin.rs`'s tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_for_test(capacity: usize) -> Self {
|
||||
Self::new(capacity)
|
||||
}
|
||||
|
||||
/// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`,
|
||||
/// re-deriving via `run_layer1` on a miss and inserting the result
|
||||
/// (evicting the LRU entry first if at capacity). Bumps the access clock
|
||||
@@ -688,6 +732,46 @@ impl TerrainAnalysisCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// D-256(d): resolve a settlement's `MorphologyZone` at its EXACT world
|
||||
/// position via `derive_at_metres`, using the terrain cache to reach a
|
||||
/// `TerrainAnalysis` without a disk re-read (`heightmap` is already in
|
||||
/// memory, reconstructed once at dispatch time from `BodyWorldState`'s
|
||||
/// cached working-grid data).
|
||||
///
|
||||
/// Returns `None` when `body_params` is absent (no DB row for this body) —
|
||||
/// the caller then leaves `context.morphology_zone` at its dispatch-time
|
||||
/// `AlluvialPlain` stub, the same fallback the pre-D-256 dispatch-time
|
||||
/// district-grid lookup used for an empty grid.
|
||||
///
|
||||
/// Extracted from `run_work_item`'s `GenerateSkeleton` arm so the resolution
|
||||
/// itself is unit-testable without going through the full skeleton-generation
|
||||
/// pipeline (`QuarterSkeleton` does not expose `morphology_zone` directly —
|
||||
/// it only affects derived fields like `layout_mode`/corridors).
|
||||
pub(crate) fn resolve_settlement_morphology_zone(
|
||||
terrain_cache: &Arc<Mutex<TerrainAnalysisCache>>,
|
||||
body_id: &str,
|
||||
body_params: Option<&BodyParams>,
|
||||
settlement_world_m: (f64, f64),
|
||||
body_seed: SeedChain,
|
||||
heightmap: &crate::atlas::heightmap::BodyHeightmap,
|
||||
) -> Option<crate::simulation::generator::MorphologyZone> {
|
||||
let params = body_params?;
|
||||
let (_l1, ta) = terrain_cache.lock().unwrap().get_or_derive(body_id, heightmap);
|
||||
let climate = ClimateConstants::default();
|
||||
let profile = crate::atlas::district_profile::derive_at_metres(
|
||||
body_seed,
|
||||
body_id,
|
||||
params,
|
||||
&ta,
|
||||
settlement_world_m.0,
|
||||
settlement_world_m.1,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
Some(profile.morphology_zone)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work execution stub
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -761,13 +845,36 @@ fn run_work_item(
|
||||
population,
|
||||
founding_age_years,
|
||||
exterior_catalog,
|
||||
settlement_world_m,
|
||||
body_params,
|
||||
body_seed,
|
||||
heightmap,
|
||||
} => {
|
||||
// D-256(d): resolve morphology_zone at the settlement's EXACT world
|
||||
// position, here at execution time — this is where TerrainAnalysis
|
||||
// is reachable (BodyWorldState drops it, D-203/T-1048). A survey
|
||||
// cell's centre can be hundreds of km from a settlement near its
|
||||
// edge; the exact-position derive closes that annotation-vs-canvas
|
||||
// disagreement class before T-1181/T-1182 draw batch-judged
|
||||
// annotations onto window-derived canvases.
|
||||
let mut resolved_context = (**context).clone();
|
||||
if let Some(zone) = resolve_settlement_morphology_zone(
|
||||
terrain_cache,
|
||||
body_id,
|
||||
body_params.as_deref(),
|
||||
*settlement_world_m,
|
||||
*body_seed,
|
||||
heightmap,
|
||||
) {
|
||||
resolved_context.morphology_zone = zone;
|
||||
}
|
||||
|
||||
// Build the Phase 1 skeleton from the pre-resolved D-199 context.
|
||||
// `economic_role`, `population`, and `founding_age_years` are the
|
||||
// D-199 raw fields carried alongside the context because
|
||||
// `generate_quarter_skeleton` accepts them as separate parameters.
|
||||
let skeleton = generate_quarter_skeleton(
|
||||
context,
|
||||
&resolved_context,
|
||||
*population,
|
||||
economic_role,
|
||||
*quarter_id,
|
||||
@@ -780,7 +887,7 @@ fn run_work_item(
|
||||
// same pass.
|
||||
let block_tags = assign_all_block_tags(
|
||||
&skeleton,
|
||||
context,
|
||||
&resolved_context,
|
||||
economic_role,
|
||||
*founding_age_years,
|
||||
*chain,
|
||||
@@ -824,9 +931,13 @@ fn run_work_item(
|
||||
Ok(hm) => {
|
||||
// Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the
|
||||
// window derive must run on the SAME working-grid resolution the
|
||||
// whole-body cascade uses, or district positions between the two
|
||||
// views would disagree (derive_district maps DistrictPos through
|
||||
// ta.w/ta.h, T-1137 decision note).
|
||||
// whole-body cascade uses, or the TRUE DistrictPos → world-metres
|
||||
// mapping between the two views would disagree (derive_district
|
||||
// maps DistrictPos through ta.w/ta.h, T-1137 decision note). This
|
||||
// is resolution consistency (ta.w/ta.h must match), a DIFFERENT
|
||||
// concern from D-256's survey-raster/true-district namespace
|
||||
// collision — both views here already address the true D-243
|
||||
// grid, so D-256 doesn't touch this comment's claim.
|
||||
let working = if hm.width > GRID_W || hm.height > GRID_H {
|
||||
hm.downsample(GRID_W, GRID_H)
|
||||
} else {
|
||||
@@ -990,6 +1101,20 @@ mod tests {
|
||||
population: 500_000,
|
||||
founding_age_years: 200,
|
||||
exterior_catalog: ExteriorCatalog::default(),
|
||||
// These tests exercise queue mechanics, not the D-256(d)
|
||||
// exact-position resolution — `body_params: None` skips it
|
||||
// entirely (the same fallback path an empty district grid used
|
||||
// pre-D-256), so the position/heightmap values below are inert.
|
||||
settlement_world_m: (0.0, 0.0),
|
||||
body_params: None,
|
||||
body_seed: SeedChain::for_body(42 + city_id, &format!("TestBody{city_id}")),
|
||||
heightmap: std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap {
|
||||
body_id: format!("TestBody{city_id}"),
|
||||
width: 1,
|
||||
height: 1,
|
||||
data: vec![0.5],
|
||||
sea_level: 0.3,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-37
@@ -27,7 +27,7 @@ use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
use crate::atlas::drainage::{self, DrainageResult};
|
||||
use crate::atlas::features::{self, TerrainAnalysis};
|
||||
use crate::atlas::heightmap::BodyHeightmap;
|
||||
use crate::atlas::scale::{BasinDirection, DistrictPos, HEIGHTMAP_CELLS_PER_DISTRICT};
|
||||
use crate::atlas::scale::{BasinDirection, HEIGHTMAP_CELLS_PER_DISTRICT, SurveyCellPos};
|
||||
use crate::atlas::subbiome;
|
||||
use crate::simulation::generator::{AttractorType, GeographicAttractor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -47,23 +47,30 @@ pub struct Layer1Output {
|
||||
/// so the overlay scale stays correct for any source resolution (mod-safe).
|
||||
pub grid_w: u32,
|
||||
pub grid_h: u32,
|
||||
/// Dominant D8 thalweg direction per district, aggregated from the `fdir`
|
||||
/// Dominant D8 thalweg direction per SURVEY CELL, aggregated from the `fdir`
|
||||
/// grid during the Layer-1 drainage pass (T-1047, D-239 §8). Each entry
|
||||
/// holds the cardinal direction with the most votes among non-ocean cells in
|
||||
/// that district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
|
||||
/// as the grid-to-district mapping.
|
||||
/// that cell's covering 8×8 working-grid pixel block. Keyed by
|
||||
/// [`SurveyCellPos`] (D-256(b)) using `HEIGHTMAP_CELLS_PER_DISTRICT` as the
|
||||
/// grid-to-cell mapping — the SAME survey raster `derive_all_districts`
|
||||
/// builds `DistrictProfile`s over, not the true D-243 `DistrictPos` grid
|
||||
/// (D-256: this field predates the newtype and escaped the initial sweep;
|
||||
/// the aggregate is honestly a survey-cell aggregate — it votes over
|
||||
/// exactly the pixel block one `DistrictProfile` summarizes — so
|
||||
/// `SurveyCellPos` is its correct, not just convenient, key).
|
||||
///
|
||||
/// This is the **true D8-computed direction** — not a seed-bit proxy — so
|
||||
/// `DistrictProfile.basin_direction` (and downstream `ChunkContext`) respect
|
||||
/// drainage monotonicity (D-239 §8: respect the D8 thalweg).
|
||||
/// The VALUES are the **true D8-computed direction** — not a seed-bit
|
||||
/// proxy — so `DistrictProfile.basin_direction` (and downstream
|
||||
/// `ChunkContext`) respect drainage monotonicity (D-239 §8: respect the D8
|
||||
/// thalweg). Only the KEY space is the coarse survey raster.
|
||||
///
|
||||
/// **Transient:** skipped in serialization (`#[serde(skip)]`) — this field is
|
||||
/// a cascade-internal transport from `run_layer1` to `derive_all_districts`
|
||||
/// and is re-derived on each `run_layer1` call. The per-district direction is
|
||||
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
|
||||
/// and is re-derived on each `run_layer1` call. The per-survey-cell direction
|
||||
/// is persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
|
||||
/// after the cascade consumes it.
|
||||
#[serde(skip)]
|
||||
pub district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
|
||||
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
|
||||
}
|
||||
|
||||
/// Run the Layer-1 topography pipeline for a single body.
|
||||
@@ -95,9 +102,10 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Aggregate per-district dominant D8 direction from the fdir grid (T-1047,
|
||||
// D-239 §8). fdir is available here before it is discarded — do NOT expose
|
||||
// the full grid on DrainageResult externally. The compact per-district map
|
||||
// Aggregate per-survey-cell dominant D8 direction from the fdir grid
|
||||
// (T-1047, D-239 §8; D-256(b) survey raster — NOT the true D-243 district
|
||||
// grid). fdir is available here before it is discarded — do NOT expose
|
||||
// the full grid on DrainageResult externally. The compact per-cell map
|
||||
// (~6 000 entries) is what propagates into Layer1Output and DistrictProfile.
|
||||
//
|
||||
// Mapping fdir index → 4-way cardinal (D-010 integer; matches D8 table):
|
||||
@@ -107,8 +115,8 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
// 6 SE → S
|
||||
// 7 SW → S
|
||||
// -1 → skip (no outflow: edge, flat peak, ocean)
|
||||
let district_basin_dirs =
|
||||
aggregate_district_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
|
||||
let survey_basin_dirs =
|
||||
aggregate_survey_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
|
||||
|
||||
let l1 = Layer1Output {
|
||||
body_id: hm.body_id.clone(),
|
||||
@@ -117,32 +125,34 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
attractors,
|
||||
grid_w: hm.width,
|
||||
grid_h: hm.height,
|
||||
district_basin_dirs,
|
||||
survey_basin_dirs,
|
||||
};
|
||||
(l1, ta)
|
||||
}
|
||||
|
||||
/// Aggregate a per-district dominant D8 flow direction from the full-grid `fdir`
|
||||
/// (index into the D8 table, -1 = no outflow). Ocean-masked cells are excluded
|
||||
/// from voting so coastal districts do not skew toward the ocean sink direction.
|
||||
/// Aggregate a per-survey-cell dominant D8 flow direction from the full-grid
|
||||
/// `fdir` (index into the D8 table, -1 = no outflow). Ocean-masked cells are
|
||||
/// excluded from voting so coastal cells do not skew toward the ocean sink
|
||||
/// direction.
|
||||
///
|
||||
/// Each non-ocean, non-sink cell casts one vote for its cardinal direction
|
||||
/// (diagonals NE/NW fold to N, SE/SW fold to S). Ties broken by cardinal
|
||||
/// precedence (N > S > E > W). Districts with no valid votes default to `North`.
|
||||
/// precedence (N > S > E > W). Survey cells with no valid votes default to
|
||||
/// `North`.
|
||||
///
|
||||
/// Integer arithmetic throughout (D-010).
|
||||
fn aggregate_district_basin_dirs(
|
||||
fn aggregate_survey_basin_dirs(
|
||||
fdir: &[i8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
ocean_mask: &[bool],
|
||||
) -> BTreeMap<DistrictPos, BasinDirection> {
|
||||
) -> BTreeMap<SurveyCellPos, BasinDirection> {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
|
||||
// Per-district vote counts: [N, S, E, W].
|
||||
let mut votes: BTreeMap<DistrictPos, [i32; 4]> = BTreeMap::new();
|
||||
// Per-survey-cell vote counts: [N, S, E, W].
|
||||
let mut votes: BTreeMap<SurveyCellPos, [i32; 4]> = BTreeMap::new();
|
||||
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
@@ -163,19 +173,19 @@ fn aggregate_district_basin_dirs(
|
||||
7 => 1, // SW → S
|
||||
_ => continue,
|
||||
};
|
||||
let district_pos: DistrictPos = ((c / gcpd) as i32, (r / gcpd) as i32);
|
||||
votes.entry(district_pos).or_insert([0i32; 4])[vote] += 1;
|
||||
let cell_pos = SurveyCellPos((c / gcpd) as i32, (r / gcpd) as i32);
|
||||
votes.entry(cell_pos).or_insert([0i32; 4])[vote] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// For each district, pick the cardinal with the most votes.
|
||||
// For each survey cell, pick the cardinal with the most votes.
|
||||
// Tie-breaking order: N > S > E > W (matches D8 priority).
|
||||
let district_cols = w.div_ceil(gcpd) as i32;
|
||||
let district_rows = h.div_ceil(gcpd) as i32;
|
||||
let survey_cols = w.div_ceil(gcpd) as i32;
|
||||
let survey_rows = h.div_ceil(gcpd) as i32;
|
||||
let mut out = BTreeMap::new();
|
||||
for dy in 0..district_rows {
|
||||
for dx in 0..district_cols {
|
||||
let pos: DistrictPos = (dx, dy);
|
||||
for dy in 0..survey_rows {
|
||||
for dx in 0..survey_cols {
|
||||
let pos = SurveyCellPos(dx, dy);
|
||||
let dir = if let Some(v) = votes.get(&pos) {
|
||||
// N=0, S=1, E=2, W=3 in descending priority for tie-breaking.
|
||||
let mut best_votes = -1i32;
|
||||
@@ -195,7 +205,7 @@ fn aggregate_district_basin_dirs(
|
||||
}
|
||||
best_dir
|
||||
} else {
|
||||
BasinDirection::North // ocean-only or empty district: default
|
||||
BasinDirection::North // ocean-only or empty cell: default
|
||||
};
|
||||
out.insert(pos, dir);
|
||||
}
|
||||
@@ -300,11 +310,11 @@ mod tests {
|
||||
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
|
||||
}
|
||||
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
|
||||
// district_basin_dirs is deterministic and non-empty on a slope grid.
|
||||
assert_eq!(o1.district_basin_dirs, o2.district_basin_dirs);
|
||||
// survey_basin_dirs is deterministic and non-empty on a slope grid.
|
||||
assert_eq!(o1.survey_basin_dirs, o2.survey_basin_dirs);
|
||||
assert!(
|
||||
!o1.district_basin_dirs.is_empty(),
|
||||
"slope grid must produce district basin directions"
|
||||
!o1.survey_basin_dirs.is_empty(),
|
||||
"slope grid must produce survey-cell basin directions"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+293
-27
@@ -667,19 +667,33 @@ pub struct AtlasLayerResponse {
|
||||
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
|
||||
/// Returns `None` when the DistrictProfile layer has not run (empty `districts`).
|
||||
/// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full
|
||||
/// heightmap), so the extent comes from the maximum `DistrictPos`.
|
||||
/// heightmap), so the extent comes from the maximum `SurveyCellPos` (D-256(b)
|
||||
/// — this is the coarse survey raster, not the true district grid).
|
||||
pub fn build_district_grid(
|
||||
state: &crate::atlas::body_world_state::BodyWorldState,
|
||||
) -> Option<DistrictGridLayer> {
|
||||
if state.districts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = state.districts.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1;
|
||||
let rows = state.districts.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1;
|
||||
let cols = state
|
||||
.districts
|
||||
.keys()
|
||||
.map(|p| p.0)
|
||||
.max()
|
||||
.unwrap_or(0) as u32
|
||||
+ 1;
|
||||
let rows = state
|
||||
.districts
|
||||
.keys()
|
||||
.map(|p| p.1)
|
||||
.max()
|
||||
.unwrap_or(0) as u32
|
||||
+ 1;
|
||||
let n = (cols * rows) as usize;
|
||||
let mut morphology = vec![0u8; n];
|
||||
let mut elev_q = vec![0u8; n];
|
||||
for (&(x, y), profile) in &state.districts {
|
||||
for (pos, profile) in &state.districts {
|
||||
let (x, y) = (pos.0, pos.1);
|
||||
if x < 0 || y < 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -2286,13 +2300,13 @@ pub fn handle_atlas_request(
|
||||
// working grid all Layer-1 positions are expressed in (#960).
|
||||
grid_w: state.heightmap_width,
|
||||
grid_h: state.heightmap_height,
|
||||
// district_basin_dirs is transient — it is aggregated during run_layer1
|
||||
// survey_basin_dirs is transient — it is aggregated during run_layer1
|
||||
// and consumed by derive_all_districts before being stored on
|
||||
// BodyWorldState. When reconstructing Layer1Output from the cache for
|
||||
// the client response, the per-district direction is already encoded in
|
||||
// DistrictProfile.basin_direction (BodyWorldState.districts) and is not
|
||||
// needed again here. Supply an empty map.
|
||||
district_basin_dirs: std::collections::BTreeMap::new(),
|
||||
// the client response, the per-survey-cell direction is already encoded
|
||||
// in DistrictProfile.basin_direction (BodyWorldState.districts) and is
|
||||
// not needed again here. Supply an empty map.
|
||||
survey_basin_dirs: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let district_grid = build_district_grid(state);
|
||||
let road_graph = build_road_graph_layer(state);
|
||||
@@ -2450,6 +2464,7 @@ mod tests {
|
||||
heightmap: vec![],
|
||||
heightmap_width: 16,
|
||||
heightmap_height: 8,
|
||||
sea_level: 0.3,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
@@ -2461,12 +2476,14 @@ mod tests {
|
||||
last_accessed: 0,
|
||||
};
|
||||
// 3×2 grid with two distinct zones at the corners.
|
||||
state
|
||||
.districts
|
||||
.insert((0, 0), dp(MorphologyZone::AlluvialPlain, 10));
|
||||
state
|
||||
.districts
|
||||
.insert((2, 1), dp(MorphologyZone::Alpine, 90));
|
||||
state.districts.insert(
|
||||
crate::atlas::scale::SurveyCellPos(0, 0),
|
||||
dp(MorphologyZone::AlluvialPlain, 10),
|
||||
);
|
||||
state.districts.insert(
|
||||
crate::atlas::scale::SurveyCellPos(2, 1),
|
||||
dp(MorphologyZone::Alpine, 90),
|
||||
);
|
||||
|
||||
let grid = build_district_grid(&state).expect("districts present → Some grid");
|
||||
assert_eq!((grid.cols, grid.rows), (3, 2));
|
||||
@@ -2661,7 +2678,7 @@ mod tests {
|
||||
assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8);
|
||||
}
|
||||
|
||||
/// T-1170/T-1168 A5 integration: the batch path
|
||||
/// T-1170/T-1168 A5 integration, strengthened by D-256: the batch path
|
||||
/// (`derive_district_profile`, sourcing courses via `near_perennial_water_at`
|
||||
/// on demand) and the window path (`build_district_window_layer`,
|
||||
/// sourcing courses via the pre-invented `Vec<InventedCourse>`) must
|
||||
@@ -2670,17 +2687,21 @@ mod tests {
|
||||
/// binding requirement, checked end to end (not just at the
|
||||
/// `near_perennial_water`/`near_perennial_water_at` unit level).
|
||||
///
|
||||
/// **Design note:** this test deliberately does NOT compare the batch
|
||||
/// and window paths' full `DistrictProfile` output for "the same
|
||||
/// district" — `derive_district_profile`'s cell-aggregate-centre
|
||||
/// sampling and the window path's district-origin sampling are
|
||||
/// legitimate, PRE-EXISTING different world positions for the same
|
||||
/// `DistrictPos` (a real quirk of the two derivation strategies,
|
||||
/// unrelated to T-1168/T-1170), so `morphology_zone`/`elev_q`/etc.
|
||||
/// routinely differ between them even before this batch's riparian work.
|
||||
/// Instead this test isolates the ONE signal this batch actually wires
|
||||
/// (`near_perennial_water`) at a SHARED, EXACT world position, proving
|
||||
/// the two paths' independent riparian derivations agree there.
|
||||
/// **Design note (D-256):** pre-D-256 this test deliberately did NOT
|
||||
/// compare the batch and window paths' full `DistrictProfile` output for
|
||||
/// "the same district" — `derive_district_profile`'s cell-aggregate-centre
|
||||
/// sampling and the window path's district-origin sampling resolved
|
||||
/// genuinely different world positions for a shared nominal `DistrictPos`
|
||||
/// (the batch namespace collision D-256 rules on). With sampling unified
|
||||
/// (one derive core, D-256(c)), that framing is now FALSE: batch and
|
||||
/// window positions agree BY CONSTRUCTION — `derive_district_profile` is
|
||||
/// a thin wrapper over the exact same [`crate::atlas::district_profile::derive_at_metres`]
|
||||
/// family the window path calls, at the survey cell's own D-256(b) centre
|
||||
/// world metres. `full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre`
|
||||
/// below is the strengthened full-profile bit-identical check this design
|
||||
/// note used to explicitly rule out; this test keeps the riparian-signal
|
||||
/// check as a focused shared-exact-position regression (the ONE hand-wired
|
||||
/// signal T-1168 added, worth its own targeted assertion).
|
||||
#[test]
|
||||
fn window_and_batch_paths_agree_on_riparian_signal_near_a_real_river_edge() {
|
||||
use crate::atlas::drainage;
|
||||
@@ -2763,6 +2784,249 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// D-256(c): the batch survey-cell profile == `derive_at_metres` at the
|
||||
/// SAME survey-cell-centre world metres — bit-identical, field for field
|
||||
/// (basin overridden on the expectation since the core always returns the
|
||||
/// default and the wrapper post-call-overrides it; riparian threaded
|
||||
/// equivalently through both paths' own mechanism). This is the
|
||||
/// strengthened replacement for the pre-D-256 "legitimate different
|
||||
/// positions" design note on the riparian-only test above — positions now
|
||||
/// agree by construction, so the FULL profile must too.
|
||||
#[test]
|
||||
fn full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre() {
|
||||
use crate::atlas::district_profile::{self, ClimateConstants};
|
||||
use crate::atlas::drainage;
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
use crate::atlas::scale::{self, BasinDirection, SurveyCellPos};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(256, 128);
|
||||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||||
let rn = &dr.river_network;
|
||||
|
||||
let params = crate::atlas::district_profile::BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||||
let climate = ClimateConstants::default();
|
||||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
|
||||
// An interior survey cell (not clamped at the grid edge — that case
|
||||
// is covered by the dedicated edge-truncation test below).
|
||||
let cell = SurveyCellPos(10, 6);
|
||||
let basin = BasinDirection::East;
|
||||
|
||||
let batch_profile = district_profile::derive_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
&ta,
|
||||
cell,
|
||||
gcpr,
|
||||
&climate,
|
||||
"GJ1c",
|
||||
&BTreeMap::new(),
|
||||
basin,
|
||||
Some(rn),
|
||||
);
|
||||
|
||||
// The SAME survey-cell-centre world metres, resolved via the SAME
|
||||
// D-256(b) bridge function derive_district_profile uses internally.
|
||||
let (world_x_m, world_y_m) =
|
||||
district_profile::survey_cell_centre_world_m(cell, gcpr, ta.w, ta.h, params.body_radius_km);
|
||||
|
||||
// Riparian equivalent: near_perennial_water_at against the same
|
||||
// RiverNetwork, same station spacing/cutoff derive_district_profile
|
||||
// uses internally — the SAME on-demand course invention, not the
|
||||
// window path's pre-invented slice (this is the batch-vs-on-demand
|
||||
// core identity, not the batch-vs-window riparian check above).
|
||||
//
|
||||
// derive_at_metres's PUBLIC signature only accepts a pre-invented
|
||||
// `nearby_courses` slice (never a raw bool — Ruling 4a), so to force
|
||||
// the SAME riparian verdict through the public path a single-point
|
||||
// synthetic course exactly at (world_x_m, world_y_m) is threaded when
|
||||
// the on-demand signal is true; an empty slice when false. Either way
|
||||
// `near_perennial_water((wx,wy), courses)` evaluates to the identical
|
||||
// bool the batch core read.
|
||||
let near_perennial_water = river_course::near_perennial_water_at(
|
||||
seed,
|
||||
&ta,
|
||||
¶ms,
|
||||
rn,
|
||||
(world_x_m, world_y_m),
|
||||
DISTRICT_M as f64,
|
||||
0.0,
|
||||
);
|
||||
let riparian_equivalent: Vec<InventedCourse> = if near_perennial_water {
|
||||
vec![InventedCourse {
|
||||
edge_id: 0,
|
||||
class: 0,
|
||||
terminus: EdgeTerminusKind::Interior,
|
||||
points: vec![(world_x_m, world_y_m)],
|
||||
bbox: (world_x_m, world_y_m, world_x_m, world_y_m),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut expected = district_profile::derive_at_metres(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
&climate,
|
||||
0.0,
|
||||
&riparian_equivalent,
|
||||
);
|
||||
// basin_direction (inert field, D-256(c) proof) — the core always
|
||||
// returns the default; the wrapper post-call-overrides it.
|
||||
expected.basin_direction = basin;
|
||||
|
||||
assert_eq!(batch_profile.morphology_zone as u8, expected.morphology_zone as u8);
|
||||
assert_eq!(batch_profile.tectonic_class as u8, expected.tectonic_class as u8);
|
||||
assert_eq!(batch_profile.glaciation_grade as u8, expected.glaciation_grade as u8);
|
||||
assert_eq!(
|
||||
batch_profile.precipitation_class as u8,
|
||||
expected.precipitation_class as u8
|
||||
);
|
||||
assert_eq!(batch_profile.slope_q, expected.slope_q);
|
||||
assert_eq!(batch_profile.elev_q, expected.elev_q);
|
||||
assert_eq!(batch_profile.ocean_fraction_q, expected.ocean_fraction_q);
|
||||
assert_eq!(batch_profile.river_threshold, expected.river_threshold);
|
||||
assert_eq!(batch_profile.temperature_c, expected.temperature_c);
|
||||
assert_eq!(batch_profile.moisture_q, expected.moisture_q);
|
||||
assert_eq!(
|
||||
batch_profile.vegetation_class as u8,
|
||||
expected.vegetation_class as u8
|
||||
);
|
||||
assert_eq!(batch_profile.basin_direction as u8, expected.basin_direction as u8);
|
||||
}
|
||||
|
||||
/// D-256(c) invariant: the wrapper (`derive_district_profile`) ≡ core
|
||||
/// (`derive_at_metres`) identity holds at survey-grid edge cases — the
|
||||
/// anti-meridian column (`px` near the wrap boundary), the pole rows
|
||||
/// (latitude clamp), and edge-truncated cells (the covering pixel block
|
||||
/// itself clamped at `w`/`h`, not just the resulting position). No
|
||||
/// riparian signal threaded here (both sides get `river_network: None` /
|
||||
/// `nearby_courses: &[]` — the riparian equivalence is the DEDICATED
|
||||
/// concern of the test above; this one isolates the geometric position
|
||||
/// mapping across the grid's hard edges).
|
||||
#[test]
|
||||
fn wrapper_matches_core_at_survey_grid_edge_cases() {
|
||||
use crate::atlas::district_profile::{self, ClimateConstants};
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
use crate::atlas::scale::{self, BasinDirection, SurveyCellPos};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// The committed GJ1c heightmap (same fixture the other integration
|
||||
// tests in this module use), downsampled to the standard 256×128
|
||||
// working grid so the survey raster is a real body's shape.
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let hm = heightmap.downsample(256, 128);
|
||||
let dr = crate::atlas::drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&hm, &dr);
|
||||
|
||||
let params = crate::atlas::district_profile::BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 2);
|
||||
let climate = ClimateConstants::default();
|
||||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
let survey_cols = (hm.width as usize).div_ceil(gcpr) as i32; // 32
|
||||
let survey_rows = (hm.height as usize).div_ceil(gcpr) as i32; // 16
|
||||
|
||||
let cases: &[(&str, SurveyCellPos)] = &[
|
||||
("west edge / row 0 corner", SurveyCellPos(0, 0)),
|
||||
(
|
||||
"anti-meridian column (max col)",
|
||||
SurveyCellPos(survey_cols - 1, survey_rows / 2),
|
||||
),
|
||||
("north pole row", SurveyCellPos(survey_cols / 2, 0)),
|
||||
(
|
||||
"south pole row",
|
||||
SurveyCellPos(survey_cols / 2, survey_rows - 1),
|
||||
),
|
||||
(
|
||||
"edge-truncated SE corner",
|
||||
SurveyCellPos(survey_cols - 1, survey_rows - 1),
|
||||
),
|
||||
];
|
||||
|
||||
for &(label, cell) in cases {
|
||||
let batch_profile = district_profile::derive_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
&ta,
|
||||
cell,
|
||||
gcpr,
|
||||
&climate,
|
||||
"GJ1c",
|
||||
&BTreeMap::new(),
|
||||
BasinDirection::North, // default — no override under test here
|
||||
None,
|
||||
);
|
||||
let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m(
|
||||
cell,
|
||||
gcpr,
|
||||
ta.w,
|
||||
ta.h,
|
||||
params.body_radius_km,
|
||||
);
|
||||
let expected = district_profile::derive_at_metres(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
batch_profile.morphology_zone as u8, expected.morphology_zone as u8,
|
||||
"[{label}] morphology_zone mismatch at {cell:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
batch_profile.elev_q, expected.elev_q,
|
||||
"[{label}] elev_q mismatch at {cell:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
batch_profile.slope_q, expected.slope_q,
|
||||
"[{label}] slope_q mismatch at {cell:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
batch_profile.ocean_fraction_q, expected.ocean_fraction_q,
|
||||
"[{label}] ocean_fraction_q mismatch at {cell:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
batch_profile.temperature_c, expected.temperature_c,
|
||||
"[{label}] temperature_c mismatch at {cell:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
batch_profile.moisture_q, expected.moisture_q,
|
||||
"[{label}] moisture_q mismatch at {cell:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discipline item 3(a), mandatory: two overlapping windows sharing a
|
||||
/// stretch of the same edge must produce BYTE-IDENTICAL course points
|
||||
/// for that shared stretch (Ruling 1e, the window-independence
|
||||
@@ -5226,6 +5490,7 @@ mod tests {
|
||||
heightmap: vec![],
|
||||
heightmap_width: 16,
|
||||
heightmap_height: 8,
|
||||
sea_level: 0.3,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
@@ -5572,6 +5837,7 @@ mod tests {
|
||||
heightmap: vec![0.0; 4],
|
||||
heightmap_width: 2,
|
||||
heightmap_height: 2,
|
||||
sea_level: 0.3,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
|
||||
+244
-121
@@ -12,6 +12,7 @@ use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::atlas::atlas_data_proxy::{
|
||||
handle_city_names_request, handle_star_map_request, StarMapDataPath,
|
||||
@@ -25,7 +26,7 @@ use crate::atlas::city_context_reader::{
|
||||
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
|
||||
};
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
||||
use crate::atlas::district_profile::{self, BodyParams, DistrictPos};
|
||||
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
|
||||
use crate::atlas::layer_proxy::{
|
||||
handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache,
|
||||
@@ -51,7 +52,7 @@ use crate::bridge::{
|
||||
};
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{
|
||||
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
|
||||
BulkClass, DistrictType, MaintenanceAuthority, ProductionUbiquity, WorldTier,
|
||||
};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -227,6 +228,7 @@ fn drain_generation_completions(
|
||||
mut window_cache: ResMut<DistrictWindowCache>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
|
||||
body_params_reader: Option<Res<BodyParamsReaderResource>>,
|
||||
rng: Option<Res<SimRng>>,
|
||||
) {
|
||||
for completion in queue.drain_completions() {
|
||||
@@ -244,6 +246,47 @@ fn drain_generation_completions(
|
||||
let world_seed = rng.seed();
|
||||
let body_id = state.body_id.clone();
|
||||
|
||||
// D-256(d): body physical params, re-read here (same pattern
|
||||
// as `serve_atlas_requests`'s `params_reader` — a cheap DB
|
||||
// row read on the main thread, mirroring the `AnalyzeBody`
|
||||
// dispatch-time precedent at T-1023's original call site) so
|
||||
// `run_work_item`'s exact-position morphology_zone derive
|
||||
// has the settlement's body radius. `None` on a read
|
||||
// failure or absent reader — the exact-position resolution
|
||||
// then skips and `context.morphology_zone` stays at its
|
||||
// `AlluvialPlain` stub (same fallback as an empty district
|
||||
// grid pre-D-256).
|
||||
let dispatch_body_params: Option<Box<BodyParams>> = match body_params_reader
|
||||
.as_ref()
|
||||
{
|
||||
Some(reader) => reader
|
||||
.0
|
||||
.read_body_params(&body_id)
|
||||
.map(Box::new)
|
||||
.map(Some)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
body_id = %body_id,
|
||||
error = %e,
|
||||
"L3→L4 dispatch: body_params read failed — morphology_zone stays AlluvialPlain"
|
||||
);
|
||||
None
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
// D-256(d): shared per-body heightmap for `run_work_item`'s
|
||||
// `TerrainAnalysisCache::get_or_derive` — built once from
|
||||
// data already in memory (no disk re-read), `Arc`'d so every
|
||||
// settlement dispatched below clones a pointer.
|
||||
let dispatch_heightmap =
|
||||
std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap {
|
||||
body_id: body_id.clone(),
|
||||
width: state.heightmap_width,
|
||||
height: state.heightmap_height,
|
||||
data: state.heightmap.clone(),
|
||||
sea_level: state.sea_level,
|
||||
});
|
||||
|
||||
// ── T-994 (D-232): body-level aggregation for the phase-1
|
||||
// trait-vocabulary K-draw ───────────────────────────────────
|
||||
// Read each placement's D-199 read-set once — reused both to
|
||||
@@ -351,9 +394,12 @@ fn drain_generation_completions(
|
||||
world_seed,
|
||||
placement,
|
||||
read_set,
|
||||
&state.districts,
|
||||
state.heightmap_width,
|
||||
state.heightmap_height,
|
||||
dispatch_body_params.as_deref(),
|
||||
&state.road_graph,
|
||||
&vocab,
|
||||
Arc::clone(&dispatch_heightmap),
|
||||
),
|
||||
GenPriority::Low,
|
||||
);
|
||||
@@ -549,9 +595,14 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
|
||||
/// - `founding_orientation` — from the attractor-matched placement (D-213).
|
||||
/// - `political_archetype` — from the attractor-matched placement (D-214, T-1039),
|
||||
/// replacing the `Commission` stub in `context_from_read_set`.
|
||||
/// - `morphology_zone` — from the `DistrictProfile` covering this placement's
|
||||
/// heightmap-grid pixel, via `state.districts` (D-239 §6, T-1039). Falls back
|
||||
/// to `AlluvialPlain` when the district grid is empty (unit tests, early cascade).
|
||||
/// - `morphology_zone` — **NOT resolved here** (D-256(d), T-1174). It stays at
|
||||
/// `context_from_read_set`'s `AlluvialPlain` stub through this function; the
|
||||
/// work item instead carries `settlement_world_m`/`body_params`/`body_seed`/
|
||||
/// `heightmap` so `run_work_item` can resolve it via an exact-position
|
||||
/// `derive_at_metres` call during execution, where `TerrainAnalysis` is
|
||||
/// reachable (`BodyWorldState` drops it, D-203/T-1048) — a survey cell's
|
||||
/// centre (the pre-D-256 lookup key) can be hundreds of km from a
|
||||
/// settlement near the cell's edge.
|
||||
/// - `road_entry_directions` — derived from `state.road_graph`: for each road edge
|
||||
/// incident on this city, the compass octant (0=N…7=NW) of the bearing from the
|
||||
/// city toward the far endpoint, de-duplicated per octant and ordered by descending
|
||||
@@ -568,6 +619,15 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
|
||||
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
|
||||
/// city)` — not the `city_id * 10` placeholder.
|
||||
///
|
||||
/// `heightmap_width`/`heightmap_height` are the body's working-grid dims
|
||||
/// (`BodyWorldState.heightmap_width`/`heightmap_height`) — used to convert the
|
||||
/// placement's pixel position to world metres via `pixel_to_world_m` (D-256(b)'s
|
||||
/// bridge function), for both `settlement_world_m` and the true `DistrictPos`
|
||||
/// used by `settlement_district_pos`/`pick_district_dominant_by_type` (D-256(a):
|
||||
/// `DistrictPos` canonically means the true D-243 grid — the pre-D-256
|
||||
/// `heightmap_pixel_to_district` conversion actually returned a survey-raster
|
||||
/// position mislabeled as a district).
|
||||
///
|
||||
/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994):
|
||||
/// `trait_selection` (phase 1, computed once per body by the caller) and the
|
||||
/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this
|
||||
@@ -575,14 +635,18 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
|
||||
/// function's argument count under the clippy `too_many_arguments` threshold.
|
||||
///
|
||||
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_skeleton_work_item(
|
||||
body_id: &str,
|
||||
world_seed: u64,
|
||||
placement: &CityPlacement,
|
||||
read_set: CityEconomicReadSet,
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
heightmap_width: u32,
|
||||
heightmap_height: u32,
|
||||
body_params: Option<&BodyParams>,
|
||||
road_graph: &RoadGraph,
|
||||
vocab: &BodyVocabularyContext,
|
||||
heightmap: Arc<crate::atlas::heightmap::BodyHeightmap>,
|
||||
) -> GenWorkItem {
|
||||
// The D-199 raw fields ride alongside the context (generate_quarter_skeleton
|
||||
// takes them separately), so capture them before context_from_read_set consumes
|
||||
@@ -602,33 +666,24 @@ fn build_skeleton_work_item(
|
||||
// Replaces the `Commission` stub that `context_from_read_set` leaves.
|
||||
context.political_archetype = placement.political_archetype;
|
||||
|
||||
// ── T-1039 / D-239 §6: morphology_zone from covering DistrictProfile ───────
|
||||
// Convert the placement's working-grid pixel position to a DistrictPos using
|
||||
// the canonical scale constant — no hardcoded magic numbers here.
|
||||
let district_pos = scale::heightmap_pixel_to_district(placement.position);
|
||||
context.morphology_zone = match districts.get(&district_pos) {
|
||||
Some(d) => d.morphology_zone,
|
||||
None => {
|
||||
// An empty grid is the expected params-missing / early-cascade case
|
||||
// (debug); a miss against a *populated* grid means the pixel→DistrictPos
|
||||
// conversion is off — a real bug worth a warning, not a silent wrong
|
||||
// topology.
|
||||
if districts.is_empty() {
|
||||
tracing::debug!(
|
||||
city_id = placement.city_id,
|
||||
?district_pos,
|
||||
"morphology_zone fallback to AlluvialPlain: district grid not built for this body"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
city_id = placement.city_id,
|
||||
?district_pos,
|
||||
"morphology_zone fallback to AlluvialPlain: pos not in populated district grid — check pixel→district convention"
|
||||
);
|
||||
}
|
||||
MorphologyZone::AlluvialPlain
|
||||
}
|
||||
};
|
||||
// ── D-256(d): settlement world metres + true DistrictPos ────────────────────
|
||||
// `pixel_to_world_m` is the SAME bridge function the survey raster uses
|
||||
// (D-256(b)) — converting the placement's working-grid pixel (row, col) to
|
||||
// world metres, then floor-dividing by DISTRICT_M for the true D-243 cell.
|
||||
// `morphology_zone` itself is NOT resolved here — see this function's doc
|
||||
// and `run_work_item`'s `GenerateSkeleton` arm (D-256(d)); it stays at the
|
||||
// `context_from_read_set` `AlluvialPlain` stub through this function.
|
||||
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
|
||||
placement.position.1 as f64,
|
||||
placement.position.0 as f64,
|
||||
heightmap_width as usize,
|
||||
heightmap_height as usize,
|
||||
body_params.and_then(|p| p.body_radius_km),
|
||||
);
|
||||
let district_pos: DistrictPos = (
|
||||
(world_x_m / scale::DISTRICT_M as f64).floor() as i32,
|
||||
(world_y_m / scale::DISTRICT_M as f64).floor() as i32,
|
||||
);
|
||||
|
||||
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
|
||||
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
|
||||
@@ -677,8 +732,8 @@ fn build_skeleton_work_item(
|
||||
// ── Canonical quarter id (D-194/D-230) ────────────────────────────────────
|
||||
// Deterministic + namespace-isolated per (world_seed, body, city).
|
||||
// SeedChain is Copy, so `chain.seed()` leaves `chain` usable for the work item.
|
||||
let chain = SeedChain::for_body(world_seed, body_id)
|
||||
.derive(SeedDomain::Layer4Quarter, placement.city_id);
|
||||
let body_seed = SeedChain::for_body(world_seed, body_id);
|
||||
let chain = body_seed.derive(SeedDomain::Layer4Quarter, placement.city_id);
|
||||
let quarter_id = chain.seed();
|
||||
|
||||
GenWorkItem::GenerateSkeleton {
|
||||
@@ -691,6 +746,10 @@ fn build_skeleton_work_item(
|
||||
population,
|
||||
founding_age_years,
|
||||
exterior_catalog: vocab.exterior_catalog.clone(),
|
||||
settlement_world_m: (world_x_m, world_y_m),
|
||||
body_params: body_params.cloned().map(Box::new),
|
||||
body_seed,
|
||||
heightmap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1123,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// D-256(d) test fixture: a minimal 64×32 working-grid heightmap
|
||||
/// (matching `district_profile::tests::test_hm`'s shape) wrapped in the
|
||||
/// `Arc` `build_skeleton_work_item`/`GenWorkItem::GenerateSkeleton` carry
|
||||
/// for the exact-position `morphology_zone` resolution. `body_params:
|
||||
/// None` (the common case in these queue-mechanics-focused tests) skips
|
||||
/// that resolution entirely, so the heightmap content is inert — flat
|
||||
/// data is enough to satisfy the type.
|
||||
fn sample_heightmap() -> Arc<crate::atlas::heightmap::BodyHeightmap> {
|
||||
Arc::new(crate::atlas::heightmap::BodyHeightmap {
|
||||
body_id: "test".into(),
|
||||
width: 64,
|
||||
height: 32,
|
||||
data: vec![0.5; 64 * 32],
|
||||
sea_level: 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement {
|
||||
CityPlacement {
|
||||
city_id,
|
||||
@@ -1252,9 +1328,12 @@ mod tests {
|
||||
42,
|
||||
&sample_placement(city_id, FoundingOrientation::Cardinal),
|
||||
read_set_with(6_000, 500_000, founding_age),
|
||||
&BTreeMap::new(),
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&road_graph,
|
||||
&vocab,
|
||||
sample_heightmap(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
@@ -1303,9 +1382,12 @@ mod tests {
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
@@ -1342,9 +1424,12 @@ mod tests {
|
||||
99,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -1355,57 +1440,36 @@ mod tests {
|
||||
assert_ne!(qid(3), qid(4));
|
||||
}
|
||||
|
||||
// ── T-1039: political_archetype + morphology_zone threading ───────────────
|
||||
// ── T-1039 / D-256(d): political_archetype + morphology_zone threading ────
|
||||
|
||||
/// Verify that `build_skeleton_work_item` threads the placement's
|
||||
/// `political_archetype` (replacing the `Commission` stub) and looks up
|
||||
/// `morphology_zone` from the district grid.
|
||||
/// `political_archetype` (replacing the `Commission` stub). `morphology_zone`
|
||||
/// is NOT resolved at this dispatch-time function any more (D-256(d)) — it
|
||||
/// stays at the `context_from_read_set` `AlluvialPlain` stub here; see
|
||||
/// `run_work_item_resolves_morphology_zone_at_exact_settlement_position`
|
||||
/// below for the execution-time resolution this ticket moved it to.
|
||||
#[test]
|
||||
fn threads_political_archetype_and_morphology_zone() {
|
||||
use crate::atlas::district_profile::{
|
||||
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
|
||||
};
|
||||
use crate::atlas::scale::BasinDirection;
|
||||
fn threads_political_archetype() {
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
|
||||
// A Corporate archetype placement in a Fjord district.
|
||||
let placement = sample_placement_with_archetype(
|
||||
42,
|
||||
FoundingOrientation::Coastal { facing_degrees: 90 },
|
||||
PoliticalArchetype::Corporate,
|
||||
ArrangementPattern::CampusGrid,
|
||||
);
|
||||
// CityPlacement.position = (10, 20) → district_pos = (col/8, row/8) = (20/8, 10/8) = (2, 1)
|
||||
let district_pos = scale::heightmap_pixel_to_district(placement.position);
|
||||
assert_eq!(district_pos, (2, 1));
|
||||
|
||||
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
|
||||
districts.insert(
|
||||
district_pos,
|
||||
DistrictProfile {
|
||||
morphology_zone: MorphologyZone::Fjord,
|
||||
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
|
||||
glaciation_grade: GlaciationGrade::Moderate,
|
||||
precipitation_class: PrecipitationClass::Temperate,
|
||||
slope_q: 60,
|
||||
elev_q: 50,
|
||||
ocean_fraction_q: 10,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(8.0),
|
||||
moisture_q: 55,
|
||||
vegetation_class: VegetationClass::Scrub,
|
||||
basin_direction: BasinDirection::North,
|
||||
},
|
||||
);
|
||||
|
||||
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
|
||||
"TestBody",
|
||||
1,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
@@ -1416,36 +1480,97 @@ mod tests {
|
||||
PoliticalArchetype::Corporate,
|
||||
"political_archetype must be threaded from placement (T-1039)"
|
||||
);
|
||||
// Morphology zone must come from the DistrictProfile.
|
||||
assert_eq!(
|
||||
context.morphology_zone,
|
||||
MorphologyZone::Fjord,
|
||||
"morphology_zone must be looked up from DistrictProfile (T-1039)"
|
||||
);
|
||||
// morphology_zone is untouched by build_skeleton_work_item post-D-256(d).
|
||||
assert_eq!(context.morphology_zone, MorphologyZone::AlluvialPlain);
|
||||
}
|
||||
|
||||
/// When no district grid is available (empty districts map), morphology_zone
|
||||
/// falls back to AlluvialPlain (the safe mesh-topology default).
|
||||
/// D-256(d): `resolve_settlement_morphology_zone` (the function
|
||||
/// `run_work_item`'s `GenerateSkeleton` arm calls) returns `None` when no
|
||||
/// `body_params` is supplied (no DB row for this body — the same
|
||||
/// condition the pre-D-256 "empty district grid" fallback covered), so
|
||||
/// the caller leaves `context.morphology_zone` at its `AlluvialPlain`
|
||||
/// stub.
|
||||
#[test]
|
||||
fn morphology_zone_fallback_when_district_missing() {
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
fn resolve_settlement_morphology_zone_none_when_body_params_absent() {
|
||||
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let placement = sample_placement(1, FoundingOrientation::Cardinal);
|
||||
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
|
||||
let heightmap = sample_heightmap();
|
||||
let cache = Arc::new(Mutex::new(
|
||||
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
|
||||
));
|
||||
let zone = resolve_settlement_morphology_zone(
|
||||
&cache,
|
||||
"BodyX",
|
||||
0,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton")
|
||||
None,
|
||||
(0.0, 0.0),
|
||||
SeedChain::for_body(0, "BodyX"),
|
||||
&heightmap,
|
||||
);
|
||||
assert_eq!(zone, None, "no body_params → no resolution, caller keeps the stub");
|
||||
}
|
||||
|
||||
/// D-256(d): with real `body_params`, `resolve_settlement_morphology_zone`
|
||||
/// resolves at the settlement's EXACT world position via `derive_at_metres`
|
||||
/// — not from a survey-cell lookup. Ground truth: an independent
|
||||
/// `derive_at_metres` call at the same position must agree bit-for-bit.
|
||||
#[test]
|
||||
fn resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position() {
|
||||
use crate::atlas::district_profile::{self, BodyParams, ClimateConstants};
|
||||
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let heightmap = sample_heightmap();
|
||||
let body_params = BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let placement = sample_placement(1, FoundingOrientation::Cardinal);
|
||||
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
|
||||
placement.position.1 as f64,
|
||||
placement.position.0 as f64,
|
||||
heightmap.width as usize,
|
||||
heightmap.height as usize,
|
||||
body_params.body_radius_km,
|
||||
);
|
||||
let body_seed = SeedChain::for_body(0, "TestBody");
|
||||
|
||||
let cache = Arc::new(Mutex::new(
|
||||
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
|
||||
));
|
||||
let zone = resolve_settlement_morphology_zone(
|
||||
&cache,
|
||||
"TestBody",
|
||||
Some(&body_params),
|
||||
(world_x_m, world_y_m),
|
||||
body_seed,
|
||||
&heightmap,
|
||||
);
|
||||
|
||||
// Ground truth: derive_at_metres at the SAME settlement world metres,
|
||||
// via the terrain cache's own re-derive path (run_layer1) so the
|
||||
// TerrainAnalysis is byte-identical to what the resolver used.
|
||||
let (_l1, ta) = crate::atlas::layer1::run_layer1(&heightmap);
|
||||
let expected = district_profile::derive_at_metres(
|
||||
body_seed,
|
||||
"TestBody",
|
||||
&body_params,
|
||||
&ta,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
&ClimateConstants::default(),
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
context.morphology_zone,
|
||||
MorphologyZone::AlluvialPlain,
|
||||
"should fall back to AlluvialPlain when district grid empty"
|
||||
zone,
|
||||
Some(expected.morphology_zone),
|
||||
"resolve_settlement_morphology_zone must match derive_at_metres at the \
|
||||
settlement's exact world position (D-256(d))"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1577,14 +1702,20 @@ mod tests {
|
||||
/// Acceptance test for T-1039: a Corporate coastal placement in a Fjord district
|
||||
/// dispatches a work item whose context uses Ribbon topology (Fjord) and
|
||||
/// Corporate (CampusGrid) layout — not mesh+Commission.
|
||||
///
|
||||
/// D-256(d): `morphology_zone` is no longer resolved by
|
||||
/// `build_skeleton_work_item` (that now happens at execution time via
|
||||
/// `resolve_settlement_morphology_zone`, tested directly elsewhere against
|
||||
/// real terrain — see
|
||||
/// `resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position`).
|
||||
/// This test's actual subject is `generate_quarter_skeleton`'s downstream
|
||||
/// Fjord+Corporate behavior, so it overrides `context.morphology_zone`
|
||||
/// directly on the built context — exactly what `run_work_item` does in
|
||||
/// production once the exact-position resolution completes.
|
||||
#[test]
|
||||
fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() {
|
||||
use crate::atlas::district_profile::{
|
||||
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
|
||||
};
|
||||
use crate::atlas::scale::BasinDirection;
|
||||
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
|
||||
use crate::simulation::generator::AccessKind;
|
||||
use crate::simulation::generator::{AccessKind, MorphologyZone};
|
||||
|
||||
// CorpTerritory → Corporate archetype; CoastalAccess attractor.
|
||||
let placement = sample_placement_with_archetype(
|
||||
@@ -1595,25 +1726,6 @@ mod tests {
|
||||
PoliticalArchetype::Corporate,
|
||||
ArrangementPattern::CampusGrid,
|
||||
);
|
||||
let district_pos = scale::heightmap_pixel_to_district(placement.position);
|
||||
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
|
||||
districts.insert(
|
||||
district_pos,
|
||||
DistrictProfile {
|
||||
morphology_zone: MorphologyZone::Fjord,
|
||||
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
|
||||
glaciation_grade: GlaciationGrade::Moderate,
|
||||
precipitation_class: PrecipitationClass::SemiArid,
|
||||
slope_q: 70,
|
||||
elev_q: 30,
|
||||
ocean_fraction_q: 20,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(5.0),
|
||||
moisture_q: 35,
|
||||
vegetation_class: VegetationClass::Barren,
|
||||
basin_direction: BasinDirection::North,
|
||||
},
|
||||
);
|
||||
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
context,
|
||||
@@ -1628,13 +1740,18 @@ mod tests {
|
||||
7,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
let mut context = context;
|
||||
context.morphology_zone = MorphologyZone::Fjord;
|
||||
|
||||
// Verify the context is correctly wired before skeleton generation.
|
||||
assert_eq!(context.political_archetype, PoliticalArchetype::Corporate);
|
||||
@@ -1913,9 +2030,12 @@ mod tests {
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&road_graph,
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
@@ -1977,9 +2097,12 @@ mod tests {
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
64,
|
||||
32,
|
||||
None,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
sample_heightmap(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
|
||||
+40
-14
@@ -72,6 +72,10 @@ const _: () = assert!(DISTRICTS_PER_REGION == 100);
|
||||
/// Position on the 64 m chunk grid (chunk-units, not metres). `BTreeMap` key (D-010).
|
||||
pub type ChunkPos = (i32, i32);
|
||||
/// Position on the 2 km district grid (district-units) — the terrain/climate carrier cell.
|
||||
/// Canonically the true D-243 grid, origin-corner quantized (`dp * DISTRICT_M`,
|
||||
/// equator/lon-0 anchor) — the [D-256](../../../governance/decisions/architecture.md)
|
||||
/// convention every position-pinning test assumes. Not to be confused with
|
||||
/// [`SurveyCellPos`], a different, coarser grid entirely.
|
||||
pub type DistrictPos = (i32, i32);
|
||||
/// Position on the ~205 km region grid (region-units) — the top hard block.
|
||||
pub type RegionPos = (i32, i32);
|
||||
@@ -100,40 +104,62 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Working-heightmap pixel ↔ district (cascade.rs working grid)
|
||||
// Survey raster — coarse planning grid (D-256), NOT a D-243 spatial rung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Number of working-heightmap-grid pixels per district side on the standard
|
||||
/// cascade working resolution (512×256 working grid, `heightmap::GRID_W` ×
|
||||
/// `heightmap::GRID_H`; D-203, D-239 §1, T-1023). (T-1170 audit nit: this
|
||||
/// comment previously read "~128×64" — stale since the working grid was
|
||||
/// widened; the actual grid-shape source of truth is `heightmap::GRID_W`/
|
||||
/// `GRID_H`, not a number restated here.)
|
||||
/// Position on the coarse 8×8-working-pixel **survey raster** (D-256(b)) — the
|
||||
/// batch planning pass used for L2/L3 inputs (settlement placement context,
|
||||
/// believability sampling, overlays, skeleton dispatch context).
|
||||
///
|
||||
/// **This is a role name for the coarse planning raster, NOT a [D-243](../../../governance/decisions/architecture.md)
|
||||
/// spatial rung.** The D-243 ladder gains no level from this type — it exists
|
||||
/// solely so the compiler rejects cross-namespace mixing with the true
|
||||
/// [`DistrictPos`] grid (D-256's root-cause fix: two semantically different
|
||||
/// `(i32, i32)` grids used to share one bare tuple type and could be passed to
|
||||
/// each other by accident). A real newtype, not a type alias.
|
||||
///
|
||||
/// `Ord` is required (not just for convenience): survey positions are
|
||||
/// `BTreeMap` keys (D-010 determinism).
|
||||
///
|
||||
/// The only sanctioned bridge from a survey cell to the true metric grid is
|
||||
/// [`crate::atlas::district_profile::pixel_to_world_m`] applied to the cell's
|
||||
/// centre pixel (the `8·rx + 3.5` pixel-index midpoint — D-256(b): the correct
|
||||
/// centre of the 8-point bilinear sample lattice, not an error) — never direct
|
||||
/// arithmetic against [`DISTRICT_M`]/[`REGION_M`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct SurveyCellPos(pub i32, pub i32);
|
||||
|
||||
/// Number of working-heightmap-grid pixels per survey-cell side on the
|
||||
/// standard cascade working resolution (512×256 working grid,
|
||||
/// `heightmap::GRID_W` × `heightmap::GRID_H`; D-203, D-239 §1, T-1023).
|
||||
/// (T-1170 audit nit: this comment previously read "~128×64" — stale since the
|
||||
/// working grid was widened; the actual grid-shape source of truth is
|
||||
/// `heightmap::GRID_W`/`GRID_H`, not a number restated here.)
|
||||
///
|
||||
/// This is NOT a metre-scale constant — it is the `grid_cells_per_district`
|
||||
/// parameter passed to [`crate::atlas::district_profile::derive_all_districts`].
|
||||
/// Centralised here so plugin.rs and cascade.rs share one definition and neither
|
||||
/// hard-codes `8` independently.
|
||||
///
|
||||
/// `DistrictPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)`
|
||||
/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_district`].
|
||||
/// `SurveyCellPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)`
|
||||
/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_survey_cell`].
|
||||
pub const HEIGHTMAP_CELLS_PER_DISTRICT: usize = 8;
|
||||
|
||||
/// Convert a working-heightmap-grid pixel coordinate `(row, col)` to the
|
||||
/// [`DistrictPos`] that covers it.
|
||||
/// [`SurveyCellPos`] that covers it.
|
||||
///
|
||||
/// `CityPlacement.position` and `RoadNode.position` are both stored in
|
||||
/// working-heightmap-grid coordinates (row-major, `(row, col)` order), and the
|
||||
/// district grid is built with the same pixel grid by
|
||||
/// survey raster is built with the same pixel grid by
|
||||
/// [`crate::atlas::district_profile::derive_all_districts`]. Integer division
|
||||
/// floors toward zero, which matches the `BTreeMap` keys inserted by `derive_all_districts`.
|
||||
#[inline]
|
||||
pub fn heightmap_pixel_to_district(pixel: (u16, u16)) -> DistrictPos {
|
||||
pub fn heightmap_pixel_to_survey_cell(pixel: (u16, u16)) -> SurveyCellPos {
|
||||
let cpd = HEIGHTMAP_CELLS_PER_DISTRICT as i32;
|
||||
// pixel = (row, col); DistrictPos convention is (dx=col_district, dy=row_district).
|
||||
// pixel = (row, col); SurveyCellPos convention is (dx=col_cell, dy=row_cell).
|
||||
let dx = pixel.1 as i32 / cpd;
|
||||
let dy = pixel.0 as i32 / cpd;
|
||||
(dx, dy)
|
||||
SurveyCellPos(dx, dy)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
//! `atlas::believability` so this binary and `tests/believability_harness` measure the
|
||||
//! exact same thing.
|
||||
//!
|
||||
//! ## Why the *district* tier
|
||||
//! ## Why the *survey-cell* tier (D-256(b))
|
||||
//!
|
||||
//! No production code maps a world chunk → its covering `DistrictProfile` yet (the voxel
|
||||
//! layer is a walking skeleton), so analysis is addressed in district space; the voxel
|
||||
//! sample inside each district is illustrative ground-truth of what its 1 m tiles derive to.
|
||||
//! layer is a walking skeleton), so the body-level report is addressed in survey-cell
|
||||
//! space — a coarse planning raster, NOT the true D-243 district grid (see
|
||||
//! `settled_reach_server::atlas::scale::SurveyCellPos`); the voxel sample inside each
|
||||
//! survey cell (at its own D-256(b) centre world metres) is illustrative ground-truth of
|
||||
//! what its 1 m tiles derive to. The `--render` mode below is the true-district
|
||||
//! counterpart, at real 2 km fidelity via the on-demand `derive_district` path.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --bin aliveness_probe -- --body GJ338Bd --seed yolo --probes 5
|
||||
@@ -37,13 +41,11 @@ use settled_reach_server::atlas::believability::{
|
||||
use settled_reach_server::atlas::body_world_state::BodyWorldState;
|
||||
use settled_reach_server::atlas::chunk_context::derive_chunk_context;
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade,
|
||||
self, derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade,
|
||||
VegetationClass,
|
||||
};
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::scale::{
|
||||
self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M,
|
||||
};
|
||||
use settled_reach_server::atlas::scale::{self, ChunkPos, DistrictPos, SurveyCellPos, CHUNK_M};
|
||||
use settled_reach_server::atlas::voxel::derive_voxel_column;
|
||||
use settled_reach_server::seed::SeedChain;
|
||||
use settled_reach_server::simulation::generator::MorphologyZone;
|
||||
@@ -89,9 +91,9 @@ fn main() {
|
||||
eprintln!("cascade produced no districts — cannot probe (body params missing?). Aborting.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
|
||||
let keys: Vec<SurveyCellPos> = districts.keys().copied().collect();
|
||||
eprintln!(
|
||||
"cascade: {} districts, {} settlement placements",
|
||||
"cascade: {} survey cells, {} settlement placements",
|
||||
districts.len(),
|
||||
bws.placements.len()
|
||||
);
|
||||
@@ -102,38 +104,79 @@ fn main() {
|
||||
);
|
||||
|
||||
// ── Body-level believability report — the D-245 enforcer verdict ────────
|
||||
let report = analyze(world_seed, &args.body, districts);
|
||||
let report = analyze(
|
||||
world_seed,
|
||||
&args.body,
|
||||
districts,
|
||||
bws.heightmap_width,
|
||||
bws.heightmap_height,
|
||||
body_params.body_radius_km,
|
||||
);
|
||||
print_report(&report);
|
||||
|
||||
// ── Anchored probe: ≈150 chunks west of the principal settlement ────────
|
||||
// (highest placement score; per-city population is 0 in the DB).
|
||||
let anchor_district = bws.placements.iter().max_by_key(|p| p.score).map(|p| {
|
||||
let dp = scale::heightmap_pixel_to_district(p.position);
|
||||
// (highest placement score; per-city population is 0 in the DB). D-256(b):
|
||||
// `heightmap_pixel_to_survey_cell` — a survey-raster position, NOT a true
|
||||
// district — so "west" is walked in survey-cell units (each cell spans
|
||||
// HEIGHTMAP_CELLS_PER_DISTRICT working pixels), never true DISTRICT_M.
|
||||
let anchor_cell = bws.placements.iter().max_by_key(|p| p.score).map(|p| {
|
||||
let cell = scale::heightmap_pixel_to_survey_cell(p.position);
|
||||
println!(
|
||||
"\nprincipal settlement: city {} @ pixel {:?} → district {:?} (placement score {})",
|
||||
p.city_id, p.position, dp, p.score
|
||||
"\nprincipal settlement: city {} @ pixel {:?} → survey cell {:?} (placement score {})",
|
||||
p.city_id, p.position, cell, p.score
|
||||
);
|
||||
let west_districts = (ANCHOR_OFFSET_CHUNKS * CHUNK_M) / scale::DISTRICT_M;
|
||||
(dp.0 - west_districts.max(1) - 1, dp.1)
|
||||
// 150 chunks (9 600 m) ÷ one survey cell's ACTUAL metric width at this
|
||||
// row — measured directly via two adjacent cells' D-256(b) centre
|
||||
// world metres (the same bridge derive_district_profile uses), NOT
|
||||
// `HEIGHTMAP_CELLS_PER_DISTRICT * DISTRICT_M` (that formula silently
|
||||
// treats a working pixel as if it were DISTRICT_M metres wide, which
|
||||
// is only true in the no-radius fallback — on a real body a working
|
||||
// pixel's metric width is `circumference / heightmap_width`, unrelated
|
||||
// to DISTRICT_M; the earlier version was the SAME pseudo-grid/true-grid
|
||||
// conflation this ticket's basin-dirs fix corrected). Diagnostic-only
|
||||
// "west" nudge — exact enough to land roughly the requested distance
|
||||
// away, never used for anything position-authoritative.
|
||||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
let (x0, _) = district_profile::survey_cell_centre_world_m(
|
||||
cell,
|
||||
gcpr,
|
||||
bws.heightmap_width as usize,
|
||||
bws.heightmap_height as usize,
|
||||
body_params.body_radius_km,
|
||||
);
|
||||
let (x1, _) = district_profile::survey_cell_centre_world_m(
|
||||
SurveyCellPos(cell.0 + 1, cell.1),
|
||||
gcpr,
|
||||
bws.heightmap_width as usize,
|
||||
bws.heightmap_height as usize,
|
||||
body_params.body_radius_km,
|
||||
);
|
||||
let cell_width_m = (x1 - x0).abs().max(1.0);
|
||||
let cell_width_chunks = (cell_width_m / CHUNK_M as f64).round() as i32;
|
||||
let west_cells = (ANCHOR_OFFSET_CHUNKS / cell_width_chunks.max(1)).max(1);
|
||||
SurveyCellPos(cell.0 - west_cells - 1, cell.1)
|
||||
});
|
||||
|
||||
if let Some(adp) = anchor_district {
|
||||
if let Some(acell) = anchor_cell {
|
||||
println!(
|
||||
"\n---- ANCHORED PROBE: ≈{ANCHOR_OFFSET_CHUNKS} chunks (9.6 km) west of the principal city ----"
|
||||
);
|
||||
match nearest_present(districts, adp) {
|
||||
Some((dp, prof)) => probe(
|
||||
match nearest_present(districts, acell) {
|
||||
Some((cell, prof)) => probe(
|
||||
&args.body,
|
||||
world_seed,
|
||||
dp,
|
||||
cell,
|
||||
bws.heightmap_width,
|
||||
bws.heightmap_height,
|
||||
body_params.body_radius_km,
|
||||
prof,
|
||||
if dp == adp {
|
||||
if cell == acell {
|
||||
""
|
||||
} else {
|
||||
"(nearest land district — target is off-map / open ocean)"
|
||||
"(nearest land survey cell — target is off-map / open ocean)"
|
||||
},
|
||||
),
|
||||
None => println!(" target {adp:?} and neighbours are off-map; nothing to sample."),
|
||||
None => println!(" target {acell:?} and neighbours are off-map; nothing to sample."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +184,20 @@ fn main() {
|
||||
println!("\n---- {} RANDOM PROBES ----", args.probes);
|
||||
for i in 0..args.probes {
|
||||
let pick = (seed_to_u64(&format!("{}/{i}", args.seed)) % keys.len() as u64) as usize;
|
||||
let dp = keys[pick];
|
||||
probe(&args.body, world_seed, dp, &districts[&dp], "");
|
||||
let cell = keys[pick];
|
||||
probe(
|
||||
&args.body,
|
||||
world_seed,
|
||||
cell,
|
||||
bws.heightmap_width,
|
||||
bws.heightmap_height,
|
||||
body_params.body_radius_km,
|
||||
&districts[&cell],
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk of each district.)");
|
||||
println!("\n(survey-cell tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk at each cell's centre.)");
|
||||
|
||||
// ── --render: district-window PNG maps (T-1123) ──────────────────────────
|
||||
if let Some(out_dir) = &args.render {
|
||||
@@ -201,12 +253,32 @@ fn print_report(r: &BelievabilityReport) {
|
||||
println!(" → {}/{} criteria pass", passes, crit.len());
|
||||
}
|
||||
|
||||
/// Probe one district: print its profile + a derived voxel-column sample for colour.
|
||||
fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, note: &str) {
|
||||
// Representative chunk at the district centre (32 chunks / district).
|
||||
/// Probe one survey cell: print its profile + a derived voxel-column sample for
|
||||
/// colour, at the TRUE chunk covering the cell's own D-256(b) centre world
|
||||
/// metres (never the pseudo-grid coordinates scaled by `CHUNKS_PER_DISTRICT`
|
||||
/// directly — that arithmetic silently assumed the survey grid WAS the true
|
||||
/// district grid, exactly the D-256 namespace collision).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn probe(
|
||||
body: &str,
|
||||
world_seed: u64,
|
||||
dp: SurveyCellPos,
|
||||
heightmap_width: u32,
|
||||
heightmap_height: u32,
|
||||
body_radius_km: Option<f64>,
|
||||
prof: &DistrictProfile,
|
||||
note: &str,
|
||||
) {
|
||||
let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m(
|
||||
dp,
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
heightmap_width as usize,
|
||||
heightmap_height as usize,
|
||||
body_radius_km,
|
||||
);
|
||||
let chunk: ChunkPos = (
|
||||
dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
(world_x_m / CHUNK_M as f64).floor() as i32,
|
||||
(world_y_m / CHUNK_M as f64).floor() as i32,
|
||||
);
|
||||
let ctx = derive_chunk_context(world_seed, body, prof, chunk, None);
|
||||
|
||||
@@ -232,7 +304,7 @@ fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, n
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n• district {dp:?} {note}");
|
||||
println!("\n• survey cell {dp:?} {note}");
|
||||
println!(
|
||||
" zone={:?} elev_q={} slope_q={} ocean%q={} moisture_q={} temp={} precip={:?} veg_class={:?} glaciation={:?} basin={:?}",
|
||||
prof.morphology_zone,
|
||||
@@ -279,18 +351,18 @@ fn fmt_hist(h: &BTreeMap<String, u32>, n: i64) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// Find the district at `dp`, else the nearest present district within a small ring.
|
||||
/// Find the survey cell at `dp`, else the nearest present cell within a small ring.
|
||||
fn nearest_present(
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
dp: DistrictPos,
|
||||
) -> Option<(DistrictPos, &DistrictProfile)> {
|
||||
districts: &BTreeMap<SurveyCellPos, DistrictProfile>,
|
||||
dp: SurveyCellPos,
|
||||
) -> Option<(SurveyCellPos, &DistrictProfile)> {
|
||||
if let Some(p) = districts.get(&dp) {
|
||||
return Some((dp, p));
|
||||
}
|
||||
for r in 1..=8 {
|
||||
for dx in -r..=r {
|
||||
for dy in -r..=r {
|
||||
let q = (dp.0 + dx, dp.1 + dy);
|
||||
let q = SurveyCellPos(dp.0 + dx, dp.1 + dy);
|
||||
if let Some(p) = districts.get(&q) {
|
||||
return Some((q, p));
|
||||
}
|
||||
@@ -493,8 +565,9 @@ fn render_window_panels(
|
||||
/// Map a working-grid settlement pixel `(row, col)` to the TRUE metric 2 km
|
||||
/// district containing it — the inverse of `derive_district`'s forward mapping
|
||||
/// (district → fractional working-grid position via the body radius, the D-204
|
||||
/// elastic seam). NOT `scale::heightmap_pixel_to_district`: that addresses the
|
||||
/// eager gcpr = 8 pseudo-grid, whose cells span tens of km.
|
||||
/// elastic seam). NOT `scale::heightmap_pixel_to_survey_cell` (D-256(b)
|
||||
/// rename): that addresses the eager gcpr = 8 survey raster, whose cells span
|
||||
/// tens of km — a coarse planning grid, not the true district.
|
||||
fn true_district_of_pixel(
|
||||
pixel: (u16, u16),
|
||||
ta_w: usize,
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
//! `cascade_golden`).
|
||||
|
||||
use settled_reach_server::atlas::believability::{
|
||||
analyze, cascade_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport,
|
||||
analyze, cascade_snapshot_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport,
|
||||
};
|
||||
|
||||
const GOLDEN_FILE: &str = "tests/golden/believability.json";
|
||||
@@ -42,8 +42,18 @@ const VALIDATION_BODIES: &[(&str, &str, &str)] = &[
|
||||
/// Run the cascade + analyze for one body, or `None` if its committed data is absent.
|
||||
fn report_for(body_id: &str, seed: &str) -> Option<BelievabilityReport> {
|
||||
let world_seed = seed_to_u64(seed);
|
||||
match cascade_for_body(world_seed, body_id) {
|
||||
Ok(bws) => Some(analyze(world_seed, body_id, &bws.districts)),
|
||||
match cascade_snapshot_for_body(world_seed, body_id) {
|
||||
Ok((snapshot, params)) => {
|
||||
let bws = snapshot.into_body_world_state();
|
||||
Some(analyze(
|
||||
world_seed,
|
||||
body_id,
|
||||
&bws.districts,
|
||||
bws.heightmap_width,
|
||||
bws.heightmap_height,
|
||||
params.body_radius_km,
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[believability] SKIP {body_id}: {e}");
|
||||
None
|
||||
|
||||
@@ -1927,7 +1927,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile {
|
||||
seed,
|
||||
params,
|
||||
&ta,
|
||||
(0, 0),
|
||||
scale::SurveyCellPos(0, 0),
|
||||
8,
|
||||
&climate,
|
||||
"test_body",
|
||||
|
||||
@@ -578,7 +578,7 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
}],
|
||||
grid_w: 512,
|
||||
grid_h: 256,
|
||||
district_basin_dirs: std::collections::BTreeMap::new(),
|
||||
survey_basin_dirs: std::collections::BTreeMap::new(),
|
||||
};
|
||||
// T-960 §1/§2: a small populated RoadGraphLayer + SettlementLayer, one
|
||||
// settlement (a capital) connected to one waypoint-free short edge.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"voxel_sampled_districts": 64,
|
||||
"contrast": {
|
||||
"moisture_q": {
|
||||
"min": 16,
|
||||
"min": 17,
|
||||
"max": 97,
|
||||
"distinct": 74
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"ocean_fraction_q": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"distinct": 42
|
||||
"distinct": 44
|
||||
},
|
||||
"morphology_zones": 9,
|
||||
"vegetation_classes": 4,
|
||||
@@ -37,7 +37,7 @@
|
||||
"drainage_samples": 0,
|
||||
"drainage_monotonic": 0,
|
||||
"vegetation_samples": 64,
|
||||
"vegetated_districts": 5
|
||||
"vegetated_districts": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -69,7 +69,7 @@
|
||||
"morphology_zones": 6,
|
||||
"vegetation_classes": 2,
|
||||
"terrain_materials": 2,
|
||||
"voxel_relief_m": 21,
|
||||
"voxel_relief_m": 22,
|
||||
"micro_habitat_distinct": 0
|
||||
},
|
||||
"coherence": {
|
||||
|
||||
Reference in New Issue
Block a user