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:
2026-06-16 11:34:22 +02:00
co-authored by Claude Opus 4.8
parent f98297d136
commit 9c8ad83071
3 changed files with 904 additions and 23 deletions
+5 -4
View File
@@ -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
View File
@@ -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");
}
}
+33
View File
@@ -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)
// ---------------------------------------------------------------------------