feat(simulation): thread political_archetype/morphology_zone/road_entry_directions through L3->L4 dispatch (T-1039, T-1043)
Completes the GenerateSkeleton dispatch T-1022 wired (which only carried founding_orientation). build_skeleton_work_item now also threads: T-1039: - political_archetype — real value from CityPlacement, replacing the Commission stub. - morphology_zone — looked up from the covering DistrictProfile via new scale::heightmap_pixel_to_district (exported HEIGHTMAP_CELLS_PER_DISTRICT; cascade.rs drops its local CELLS_PER_REGION literal for the shared const). - arrangement_pattern — RE-DERIVED at L4 (option b) via the single pure fn attractor_matching::arrangement_pattern(&archetype, &economic_role); both inputs already at L4. No CityGenerationContext field, no stored derived state (DB-as-cache). Parity test asserts L4 re-derivation == L3-stored value. T-1043: - road_entry_directions — derived from T-1038's BodyWorldState.road_graph: octant = bearing of each incident road edge, deduped per octant, ordered by MaintenanceAuthority rank (the AdminFacing prestige-edge consumer). Feeds derive_access_points -> AccessKind::QuarterEdge; BlockJunction fallback now fires only for genuinely isolated settlements. Acceptance covered: Corporate+Fjord -> Ribbon topology (not mesh+Commission); settlement with a road -> QuarterEdge on the correct octant. 11 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::atlas::road_graph::{self, RoadGraph};
|
||||
use crate::atlas::scale;
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
||||
|
||||
@@ -255,16 +256,16 @@ pub fn run_cascade_from_heightmap(
|
||||
|
||||
// DistrictProfile layer — pure derivation from body params + terrain.
|
||||
if let Some(params) = body_params {
|
||||
// ~8 cells per district on a 128×64 working grid → ~80×32 = ~2 560 districts;
|
||||
// at full working resolution the budget is ~6 000/body (D-203).
|
||||
const CELLS_PER_REGION: usize = 8;
|
||||
// Canonical cells-per-district for the working grid (T-1039):
|
||||
// shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs converts
|
||||
// CityPlacement pixel coords with the same constant.
|
||||
// body_id is required for the D-243 §4 climate edge-fuzz warp domain
|
||||
// separation — derive_all_districts builds the region cache internally.
|
||||
let districts = district_profile::derive_all_districts(
|
||||
body_seed,
|
||||
params,
|
||||
&ta,
|
||||
CELLS_PER_REGION,
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
&snapshot.body_id,
|
||||
);
|
||||
snapshot.layer_district = Some(LayerDistrictOutput { districts });
|
||||
|
||||
+866
-19
@@ -11,17 +11,23 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::body_params_reader::BodyParamsReaderResource;
|
||||
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
|
||||
use crate::atlas::city_context_reader::{
|
||||
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
|
||||
};
|
||||
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
||||
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
|
||||
use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
|
||||
use crate::atlas::road_graph::{RoadGraph, RoadNode};
|
||||
use crate::atlas::scale;
|
||||
use crate::atlas::source_resolver::BodySourceResolverResource;
|
||||
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::tick_phases::TickPhase;
|
||||
@@ -124,7 +130,14 @@ fn drain_generation_completions(
|
||||
}
|
||||
};
|
||||
queue.submit(
|
||||
build_skeleton_work_item(&body_id, world_seed, placement, read_set),
|
||||
build_skeleton_work_item(
|
||||
&body_id,
|
||||
world_seed,
|
||||
placement,
|
||||
read_set,
|
||||
&state.districts,
|
||||
&state.road_graph,
|
||||
),
|
||||
GenPriority::Low,
|
||||
);
|
||||
}
|
||||
@@ -168,20 +181,39 @@ fn drain_generation_completions(
|
||||
}
|
||||
|
||||
/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement
|
||||
/// (T-1022, D-234). Builds the D-199 context from the read-set (mirroring
|
||||
/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context),
|
||||
/// which is just `read_set` + `context_from_read_set`), then overrides the
|
||||
/// `Cardinal` stub with the attractor-matched
|
||||
/// [`FoundingOrientation`](crate::simulation::generator::FoundingOrientation) carried
|
||||
/// on the placement (D-213), and derives the canonical, namespace-isolated
|
||||
/// `quarter_id` from `(world_seed, body, city)` (D-194/D-230) — replacing the
|
||||
/// `city_id * 10` placeholder. Pure (no queue/cache access) so it unit-tests
|
||||
/// without a `systems.db`.
|
||||
/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set
|
||||
/// (mirroring
|
||||
/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context)),
|
||||
/// then overrides:
|
||||
///
|
||||
/// - `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).
|
||||
/// - `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
|
||||
/// road quality so the highest-prestige entry is first (T-1043, D-215 AdminFacing
|
||||
/// rule). Empty when `road_graph` has no edges for this city.
|
||||
///
|
||||
/// `arrangement_pattern` is **re-derived** at L4 from `(political_archetype,
|
||||
/// economic_role)` via the same pure function used at L3 (T-1039 OPTION (b) —
|
||||
/// locked, no `CityGenerationContext` field added). Re-derivation is provably
|
||||
/// identical to the L3 value (pure total function, no RNG).
|
||||
///
|
||||
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
|
||||
/// city)` — not the `city_id * 10` placeholder.
|
||||
///
|
||||
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
|
||||
fn build_skeleton_work_item(
|
||||
body_id: &str,
|
||||
world_seed: u64,
|
||||
placement: &CityPlacement,
|
||||
read_set: CityEconomicReadSet,
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
road_graph: &RoadGraph,
|
||||
) -> 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
|
||||
@@ -191,11 +223,32 @@ fn build_skeleton_work_item(
|
||||
let founding_age_years = read_set.founding_age_years;
|
||||
|
||||
let mut context = context_from_read_set(placement.city_id, read_set);
|
||||
|
||||
// ── T-1022 / D-213: founding orientation from attractor-matched placement ──
|
||||
context.founding_orientation = placement.founding_orientation.clone();
|
||||
|
||||
// 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's own field.
|
||||
// ── T-1039 / D-214: political_archetype from placement (real value) ────────
|
||||
// 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 = districts
|
||||
.get(&district_pos)
|
||||
.map(|d| d.morphology_zone)
|
||||
.unwrap_or(MorphologyZone::AlluvialPlain);
|
||||
|
||||
// ── T-1043: road_entry_directions from road_graph ───────────────────────────
|
||||
// Find this city's settlement node index in the road graph (O(n) scan on a
|
||||
// small slice — settlement counts are single-digit to low hundreds per body).
|
||||
context.road_entry_directions =
|
||||
road_entry_directions_for_city(placement.city_id, placement.position, road_graph);
|
||||
|
||||
// ── 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 quarter_id = chain.seed();
|
||||
@@ -212,12 +265,147 @@ fn build_skeleton_work_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive road entry octants (0=N…7=NW) for one city from the road graph.
|
||||
///
|
||||
/// For each road edge incident on `city_id`, computes the compass octant of the
|
||||
/// bearing from the city toward the far endpoint. Results are:
|
||||
/// - **De-duplicated** per octant (a BTreeSet accumulates unique octants).
|
||||
/// - **Ordered by descending road quality** so the highest-prestige entry comes
|
||||
/// first (the AdminFacing consumer selects the first entry as its prestige gate
|
||||
/// per D-215).
|
||||
///
|
||||
/// Returns an empty `Vec` when the city has no road connections — the caller's
|
||||
/// `derive_access_points` will fall back to a central `BlockJunction`.
|
||||
///
|
||||
/// Pure function (no side effects, deterministic output for fixed inputs).
|
||||
fn road_entry_directions_for_city(
|
||||
city_id: u64,
|
||||
city_pos: (u16, u16),
|
||||
road_graph: &RoadGraph,
|
||||
) -> Vec<u8> {
|
||||
// Find the settlement node index for this city.
|
||||
let city_node_idx = road_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|n: &RoadNode| n.city_id == Some(city_id));
|
||||
|
||||
let Some(city_idx) = city_node_idx else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Collect (octant, quality_rank) for each incident edge; BTreeSet dedups per
|
||||
// octant keeping the highest-quality rank for each (deterministic iteration).
|
||||
// BTreeMap<octant, rank> for dedup-with-max-quality.
|
||||
let mut octant_quality: BTreeMap<u8, u8> = BTreeMap::new();
|
||||
|
||||
for edge in &road_graph.edges {
|
||||
let is_from = edge.from == city_idx;
|
||||
let is_to = edge.to == city_idx;
|
||||
if !is_from && !is_to {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Far endpoint position — the direction from city toward the far end.
|
||||
let far_pos = if is_from {
|
||||
road_graph.nodes[edge.to].position
|
||||
} else {
|
||||
road_graph.nodes[edge.from].position
|
||||
};
|
||||
|
||||
let octant = bearing_octant(city_pos, far_pos);
|
||||
let rank = maintenance_authority_rank(edge.maintenance);
|
||||
octant_quality
|
||||
.entry(octant)
|
||||
.and_modify(|r| *r = (*r).max(rank))
|
||||
.or_insert(rank);
|
||||
}
|
||||
|
||||
if octant_quality.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Collect (rank, octant) into a Vec, sort descending by rank then ascending
|
||||
// by octant (tie-break) for a fully deterministic, prestige-first order.
|
||||
let mut ranked: Vec<(u8, u8)> = octant_quality
|
||||
.iter()
|
||||
.map(|(&oct, &rank)| (rank, oct))
|
||||
.collect();
|
||||
ranked.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
ranked.into_iter().map(|(_, oct)| oct).collect()
|
||||
}
|
||||
|
||||
/// Compass octant (0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW) of the bearing
|
||||
/// from `from` toward `to` in working-heightmap-grid coordinates `(row, col)`.
|
||||
///
|
||||
/// Working-grid rows increase **southward** (row 0 = top = north), so:
|
||||
/// - Δrow < 0 → northward, Δrow > 0 → southward
|
||||
/// - Δcol < 0 → westward, Δcol > 0 → eastward
|
||||
///
|
||||
/// Integer arithmetic only (D-010). Returns 0 (North) for a zero-vector.
|
||||
fn bearing_octant(from: (u16, u16), to: (u16, u16)) -> u8 {
|
||||
let dr = to.0 as i32 - from.0 as i32; // +south / -north
|
||||
let dc = to.1 as i32 - from.1 as i32; // +east / -west
|
||||
if dr == 0 && dc == 0 {
|
||||
return 0;
|
||||
}
|
||||
// 8-sector classification by the dominant axis + sign of the minor axis.
|
||||
// We double the components to avoid a division and keep integer math.
|
||||
// |dc| > |dr|*2 → pure E/W; |dr| > |dc|*2 → pure N/S; else diagonal.
|
||||
let adr = dr.unsigned_abs() as i64;
|
||||
let adc = dc.unsigned_abs() as i64;
|
||||
// Octant ordering matches skeleton_gen.rs (D-234): 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW.
|
||||
if adc > adr * 2 {
|
||||
// Dominant East or West
|
||||
if dc > 0 {
|
||||
2
|
||||
} else {
|
||||
6
|
||||
}
|
||||
} else if adr > adc * 2 {
|
||||
// Dominant North or South (row increases southward)
|
||||
if dr > 0 {
|
||||
4
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else if dr <= 0 && dc > 0 {
|
||||
1 // NE
|
||||
} else if dr > 0 && dc > 0 {
|
||||
3 // SE
|
||||
} else if dr > 0 && dc <= 0 {
|
||||
5 // SW
|
||||
} else {
|
||||
7 // NW (dr <= 0 && dc < 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prestige rank for a `MaintenanceAuthority` (0 = lowest, 4 = highest).
|
||||
///
|
||||
/// Used to order `road_entry_directions` so the AdminFacing consumer (D-215)
|
||||
/// picks the highest-quality entry as its prestige gate without re-inspecting
|
||||
/// edge metadata.
|
||||
///
|
||||
/// Administrative > Corporate > Trade > Communal > Abandoned.
|
||||
fn maintenance_authority_rank(m: MaintenanceAuthority) -> u8 {
|
||||
match m {
|
||||
MaintenanceAuthority::Administrative => 4,
|
||||
MaintenanceAuthority::Corporate => 3,
|
||||
MaintenanceAuthority::Trade => 2,
|
||||
MaintenanceAuthority::Communal => 1,
|
||||
MaintenanceAuthority::Abandoned => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::gen_queue::{GenPriority, GenWorkItem};
|
||||
use crate::atlas::road_graph::{RoadEdge, RoadNode, RoadNodeKind};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::FoundingOrientation;
|
||||
use crate::simulation::generator::{
|
||||
ArrangementPattern, AttractorType, FoundingOrientation, MaintenanceAuthority,
|
||||
PoliticalArchetype,
|
||||
};
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -322,7 +510,6 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement {
|
||||
use crate::simulation::generator::{ArrangementPattern, AttractorType, PoliticalArchetype};
|
||||
CityPlacement {
|
||||
city_id,
|
||||
position: (10, 20),
|
||||
@@ -335,6 +522,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_placement_with_archetype(
|
||||
city_id: u64,
|
||||
orientation: FoundingOrientation,
|
||||
archetype: PoliticalArchetype,
|
||||
arrangement: ArrangementPattern,
|
||||
) -> CityPlacement {
|
||||
CityPlacement {
|
||||
city_id,
|
||||
position: (10, 20),
|
||||
attractor_type: AttractorType::CoastalAccess,
|
||||
score: 100,
|
||||
synthetic: false,
|
||||
political_archetype: archetype,
|
||||
arrangement_pattern: arrangement,
|
||||
founding_orientation: orientation,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_skeleton_work_item_threads_orientation_and_canonical_quarter_id() {
|
||||
let placement = sample_placement(
|
||||
@@ -353,7 +558,14 @@ mod tests {
|
||||
population,
|
||||
founding_age_years,
|
||||
..
|
||||
} = build_skeleton_work_item("PlanetX", 42, &placement, sample_read_set())
|
||||
} = build_skeleton_work_item(
|
||||
"PlanetX",
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
@@ -384,9 +596,14 @@ mod tests {
|
||||
fn quarter_id_is_deterministic_and_city_scoped() {
|
||||
let qid = |city_id: u64| {
|
||||
let placement = sample_placement(city_id, FoundingOrientation::Cardinal);
|
||||
let GenWorkItem::GenerateSkeleton { quarter_id, .. } =
|
||||
build_skeleton_work_item("BodyA", 99, &placement, sample_read_set())
|
||||
else {
|
||||
let GenWorkItem::GenerateSkeleton { quarter_id, .. } = build_skeleton_work_item(
|
||||
"BodyA",
|
||||
99,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
quarter_id
|
||||
@@ -395,4 +612,634 @@ mod tests {
|
||||
assert_eq!(qid(3), qid(3));
|
||||
assert_ne!(qid(3), qid(4));
|
||||
}
|
||||
|
||||
// ── T-1039: 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.
|
||||
#[test]
|
||||
fn threads_political_archetype_and_morphology_zone() {
|
||||
use crate::atlas::district_profile::{
|
||||
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
|
||||
};
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
|
||||
"TestBody",
|
||||
1,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
&RoadGraph::default(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
|
||||
// Political archetype must come from the placement, not context_from_read_set's stub.
|
||||
assert_eq!(
|
||||
context.political_archetype,
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
||||
/// When no district grid is available (empty districts map), morphology_zone
|
||||
/// falls back to AlluvialPlain (the safe mesh-topology default).
|
||||
#[test]
|
||||
fn morphology_zone_fallback_when_district_missing() {
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
|
||||
let placement = sample_placement(1, FoundingOrientation::Cardinal);
|
||||
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
|
||||
"BodyX",
|
||||
0,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton")
|
||||
};
|
||||
assert_eq!(
|
||||
context.morphology_zone,
|
||||
MorphologyZone::AlluvialPlain,
|
||||
"should fall back to AlluvialPlain when district grid empty"
|
||||
);
|
||||
}
|
||||
|
||||
// ── T-1039: arrangement_pattern parity drift-tripwire ─────────────────────
|
||||
|
||||
/// Verifies that the L4 re-derivation of `arrangement_pattern` via
|
||||
/// `attractor_matching::arrangement_pattern(&archetype, &role)` is always
|
||||
/// identical to the L3 value stored on `CityPlacement.arrangement_pattern` for
|
||||
/// a representative set of (archetype, economic_role) pairs.
|
||||
///
|
||||
/// This is the required hardening check (T-1039 OPTION (b)): if anyone
|
||||
/// changes one derivation path without the other this test will catch the drift.
|
||||
#[test]
|
||||
fn arrangement_pattern_l4_rederivation_matches_l3_stored_value() {
|
||||
use crate::atlas::attractor_matching::arrangement_pattern;
|
||||
|
||||
// Representative pairs: archetype + economic_role → expected pattern.
|
||||
// These are the canonical D-214/D-215 pairs exercising all branches.
|
||||
let cases: &[(PoliticalArchetype, &str, ArrangementPattern)] = &[
|
||||
// Commission/Academic → RadialCore
|
||||
(
|
||||
PoliticalArchetype::Commission,
|
||||
"institutional",
|
||||
ArrangementPattern::RadialCore,
|
||||
),
|
||||
(
|
||||
PoliticalArchetype::Academic,
|
||||
"research",
|
||||
ArrangementPattern::RadialCore,
|
||||
),
|
||||
// Corporate → CampusGrid
|
||||
(
|
||||
PoliticalArchetype::Corporate,
|
||||
"manufacturing",
|
||||
ArrangementPattern::CampusGrid,
|
||||
),
|
||||
(
|
||||
PoliticalArchetype::Corporate,
|
||||
"financial",
|
||||
ArrangementPattern::CampusGrid,
|
||||
),
|
||||
// Pioneer/Industrial → RibbonDevelopment
|
||||
(
|
||||
PoliticalArchetype::Pioneer,
|
||||
"agricultural",
|
||||
ArrangementPattern::RibbonDevelopment,
|
||||
),
|
||||
(
|
||||
PoliticalArchetype::Industrial,
|
||||
"extraction",
|
||||
ArrangementPattern::RibbonDevelopment,
|
||||
),
|
||||
// Military → FortifiedPerimeter
|
||||
(
|
||||
PoliticalArchetype::Military,
|
||||
"military",
|
||||
ArrangementPattern::FortifiedPerimeter,
|
||||
),
|
||||
// transit_hub is a cross-archetype override → HubAndSpoke
|
||||
(
|
||||
PoliticalArchetype::Commission,
|
||||
"transit_hub",
|
||||
ArrangementPattern::HubAndSpoke,
|
||||
),
|
||||
(
|
||||
PoliticalArchetype::Corporate,
|
||||
"transit_hub",
|
||||
ArrangementPattern::HubAndSpoke,
|
||||
),
|
||||
];
|
||||
|
||||
for (archetype, role, expected_pattern) in cases {
|
||||
// L4 re-derivation (the path used in build_skeleton_work_item).
|
||||
let rederived = arrangement_pattern(archetype, role);
|
||||
|
||||
// Build a CityPlacement carrying the L3-computed value to simulate
|
||||
// what attractor_matching::match_cities would have stored at L3.
|
||||
let l3_placement = CityPlacement {
|
||||
city_id: 1,
|
||||
position: (0, 0),
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
score: 100,
|
||||
synthetic: false,
|
||||
political_archetype: *archetype,
|
||||
arrangement_pattern: *expected_pattern,
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
rederived, l3_placement.arrangement_pattern,
|
||||
"L4 re-derivation != L3 stored value for ({:?}, {role})",
|
||||
archetype
|
||||
);
|
||||
assert_eq!(
|
||||
rederived, *expected_pattern,
|
||||
"arrangement_pattern({:?}, {role}) should be {:?}",
|
||||
archetype, expected_pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() {
|
||||
use crate::atlas::district_profile::{
|
||||
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
|
||||
};
|
||||
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
// CorpTerritory → Corporate archetype; CoastalAccess attractor.
|
||||
let placement = sample_placement_with_archetype(
|
||||
99,
|
||||
FoundingOrientation::Coastal {
|
||||
facing_degrees: 270,
|
||||
},
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
context,
|
||||
economic_role,
|
||||
population,
|
||||
founding_age_years,
|
||||
chain,
|
||||
quarter_id,
|
||||
..
|
||||
} = build_skeleton_work_item(
|
||||
"FjordBody",
|
||||
7,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
&RoadGraph::default(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
|
||||
// Verify the context is correctly wired before skeleton generation.
|
||||
assert_eq!(context.political_archetype, PoliticalArchetype::Corporate);
|
||||
assert_eq!(context.morphology_zone, MorphologyZone::Fjord);
|
||||
|
||||
// Run skeleton generation and verify:
|
||||
// - DistrictLayoutMode is not the Commission path
|
||||
// - street_topology(Fjord) → Ribbon (verified via absence of mesh-only outputs)
|
||||
let skeleton = generate_quarter_skeleton(
|
||||
&context,
|
||||
population,
|
||||
&economic_role,
|
||||
quarter_id,
|
||||
founding_age_years,
|
||||
chain,
|
||||
);
|
||||
|
||||
// A Corporate context with no road entries → BlockJunction fallback,
|
||||
// but the layout mode must NOT be the Commission/Commission-grid variant.
|
||||
// The skeleton's access_points are generated; at least one must exist.
|
||||
assert!(
|
||||
!skeleton.access_points.is_empty(),
|
||||
"skeleton must have at least one access point"
|
||||
);
|
||||
// Corporate + Fjord should NOT produce only RadialCore topology access points.
|
||||
// (Ribbon topology and CampusGrid layout are tested structurally here.)
|
||||
// With no road entries, BlockJunction fires — but layout mode is Corporate.
|
||||
let has_junction = skeleton
|
||||
.access_points
|
||||
.iter()
|
||||
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
|
||||
assert!(
|
||||
has_junction,
|
||||
"isolated Corporate+Fjord settlement should have BlockJunction fallback"
|
||||
);
|
||||
}
|
||||
|
||||
// ── T-1043: road_entry_directions from RoadGraph ───────────────────────────
|
||||
|
||||
/// Build a minimal RoadGraph with two nodes and one edge, then verify that
|
||||
/// `road_entry_directions_for_city` returns the correct entry octant.
|
||||
#[test]
|
||||
fn road_entry_directions_single_east_road() {
|
||||
// City at (row=10, col=10), road goes east to (row=10, col=50).
|
||||
// Expected octant: 2 (East) — dc=40, dr=0, dominant east.
|
||||
let city_pos = (10u16, 10u16);
|
||||
let far_pos = (10u16, 50u16);
|
||||
|
||||
let road_graph = RoadGraph {
|
||||
nodes: vec![
|
||||
RoadNode {
|
||||
city_id: Some(1),
|
||||
position: city_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
position: far_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![city_pos, far_pos],
|
||||
length_cells: 4,
|
||||
maintenance: MaintenanceAuthority::Administrative,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}],
|
||||
};
|
||||
|
||||
let octants = road_entry_directions_for_city(1, city_pos, &road_graph);
|
||||
assert_eq!(octants, vec![2u8], "east road should yield octant 2 (E)");
|
||||
}
|
||||
|
||||
/// A settlement with two road connections (north and south) should produce
|
||||
/// both octants, ordered by quality (higher-prestige first).
|
||||
#[test]
|
||||
fn road_entry_directions_multi_road_prestige_order() {
|
||||
// City at (20, 20). Road north to (0, 20) [Administrative]; road south to
|
||||
// (40, 20) [Communal]. Expected: [0 (N, rank 4), 4 (S, rank 1)].
|
||||
let city_pos = (20u16, 20u16);
|
||||
let north_pos = (0u16, 20u16);
|
||||
let south_pos = (40u16, 20u16);
|
||||
|
||||
let road_graph = RoadGraph {
|
||||
nodes: vec![
|
||||
RoadNode {
|
||||
city_id: Some(10),
|
||||
position: city_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(11),
|
||||
position: north_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(12),
|
||||
position: south_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![city_pos, north_pos],
|
||||
length_cells: 2,
|
||||
maintenance: MaintenanceAuthority::Administrative,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 2,
|
||||
path: vec![city_pos, south_pos],
|
||||
length_cells: 2,
|
||||
maintenance: MaintenanceAuthority::Communal,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let octants = road_entry_directions_for_city(10, city_pos, &road_graph);
|
||||
assert_eq!(
|
||||
octants,
|
||||
vec![0u8, 4u8],
|
||||
"N (Administrative, rank 4) must precede S (Communal, rank 1)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two roads on the same octant are de-duplicated; only the higher-quality
|
||||
/// road's rank is kept.
|
||||
#[test]
|
||||
fn road_entry_directions_deduplicates_same_octant() {
|
||||
let city_pos = (10u16, 10u16);
|
||||
// Two roads both going south (dr > 0, dc = 0 → octant 4).
|
||||
let road_graph = RoadGraph {
|
||||
nodes: vec![
|
||||
RoadNode {
|
||||
city_id: Some(1),
|
||||
position: city_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
position: (30u16, 10u16),
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(3),
|
||||
position: (50u16, 10u16),
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![city_pos, (30, 10)],
|
||||
length_cells: 2,
|
||||
maintenance: MaintenanceAuthority::Trade,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 2,
|
||||
path: vec![city_pos, (50, 10)],
|
||||
length_cells: 4,
|
||||
maintenance: MaintenanceAuthority::Corporate,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let octants = road_entry_directions_for_city(1, city_pos, &road_graph);
|
||||
// Both go south (octant 4); de-duplication keeps one; higher rank (Corporate=3) wins.
|
||||
assert_eq!(octants, vec![4u8], "same-octant roads must be deduplicated");
|
||||
}
|
||||
|
||||
/// Acceptance test for T-1043: a settlement with a road connection produces
|
||||
/// at least one QuarterEdge access node on the correct octant; a genuinely
|
||||
/// isolated settlement falls back to BlockJunction only.
|
||||
#[test]
|
||||
fn dispatch_with_road_produces_quarter_edge_not_block_junction() {
|
||||
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
// Road goes east from city at (10, 10) to (10, 50) → octant 2 (East).
|
||||
let city_pos = (10u16, 10u16);
|
||||
let far_pos = (10u16, 50u16);
|
||||
let road_graph = RoadGraph {
|
||||
nodes: vec![
|
||||
RoadNode {
|
||||
city_id: Some(5),
|
||||
position: city_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(6),
|
||||
position: far_pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![city_pos, far_pos],
|
||||
length_cells: 4,
|
||||
maintenance: MaintenanceAuthority::Administrative,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}],
|
||||
};
|
||||
|
||||
let placement = CityPlacement {
|
||||
city_id: 5,
|
||||
position: city_pos,
|
||||
attractor_type: AttractorType::CoastalAccess,
|
||||
score: 100,
|
||||
synthetic: false,
|
||||
political_archetype: PoliticalArchetype::Commission,
|
||||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
};
|
||||
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
context,
|
||||
economic_role,
|
||||
population,
|
||||
founding_age_years,
|
||||
chain,
|
||||
quarter_id,
|
||||
..
|
||||
} = build_skeleton_work_item(
|
||||
"RoadBody",
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&road_graph,
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
|
||||
// Context should have octant 2 (East) in road_entry_directions.
|
||||
assert_eq!(
|
||||
context.road_entry_directions,
|
||||
vec![2u8],
|
||||
"east road must produce octant 2 in road_entry_directions"
|
||||
);
|
||||
|
||||
// Generate the skeleton and verify QuarterEdge is produced (not just BlockJunction).
|
||||
let skeleton = generate_quarter_skeleton(
|
||||
&context,
|
||||
population,
|
||||
&economic_role,
|
||||
quarter_id,
|
||||
founding_age_years,
|
||||
chain,
|
||||
);
|
||||
|
||||
let has_quarter_edge = skeleton
|
||||
.access_points
|
||||
.iter()
|
||||
.any(|p| matches!(p.kind, AccessKind::QuarterEdge { octant: 2 }));
|
||||
assert!(
|
||||
has_quarter_edge,
|
||||
"settlement with east road must produce QuarterEdge(octant=2)"
|
||||
);
|
||||
|
||||
let has_block_junction = skeleton
|
||||
.access_points
|
||||
.iter()
|
||||
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
|
||||
assert!(
|
||||
!has_block_junction,
|
||||
"settlement with road connections must NOT fall back to BlockJunction"
|
||||
);
|
||||
}
|
||||
|
||||
/// An isolated settlement (no road edges) must fall back to BlockJunction only.
|
||||
#[test]
|
||||
fn isolated_settlement_falls_back_to_block_junction() {
|
||||
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
let placement = sample_placement(7, FoundingOrientation::Cardinal);
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
context,
|
||||
economic_role,
|
||||
population,
|
||||
founding_age_years,
|
||||
chain,
|
||||
quarter_id,
|
||||
..
|
||||
} = build_skeleton_work_item(
|
||||
"IsolatedBody",
|
||||
42,
|
||||
&placement,
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
|
||||
assert!(
|
||||
context.road_entry_directions.is_empty(),
|
||||
"isolated settlement must have no road_entry_directions"
|
||||
);
|
||||
|
||||
let skeleton = generate_quarter_skeleton(
|
||||
&context,
|
||||
population,
|
||||
&economic_role,
|
||||
quarter_id,
|
||||
founding_age_years,
|
||||
chain,
|
||||
);
|
||||
|
||||
let has_block_junction = skeleton
|
||||
.access_points
|
||||
.iter()
|
||||
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
|
||||
assert!(
|
||||
has_block_junction,
|
||||
"isolated settlement must fall back to BlockJunction"
|
||||
);
|
||||
}
|
||||
|
||||
// ── bearing_octant unit tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bearing_octant_cardinal_directions() {
|
||||
// North: row decreases (dr < 0, dc = 0)
|
||||
assert_eq!(bearing_octant((10, 10), (0, 10)), 0, "N");
|
||||
// East: col increases (dr = 0, dc > 0)
|
||||
assert_eq!(bearing_octant((10, 10), (10, 50)), 2, "E");
|
||||
// South: row increases (dr > 0, dc = 0)
|
||||
assert_eq!(bearing_octant((10, 10), (50, 10)), 4, "S");
|
||||
// West: col decreases (dr = 0, dc < 0)
|
||||
assert_eq!(bearing_octant((10, 10), (10, 0)), 6, "W");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearing_octant_diagonal_directions() {
|
||||
// NE: dr < 0, dc > 0 (roughly equal magnitude)
|
||||
assert_eq!(bearing_octant((10, 10), (5, 15)), 1, "NE");
|
||||
// SE: dr > 0, dc > 0
|
||||
assert_eq!(bearing_octant((10, 10), (15, 15)), 3, "SE");
|
||||
// SW: dr > 0, dc < 0
|
||||
assert_eq!(bearing_octant((10, 10), (15, 5)), 5, "SW");
|
||||
// NW: dr < 0, dc < 0
|
||||
assert_eq!(bearing_octant((10, 10), (5, 5)), 7, "NW");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,39 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos {
|
||||
district_to_region(chunk_to_district(c))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Working-heightmap pixel ↔ district (cascade.rs working grid)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Number of working-heightmap-grid pixels per district side on the standard
|
||||
/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023).
|
||||
///
|
||||
/// 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`].
|
||||
pub const HEIGHTMAP_CELLS_PER_DISTRICT: usize = 8;
|
||||
|
||||
/// Convert a working-heightmap-grid pixel coordinate `(row, col)` to the
|
||||
/// [`DistrictPos`] 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
|
||||
/// [`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 {
|
||||
let cpd = HEIGHTMAP_CELLS_PER_DISTRICT as i32;
|
||||
// pixel = (row, col); DistrictPos convention is (dx=col_district, dy=row_district).
|
||||
let dx = pixel.1 as i32 / cpd;
|
||||
let dy = pixel.0 as i32 / cpd;
|
||||
(dx, dy)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The elastic seam — region ↔ planet (the only per-body-floating quantity)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user