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:
+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");
|
||||
|
||||
Reference in New Issue
Block a user