From 9c8ad83071f59b6746b7265de26c31caaa908742 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 16 Jun 2026 11:34:22 +0200 Subject: [PATCH] feat(simulation): thread political_archetype/morphology_zone/road_entry_directions through L3->L4 dispatch (T-1039, T-1043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- server/src/atlas/cascade.rs | 9 +- server/src/atlas/plugin.rs | 885 +++++++++++++++++++++++++++++++++++- server/src/atlas/scale.rs | 33 ++ 3 files changed, 904 insertions(+), 23 deletions(-) diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 13e13d5df..581352c03 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -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 }); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index b929df31d..cc4bbce00 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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, + 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 { + // 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 for dedup-with-max-quality. + let mut octant_quality: BTreeMap = 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 = 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 = 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"); + } } diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 0b50888c0..d6daad8fb 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -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) // ---------------------------------------------------------------------------