diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index 8e71e641f..092c000d9 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::region_profile::{RegionPos, RegionProfile}; +use crate::atlas::road_graph::RoadGraph; use crate::simulation::generator::{ GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus, }; @@ -84,6 +85,10 @@ pub struct BodyWorldState { /// Settlement placements (D-211, #955). Attractor-matched city positions. /// Empty until the Layer-3 placement task completes. pub placements: Vec, + /// Inter-settlement road/rail graph (D-211, T-1038). Session-cached, never + /// serialized — re-derived from `(placements, terrain, seed)` on resume. + /// Empty until the Layer-2 road task completes (needs placements + terrain). + pub road_graph: RoadGraph, /// Quarter-level world state, keyed by `QuarterId` (D-230). /// /// Populated by `GenCompletion::SkeletonGenerated` after the plan phase @@ -216,6 +221,7 @@ mod tests { drainage_basins: vec![], attractors: vec![], placements: vec![], + road_graph: RoadGraph::default(), quarters: BTreeMap::new(), regions: BTreeMap::new(), last_accessed: tick, diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 92c178c81..126512c07 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -28,6 +28,7 @@ use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile}; +use crate::atlas::road_graph::{self, RoadGraph}; use crate::seed::SeedChain; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus}; @@ -49,6 +50,12 @@ pub enum CascadeLayer { /// `(seed, body_params, terrain_analysis)`. Appended after Settlement so /// declaration order (= Ord) is preserved — never reorder (D-010). RegionProfile, + /// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function + /// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer + /// 2", but it depends only on Settlement + Topography, so it is **appended + /// last** to honour the append-only `Ord` rule (it neither needs nor blocks + /// the RegionProfile layer; requesting it runs RegionProfile first, harmlessly). + RoadGraph, } /// Output of the cascade for one body, up to the requested layer (#952). @@ -70,6 +77,9 @@ pub struct CascadeSnapshot { /// RegionProfile layer — ~1 km carriers. `Some` once /// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1). pub layer_region: Option, + /// Layer 2 — inter-settlement road/rail graph. `Some` once + /// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038). + pub road_graph: Option, } /// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain @@ -98,6 +108,7 @@ impl CascadeSnapshot { }; let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default(); let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default(); + let road_graph = self.road_graph.unwrap_or_default(); BodyWorldState { body_id: self.body_id, heightmap: self.heightmap.data, @@ -107,6 +118,7 @@ impl CascadeSnapshot { drainage_basins, attractors, placements, + road_graph, quarters: std::collections::BTreeMap::new(), regions, last_accessed: 0, @@ -173,6 +185,7 @@ pub fn run_cascade_from_heightmap( layer1: None, layer3: None, layer_region: None, + road_graph: None, }; // TerritorialStatus is derived once per body from the system's dominant @@ -210,28 +223,35 @@ pub fn run_cascade_from_heightmap( snapshot.layer3 = Some(l3); } - // RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params + - // terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass. - // Layer 1 already ran drainage inside run_layer1, but neither the drainage - // result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both - // here. Pure → determinism preserved, but the drainage re-run is NOT free at - // the ~6 000-regions/body working scale (D-203). + // RegionProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a + // TerrainAnalysis, which needs a drainage pass. Layer 1 already ran drainage + // inside run_layer1, but neither result is stored on Layer1Output, so we re-run + // both here once and share them. Pure → determinism preserved, but the drainage + // re-run is NOT free at the ~6 000-regions/body working scale (D-203). // PERF/TODO(T-1044): cache TerrainAnalysis on Layer1Output to drop this // redundant drainage pass, and validate the combined cost against the D-239 §10 - // ~45 ms/body budget in the T-1031 verification harness. This is now a LIVE + // ~45 ms/body budget in the T-1031 verification harness. This is a LIVE // production cost: T-1032 wired the real body_params read, so every analyzed - // body runs this path. If body_params is None, the region layer is skipped - // (e.g. unit tests without DB). - if up_to >= CascadeLayer::RegionProfile { + // body runs this path. + // + // The terrain analysis is computed only when it will actually be used: + // body_params present (RegionProfile) or RoadGraph requested. RegionProfile + // skips silently without body_params (e.g. unit tests without DB), but the + // road graph needs no body params, so RoadGraph runs regardless. + if up_to >= CascadeLayer::RegionProfile + && (body_params.is_some() || up_to >= CascadeLayer::RoadGraph) + { + use crate::atlas::drainage; + let dr = drainage::analyze( + &snapshot.heightmap.data, + snapshot.heightmap.width, + snapshot.heightmap.height, + snapshot.heightmap.sea_level, + ); + let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr); + + // RegionProfile layer — pure derivation from body params + terrain. if let Some(params) = body_params { - use crate::atlas::drainage; - let dr = drainage::analyze( - &snapshot.heightmap.data, - snapshot.heightmap.width, - snapshot.heightmap.height, - snapshot.heightmap.sea_level, - ); - let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr); // ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions; // at full working resolution the budget is ~6 000/body (D-203). const CELLS_PER_REGION: usize = 8; @@ -239,6 +259,33 @@ pub fn run_cascade_from_heightmap( region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION); snapshot.layer_region = Some(LayerRegionOutput { regions }); } + + // Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure + // function of (Layer-3 placements, Layer-1 terrain). The named-route pool + // is empty for now (atlas_roads/atlas_railroads carry no rows post-D-223), + // so the named-route identity join is a designed-for no-op. + if up_to >= CascadeLayer::RoadGraph { + let placements = snapshot + .layer3 + .as_ref() + .map(|l3| l3.placements.as_slice()) + .unwrap_or(&[]); + let river_cells = snapshot + .layer1 + .as_ref() + .map(|l1| l1.river_network.river_cells.as_slice()) + .unwrap_or(&[]); + let graph = road_graph::build_road_graph( + placements, + &ta, + river_cells, + snapshot.heightmap.width, + snapshot.heightmap.height, + &territorial_status, + &[], + ); + snapshot.road_graph = Some(graph); + } } snapshot @@ -371,6 +418,7 @@ mod tests { assert!(CascadeLayer::Heightmap < CascadeLayer::Topography); assert!(CascadeLayer::Topography < CascadeLayer::Settlement); assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile); + assert!(CascadeLayer::RegionProfile < CascadeLayer::RoadGraph); } #[test] @@ -511,4 +559,83 @@ mod tests { placement_count ); } + + /// Layer 2 — road graph runs through the full cascade, connects the placed + /// cities, is deterministic, and propagates into BodyWorldState (T-1038). + #[test] + fn road_graph_layer_connects_cities_deterministically() { + use crate::atlas::attractor_matching::CityRecord; + use crate::atlas::road_graph::RoadNodeKind; + use crate::simulation::generator::SettlementClass; + + // Several inland cities (the slope heightmap is land away from the low + // corner) so the MST has real edges to route. + let cities: Vec = [ + (1u64, "A", 800_000i64), + (2, "B", 400_000), + (3, "C", 200_000), + (4, "D", 150_000), + ] + .iter() + .map(|(id, name, pop)| CityRecord { + city_id: *id, + name: (*name).into(), + settlement_class: SettlementClass::PopulationBudget, + population: *pop, + economic_role: "manufacturing".into(), + }) + .collect(); + + let run = || { + run_cascade_from_heightmap( + body_seed(), + test_heightmap(), + &cities, + Some("independent"), + None, // body_params — road graph needs none + CascadeLayer::RoadGraph, + ) + }; + let snap = run(); + let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran"); + let settlements = graph + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .count(); + assert_eq!(settlements, cities.len(), "one road node per placed city"); + assert!( + !graph.edges.is_empty(), + "placed cities on shared land must be connected" + ); + // Every edge endpoint is a settlement node and the path snaps to it. + for e in &graph.edges { + assert!(e.from < e.to); + assert_eq!(graph.nodes[e.from].position, *e.path.first().unwrap()); + assert_eq!(graph.nodes[e.to].position, *e.path.last().unwrap()); + } + + // Determinism: identical inputs → identical graph. + let key = |s: &CascadeSnapshot| { + let g = s.road_graph.as_ref().unwrap(); + ( + g.nodes + .iter() + .map(|n| (n.city_id, n.position, n.degree)) + .collect::>(), + g.edges + .iter() + .map(|e| (e.from, e.to, e.length_cells, e.maintenance)) + .collect::>(), + ) + }; + assert_eq!(key(&snap), key(&run()), "road graph must be deterministic"); + + // Propagates into the hot-cache BodyWorldState. + let edge_count = graph.edges.len(); + assert_eq!( + snap.into_body_world_state().road_graph.edges.len(), + edge_count + ); + } } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index ba193e34d..496a6eda5 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -374,15 +374,13 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { } else { hm }; - // Run through RegionProfile (T-1023, D-239 §1): includes Settlement - // and all prior layers. RegionProfile > Settlement in CascadeLayer ord - // so Settlement also runs when body_params is Some. When body_params - // is None the cascade falls back to Settlement as the terminal layer. - let up_to = if body_params.is_some() { - CascadeLayer::RegionProfile - } else { - CascadeLayer::Settlement - }; + // Run the full cascade through RoadGraph (Layer 2, T-1038), the + // terminal layer. It subsumes Settlement, RegionProfile (T-1023), + // and all prior layers. RegionProfile derivation still gates on + // body_params internally (skipped when absent — e.g. a body with no + // params row), but the road graph needs no body params, so it runs + // for every analyzed body. + let up_to = CascadeLayer::RoadGraph; let snapshot = run_cascade_from_heightmap( *body_seed, working, diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 9ebba0caa..8939554ea 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -270,6 +270,7 @@ mod tests { drainage_basins: vec![], attractors: vec![], placements: vec![], + road_graph: crate::atlas::road_graph::RoadGraph::default(), quarters: std::collections::BTreeMap::new(), regions: std::collections::BTreeMap::new(), last_accessed: 0, diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 203959cd9..45b3321eb 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -20,6 +20,7 @@ pub mod layer1; pub mod layer_proxy; pub mod plugin; pub mod region_profile; +pub mod road_graph; pub mod skeleton_gen; pub mod source_resolver; pub mod subbiome; diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs new file mode 100644 index 000000000..986b77db8 --- /dev/null +++ b/server/src/atlas/road_graph.rs @@ -0,0 +1,1065 @@ +//! Layer 2 — inter-settlement road/rail graph (D-211, T-1038). +//! +//! The transport topology drawn onto the world *between* settlements: the +//! workshopped-but-never-built link from settlement placement (Layer 3, +//! [`crate::atlas::attractor_matching`]) to everything that consumes transport +//! topology (road-entry octants, TerritorialStatus road-coverage thresholds, +//! hinterland fill, Atlas overlays). +//! +//! **Algorithm** (fully decided — `docs/workshops/planet-down-cascade/tyre-round1.md` +//! §"Road graph generation", `round-1-notes.md` Layer 2): +//! 1. **MST force-connect** all city placements (Euclidean), Kruskal over a +//! strict `(dist², i, j)` order so the tree is unique and deterministic. +//! 2. **Secondary links**: city pairs whose straight-line distance is within +//! `[30%, 60%]` of body scale *and* whose MST-tree path exceeds `1.5×` the +//! straight line get a direct road (cuts long detours). +//! 3. **A\*** routes each edge over the L1 terrain-cost field — `ocean`/`lake` +//! impassable, river corridors preferred (½ cost, rivers as valley routes), +//! steep cells (`slope ≥ 35°` ≈ D-210 roughness 0.7) penalised 10×, all +//! other land at its D-210 `terrain_modification_cost`. Routing runs on a +//! coarse ~64-wide regional grid (the workshop's "2 048 cells is trivial"). +//! 4. **MaintenanceAuthority** per edge from endpoint political archetypes + +//! haul length ([`maintenance_authority`]). +//! 5. **Waypoint** sub-settlement nodes at the midpoints of long edges. +//! 6. **Named-route identity**: trunk edges join `systems.db` named routes via +//! an optional `named_route_id` (the pool is supplied by the caller; it is +//! empty today — `atlas_roads`/`atlas_railroads` carry no rows post-D-223 — +//! so the join is a designed-for no-op until that pool is repopulated). +//! +//! **Storage:** the result lives on `BodyWorldState` (D-203), session-cached, +//! **never serialized** — it is re-derived from `(placements, terrain, seed)` on +//! resume. The `atlas_road_nodes`/`atlas_road_edges` schema shape carries forward +//! (tyre-round1.md) but the data is runtime Rust, not committed to systems.db. +//! +//! **Determinism (D-010):** no RNG. MST is a strictly-ordered Kruskal, A* breaks +//! ties by cell index, waypoints are geometric midpoints. f64 distance math is +//! correctly-rounded (IEEE-754) and summed in a fixed order, so the output is +//! bit-reproducible. +//! +//! Coordinates: every stored position/polyline is in the **working heightmap +//! grid** (`grid_w × grid_h`, the same space as Layer-1 rivers/attractors and +//! Layer-3 placements), so downstream Atlas overlays need no rescaling. A\* +//! routes on an internal coarse grid and the path is upscaled back. + +use std::cmp::Reverse; +use std::collections::{BTreeSet, BinaryHeap}; + +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::atlas::attractor_matching::CityPlacement; +use crate::atlas::features::TerrainAnalysis; +use crate::atlas::subbiome; +use crate::simulation::generator::{MaintenanceAuthority, PoliticalArchetype, TerritorialStatus}; + +// --------------------------------------------------------------------------- +// Tunables (workshop-anchored) +// --------------------------------------------------------------------------- + +/// Target width of the internal A\* routing grid (the workshop's ~64×32 regional +/// grid — "A\* on 2 048 cells is trivial"). The working heightmap (512×256) is +/// downsampled to ≤ this width for routing; paths are upscaled back to grid space. +const ROUTE_W: u32 = 64; + +/// Movement-cost multipliers (integer, D-010). A step into a cell costs +/// `cell_cost × ORTHO` (orthogonal) or `cell_cost × DIAG` (diagonal); 3/2 ≈ √2. +const ORTHO: u32 = 2; +const DIAG: u32 = 3; + +/// Per-cell A\* costs (percent of grassland baseline; flat grassland = 100, the +/// D-210 `terrain_modification_cost` of `TemperateGrassland`). +const RIVER_COST: u32 = 50; // 0.5× — rivers are preferred valley routes. +const MOUNTAIN_COST: u32 = 1000; // 10× — D-210 roughness > 0.7 (atan(0.7) ≈ 35°). +const IMPASSABLE: u32 = u32::MAX; // ocean / lake — no road. + +/// `slope_deg` (= `atan(max |Δelev|)`, [`TerrainAnalysis`]) at/above which a cell +/// is treated as mountain. `atan(0.7) ≈ 34.99°`, i.e. the workshop's roughness 0.7. +const MOUNTAIN_SLOPE_DEG: f32 = 35.0; + +/// Cheapest possible cell cost — the A\* heuristic's per-step lower bound. Must be +/// `≤` every traversable `cell_cost` for the heuristic to stay admissible. +const MIN_CELL_COST: u32 = RIVER_COST; + +/// A routed edge longer than this (in routing cells) earns a midpoint waypoint +/// (tyre-round1.md: "edge length > ~12 regional cells"). +const WAYPOINT_THRESHOLD_CELLS: u32 = 12; + +/// Secondary-link candidate band: straight-line distance within `[30%, 60%]` of +/// body scale (round-1-notes.md). +const SECONDARY_MIN_FRAC: f64 = 0.30; +const SECONDARY_MAX_FRAC: f64 = 0.60; +/// A secondary link is added when the MST-tree path exceeds this multiple of the +/// straight line ("MST path exceeds 1.5× Euclidean"). +const SECONDARY_DETOUR: f64 = 1.5; + +/// A non-state edge longer than this fraction of body scale is a `Trade` haul; +/// shorter ones between neighbours are `Communal`. +const LONG_HAUL_FRAC: f64 = 0.30; + +/// Settlement nodes with at least this many incident edges are high-connectivity +/// junctions (the candidates a `RailHeadFacing` second pass would target). +pub const JUNCTION_DEGREE: u16 = 3; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +/// What a [`RoadNode`] represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RoadNodeKind { + /// A placed city (carries its `city_id`). + Settlement, + /// A sub-settlement waypoint at the midpoint of a long edge. + Waypoint, +} + +/// One node in the road graph — a settlement or a waypoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadNode { + /// The placed city's id; `None` for waypoints. + pub city_id: Option, + /// Position in working-heightmap-grid coordinates `(row, col)`. + pub position: (u16, u16), + pub kind: RoadNodeKind, + /// Number of incident edges. Settlement junctions (`degree ≥ JUNCTION_DEGREE`) + /// are the candidates for a `RailHeadFacing` second pass (T-1038 §6). + pub degree: u16, + /// For a waypoint: the edge it sits on. `None` for settlements. + pub parent_edge: Option, +} + +/// One inter-settlement road/rail edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadEdge { + /// `nodes` index of the endpoint settlements (`from < to` by construction). + pub from: usize, + pub to: usize, + /// Routed polyline in working-heightmap-grid coordinates `(row, col)`, + /// endpoints snapped to the exact settlement positions. + pub path: Vec<(u16, u16)>, + /// Routed length in routing-grid cells (drives the waypoint threshold and is + /// a cheap display proxy for path length). + pub length_cells: u32, + pub maintenance: MaintenanceAuthority, + /// Joined `systems.db` named-route id, if this trunk edge was assigned one. + /// `None` until the named-route pool is repopulated (D-223 emptied it). + pub named_route_id: Option, + /// `true` if the joined named route is a railroad; `false` (a road) otherwise. + pub is_rail: bool, +} + +/// A named transport route from `systems.db` (`atlas_roads` / `atlas_railroads`), +/// the pool the post-generation identity join draws from. Empty today (D-223). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamedRoute { + pub route_id: String, + pub is_rail: bool, +} + +/// The inter-settlement transport graph for one body (D-211, T-1038). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadGraph { + pub nodes: Vec, + pub edges: Vec, +} + +impl RoadGraph { + /// Settlement node indices with `degree ≥ JUNCTION_DEGREE` — high-connectivity + /// junctions (the `RailHeadFacing` candidates, T-1038 §6). + pub fn high_connectivity_junctions(&self) -> Vec { + self.nodes + .iter() + .enumerate() + .filter(|(_, n)| n.kind == RoadNodeKind::Settlement && n.degree >= JUNCTION_DEGREE) + .map(|(i, _)| i) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Build the inter-settlement road/rail graph for one body (D-211, T-1038). +/// +/// `placements` are the Layer-3 city placements; `ta` is the Layer-1 terrain +/// analysis (A\* cost inputs); `river_cells` are the Layer-1 river cells (grid +/// coords) used as preferred valley routes. `body_status` (the body's uniform +/// `TerritorialStatus`, D-212) and the endpoint archetypes drive +/// `MaintenanceAuthority`. `named_routes` is the `systems.db` named-route pool +/// (empty today — D-223). +pub fn build_road_graph( + placements: &[CityPlacement], + ta: &TerrainAnalysis, + river_cells: &[(u16, u16)], + grid_w: u32, + grid_h: u32, + body_status: &TerritorialStatus, + named_routes: &[NamedRoute], +) -> RoadGraph { + if placements.is_empty() || grid_w == 0 || grid_h == 0 { + return RoadGraph::default(); + } + + // Settlement nodes, one per placement, in placement order (deterministic). + let mut nodes: Vec = placements + .iter() + .map(|p| RoadNode { + city_id: Some(p.city_id), + position: p.position, + kind: RoadNodeKind::Settlement, + degree: 0, + parent_edge: None, + }) + .collect(); + let n = nodes.len(); + + // Single city — nothing to connect. + if n == 1 { + return RoadGraph { + nodes, + edges: Vec::new(), + }; + } + + // --- routing grid + cost field ----------------------------------------- + let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h); + let body_scale = grid.body_scale(); + + // --- (1) MST over Euclidean distances (strict Kruskal) ----------------- + let positions: Vec<(f64, f64)> = nodes + .iter() + .map(|node| (node.position.0 as f64, node.position.1 as f64)) + .collect(); + let mst_pairs = minimum_spanning_tree(&positions); + + // --- (2) secondary links: long MST detours within the body-scale band -- + let tree_dist = all_pairs_tree_distance(n, &mst_pairs, &positions); + let mut wanted: BTreeSet<(usize, usize)> = mst_pairs.iter().copied().collect(); + for i in 0..n { + for j in (i + 1)..n { + let euclid = euclid(positions[i], positions[j]); + if euclid < SECONDARY_MIN_FRAC * body_scale || euclid > SECONDARY_MAX_FRAC * body_scale + { + continue; + } + let td = tree_dist[i][j]; + if td.is_finite() && td > SECONDARY_DETOUR * euclid { + wanted.insert((i, j)); + } + } + } + + // --- (3) A* route every wanted edge; drop unroutable pairs (oceans) ---- + let mut edges: Vec = Vec::new(); + let mut dropped = 0usize; + for (i, j) in wanted { + let Some((route_cells, length_cells)) = grid.astar(positions[i], positions[j]) else { + dropped += 1; + continue; + }; + let path = grid.to_grid_path(nodes[i].position, nodes[j].position, &route_cells); + let archetype_i = placements[i].political_archetype; + let archetype_j = placements[j].political_archetype; + let maintenance = maintenance_authority( + archetype_i, + archetype_j, + euclid(positions[i], positions[j]), + body_scale, + body_status, + ); + edges.push(RoadEdge { + from: i, + to: j, + path, + length_cells, + maintenance, + named_route_id: None, + is_rail: false, + }); + } + if dropped > 0 { + debug!( + dropped, + kept = edges.len(), + "road_graph: some settlement pairs are unroutable by land (separated by water)" + ); + } + + // --- degree (settlement junction detection) ---------------------------- + for e in &edges { + nodes[e.from].degree += 1; + nodes[e.to].degree += 1; + } + + // --- (4) named-route identity join: trunk (longest) edges first -------- + assign_named_routes(&mut edges, named_routes); + + // --- (5) waypoints at midpoints of long edges -------------------------- + add_waypoints(&mut nodes, &edges); + + RoadGraph { nodes, edges } +} + +// --------------------------------------------------------------------------- +// MaintenanceAuthority derivation (workshop OQ-R3-4) +// --------------------------------------------------------------------------- + +/// Derive an edge's [`MaintenanceAuthority`] from its endpoint political +/// archetypes + haul length (tyre-round1.md step 4; ozzie-round1.md "read who +/// controls territory by the state of the roads"). +/// +/// Priority: a Derelict province abandons all roads; else a state endpoint +/// (Commission or strategic Military) → `Administrative`; else a Corporate +/// endpoint → `Corporate`; else (purely Pioneer/Industrial/Academic) a long +/// inter-regional haul → `Trade`, a short neighbour link → `Communal`. +pub fn maintenance_authority( + a: PoliticalArchetype, + b: PoliticalArchetype, + euclid_dist: f64, + body_scale: f64, + body_status: &TerritorialStatus, +) -> MaintenanceAuthority { + if matches!(body_status, TerritorialStatus::Derelict) { + return MaintenanceAuthority::Abandoned; + } + let has = |k: PoliticalArchetype| a == k || b == k; + if has(PoliticalArchetype::Commission) || has(PoliticalArchetype::Military) { + MaintenanceAuthority::Administrative + } else if has(PoliticalArchetype::Corporate) { + MaintenanceAuthority::Corporate + } else if euclid_dist > LONG_HAUL_FRAC * body_scale { + MaintenanceAuthority::Trade + } else { + MaintenanceAuthority::Communal + } +} + +// --------------------------------------------------------------------------- +// Named-route join + waypoints +// --------------------------------------------------------------------------- + +/// Assign named-route identities to trunk edges (longest first; ties by edge +/// index for determinism), one per available route until the pool is exhausted. +fn assign_named_routes(edges: &mut [RoadEdge], named_routes: &[NamedRoute]) { + if named_routes.is_empty() { + return; + } + let mut order: Vec = (0..edges.len()).collect(); + order.sort_by(|&x, &y| { + edges[y] + .length_cells + .cmp(&edges[x].length_cells) + .then(x.cmp(&y)) + }); + for (route, &ei) in named_routes.iter().zip(order.iter()) { + edges[ei].named_route_id = Some(route.route_id.clone()); + edges[ei].is_rail = route.is_rail; + } +} + +/// Append a waypoint node at the geometric midpoint of every edge whose routed +/// length exceeds [`WAYPOINT_THRESHOLD_CELLS`] (tyre-round1.md). Waypoints are +/// points-of-interest on the through-edge, not topology splits. +fn add_waypoints(nodes: &mut Vec, edges: &[RoadEdge]) { + for (ei, e) in edges.iter().enumerate() { + if e.length_cells <= WAYPOINT_THRESHOLD_CELLS || e.path.len() < 2 { + continue; + } + let mid = e.path[e.path.len() / 2]; + nodes.push(RoadNode { + city_id: None, + position: mid, + kind: RoadNodeKind::Waypoint, + degree: 0, + parent_edge: Some(ei), + }); + } +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +#[inline] +fn euclid(a: (f64, f64), b: (f64, f64)) -> f64 { + let dr = a.0 - b.0; + let dc = a.1 - b.1; + (dr * dr + dc * dc).sqrt() +} + +/// Kruskal MST over the complete graph of `positions`, edges ordered by +/// `(squared distance, i, j)` so the tree is unique and platform-independent +/// (integer squared-distance comparisons — no float in the connectivity choice). +/// Returns the chosen `(i, j)` pairs with `i < j`. +fn minimum_spanning_tree(positions: &[(f64, f64)]) -> Vec<(usize, usize)> { + let n = positions.len(); + let mut candidates: Vec<(i64, usize, usize)> = Vec::with_capacity(n * (n - 1) / 2); + for i in 0..n { + for j in (i + 1)..n { + let dr = (positions[i].0 - positions[j].0) as i64; + let dc = (positions[i].1 - positions[j].1) as i64; + candidates.push((dr * dr + dc * dc, i, j)); + } + } + candidates.sort_unstable(); + + let mut uf = UnionFind::new(n); + let mut tree = Vec::with_capacity(n - 1); + for (_, i, j) in candidates { + if uf.union(i, j) { + tree.push((i, j)); + if tree.len() == n - 1 { + break; + } + } + } + tree +} + +/// All-pairs shortest-path distance over the MST tree (BFS from each node along +/// tree edges, summing Euclidean edge lengths). `f64::INFINITY` if disconnected +/// (cannot happen for a spanning tree, but guarded). +fn all_pairs_tree_distance( + n: usize, + tree: &[(usize, usize)], + positions: &[(f64, f64)], +) -> Vec> { + let mut adj: Vec> = vec![Vec::new(); n]; + for &(i, j) in tree { + let d = euclid(positions[i], positions[j]); + adj[i].push((j, d)); + adj[j].push((i, d)); + } + let mut dist = vec![vec![f64::INFINITY; n]; n]; + // BFS from each source `s` along tree edges; each source only ever writes its + // own row, so iterate the rows mutably (clippy: needless_range_loop). + for (s, row) in dist.iter_mut().enumerate() { + row[s] = 0.0; + let mut stack = vec![s]; + while let Some(u) = stack.pop() { + for &(v, d) in &adj[u] { + if row[v] == f64::INFINITY { + row[v] = row[u] + d; + stack.push(v); + } + } + } + } + dist +} + +/// Union-Find with path compression + union by size (deterministic). +struct UnionFind { + parent: Vec, + size: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + size: vec![1; n], + } + } + fn find(&mut self, x: usize) -> usize { + let mut r = x; + while self.parent[r] != r { + r = self.parent[r]; + } + // Path compression. + let mut cur = x; + while self.parent[cur] != r { + let next = self.parent[cur]; + self.parent[cur] = r; + cur = next; + } + r + } + /// Returns `true` if the two sets were merged (were previously disjoint). + fn union(&mut self, a: usize, b: usize) -> bool { + let (ra, rb) = (self.find(a), self.find(b)); + if ra == rb { + return false; + } + let (big, small) = if self.size[ra] >= self.size[rb] { + (ra, rb) + } else { + (rb, ra) + }; + self.parent[small] = big; + self.size[big] += self.size[small]; + true + } +} + +// --------------------------------------------------------------------------- +// Routing grid + A* +// --------------------------------------------------------------------------- + +/// Coarse routing grid: a downsampled terrain-cost field for A\* (the workshop's +/// ~64×32 regional grid). Built once per body from the Layer-1 `TerrainAnalysis`. +struct RouteGrid { + rw: usize, + rh: usize, + /// Native-grid cells per routing cell (downsample factor, ≥ 1). + scale: usize, + grid_w: u32, + grid_h: u32, + /// Per-routing-cell movement cost; [`IMPASSABLE`] for ocean/lake. + cost: Vec, +} + +impl RouteGrid { + fn build(ta: &TerrainAnalysis, river_cells: &[(u16, u16)], grid_w: u32, grid_h: u32) -> Self { + let scale = (grid_w.div_ceil(ROUTE_W)).max(1) as usize; + let rw = (grid_w as usize).div_ceil(scale).max(1); + let rh = (grid_h as usize).div_ceil(scale).max(1); + let nw = grid_w as usize; + let nh = grid_h as usize; + + // Aggregate the native TerrainAnalysis into routing cells. + let mut water_count = vec![0u32; rw * rh]; + let mut total_count = vec![0u32; rw * rh]; + let mut max_slope = vec![0.0f32; rw * rh]; + for nr in 0..nh.min(ta.h) { + for nc in 0..nw.min(ta.w) { + let ri = (nr / scale) * rw + (nc / scale); + let i = nr * ta.w + nc; + total_count[ri] += 1; + if ta.ocean_mask[i] || ta.lake_mask[i] { + water_count[ri] += 1; + } + if ta.slope_deg[i] > max_slope[ri] { + max_slope[ri] = ta.slope_deg[i]; + } + } + } + + // River corridors: project river cells onto routing cells. + let mut is_river = vec![false; rw * rh]; + for &(r, c) in river_cells { + let (r, c) = (r as usize, c as usize); + if r < nh && c < nw { + is_river[(r / scale) * rw + (c / scale)] = true; + } + } + + // Final per-cell cost: water (majority) impassable > river ½ > mountain + // 10× > D-210 terrain_modification_cost at the block centre. + let mut cost = vec![100u32; rw * rh]; + for rr in 0..rh { + for rc in 0..rw { + let ri = rr * rw + rc; + cost[ri] = if total_count[ri] == 0 || water_count[ri] * 2 > total_count[ri] { + IMPASSABLE + } else if is_river[ri] { + RIVER_COST + } else if max_slope[ri] >= MOUNTAIN_SLOPE_DEG { + MOUNTAIN_COST + } else { + let cr = ((rr * scale) + scale / 2).min(ta.h.saturating_sub(1)); + let cc = ((rc * scale) + scale / 2).min(ta.w.saturating_sub(1)); + let (_, base) = subbiome::classify(ta, cr, cc); + base.max(1) as u32 + }; + } + } + + Self { + rw, + rh, + scale, + grid_w, + grid_h, + cost, + } + } + + /// Body scale in routing cells — the grid diagonal (max sensible distance). + fn body_scale(&self) -> f64 { + ((self.rw * self.rw + self.rh * self.rh) as f64).sqrt() * self.scale as f64 + } + + /// Project a grid-space `(row, col)` to a routing-cell index, clamped. + fn to_route_cell(&self, pos: (f64, f64)) -> (usize, usize) { + let r = ((pos.0 as usize) / self.scale).min(self.rh - 1); + let c = ((pos.1 as usize) / self.scale).min(self.rw - 1); + (r, c) + } + + /// A\* from `start` to `goal` (grid-space coords) over the cost field. Columns + /// wrap (equirectangular); rows clamp. Returns the routing-cell path and its + /// length in routing cells, or `None` if unroutable (no land path). + fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> { + let (sr, sc) = self.to_route_cell(start); + let (gr, gc) = self.to_route_cell(goal); + let s = sr * self.rw + sc; + let g = gr * self.rw + gc; + if self.cost[s] == IMPASSABLE || self.cost[g] == IMPASSABLE { + return None; + } + if s == g { + return Some((vec![(sr, sc)], 0)); + } + + let n = self.rw * self.rh; + let mut gscore = vec![u32::MAX; n]; + let mut came: Vec = vec![usize::MAX; n]; + gscore[s] = 0; + // Min-heap on (f, cell-index) — index tie-break keeps A* deterministic. + let mut open: BinaryHeap> = BinaryHeap::new(); + open.push(Reverse((self.heuristic(s, g), s))); + + while let Some(Reverse((_, cur))) = open.pop() { + if cur == g { + return Some(self.reconstruct(&came, g)); + } + let cr = cur / self.rw; + let cc = cur % self.rw; + let gc_cur = gscore[cur]; + for (nr, nc, diagonal) in self.neighbors(cr, cc) { + let ni = nr * self.rw + nc; + let cell = self.cost[ni]; + if cell == IMPASSABLE { + continue; + } + let step = cell.saturating_mul(if diagonal { DIAG } else { ORTHO }); + let tentative = gc_cur.saturating_add(step); + if tentative < gscore[ni] { + gscore[ni] = tentative; + came[ni] = cur; + let f = tentative.saturating_add(self.heuristic(ni, g)); + open.push(Reverse((f, ni))); + } + } + } + None + } + + /// 8-neighbours of `(r, c)`; columns wrap, rows clamp. Yields `(nr, nc, diagonal)`. + fn neighbors(&self, r: usize, c: usize) -> impl Iterator + '_ { + const STEPS: [(i32, i32); 8] = [ + (-1, 0), + (1, 0), + (0, -1), + (0, 1), + (-1, -1), + (-1, 1), + (1, -1), + (1, 1), + ]; + let (rh, rw) = (self.rh, self.rw); + STEPS.iter().filter_map(move |&(dr, dc)| { + let nr = r as i32 + dr; + if nr < 0 || nr >= rh as i32 { + return None; + } + let nc = (c as i32 + dc).rem_euclid(rw as i32) as usize; + Some((nr as usize, nc, dr != 0 && dc != 0)) + }) + } + + /// Admissible octile heuristic in cost units (lower bound: cheapest cell cost + /// on the fewest possible diagonal+orthogonal steps). Columns wrap. + fn heuristic(&self, a: usize, g: usize) -> u32 { + let (ar, ac) = (a / self.rw, a % self.rw); + let (gr, gc) = (g / self.rw, g % self.rw); + let dr = ar.abs_diff(gr) as u32; + let raw = ac.abs_diff(gc) as u32; + let dc = raw.min(self.rw as u32 - raw); // wrapped column distance + let diag = dr.min(dc); + let straight = dr.max(dc) - diag; + diag * DIAG * MIN_CELL_COST + straight * ORTHO * MIN_CELL_COST + } + + fn reconstruct(&self, came: &[usize], goal: usize) -> (Vec<(usize, usize)>, u32) { + let mut cells = Vec::new(); + let mut cur = goal; + while cur != usize::MAX { + cells.push((cur / self.rw, cur % self.rw)); + cur = came[cur]; + } + cells.reverse(); + let len = cells.len().saturating_sub(1) as u32; + (cells, len) + } + + /// Upscale a routing-cell path to grid coords, snapping the endpoints to the + /// exact settlement positions and dropping consecutive duplicates. + fn to_grid_path( + &self, + start: (u16, u16), + end: (u16, u16), + route_cells: &[(usize, usize)], + ) -> Vec<(u16, u16)> { + let mut path: Vec<(u16, u16)> = Vec::with_capacity(route_cells.len() + 2); + path.push(start); + for &(rr, rc) in route_cells { + let gr = ((rr * self.scale) + self.scale / 2).min(self.grid_h as usize - 1) as u16; + let gc = ((rc * self.scale) + self.scale / 2).min(self.grid_w as usize - 1) as u16; + if path.last() != Some(&(gr, gc)) { + path.push((gr, gc)); + } + } + if path.last() != Some(&end) { + path.push(end); + } + path + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::drainage; + use crate::atlas::heightmap::BodyHeightmap; + use crate::simulation::generator::{ArrangementPattern, AttractorType, FoundingOrientation}; + + /// A flat all-land heightmap (no water, no mountains) so A* always connects. + fn flat_hm(w: u32, h: u32) -> BodyHeightmap { + BodyHeightmap { + body_id: "road_test".into(), + width: w, + height: h, + data: vec![0.6; (w * h) as usize], + sea_level: 0.3, + } + } + + fn ta_for(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + fn placement(city_id: u64, pos: (u16, u16), archetype: PoliticalArchetype) -> CityPlacement { + CityPlacement { + city_id, + position: pos, + attractor_type: AttractorType::PlainCenter, + score: 1000, + synthetic: false, + political_archetype: archetype, + arrangement_pattern: ArrangementPattern::RibbonDevelopment, + founding_orientation: FoundingOrientation::Cardinal, + } + } + + #[test] + fn empty_input_empty_graph() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let g = build_road_graph( + &[], + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert!(g.nodes.is_empty() && g.edges.is_empty()); + } + + #[test] + fn single_city_no_edges() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let g = build_road_graph( + &[placement(1, (10, 10), PoliticalArchetype::Pioneer)], + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert_eq!(g.nodes.len(), 1); + assert!(g.edges.is_empty()); + } + + #[test] + fn mst_connects_n_cities_with_n_minus_1_edges() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + // Four spread-out cities on flat land — MST gives exactly 3 edges, and on + // open terrain no long detours, so no secondary links. + let placements = vec![ + placement(1, (5, 5), PoliticalArchetype::Pioneer), + placement(2, (5, 25), PoliticalArchetype::Pioneer), + placement(3, (25, 5), PoliticalArchetype::Pioneer), + placement(4, (25, 25), PoliticalArchetype::Pioneer), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let settlements = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .count(); + assert_eq!(settlements, 4); + assert_eq!(g.edges.len(), 3, "MST of 4 nodes has 3 edges"); + // Every edge endpoint resolves to a settlement node, path snaps to it. + for e in &g.edges { + assert!(e.from < e.to); + assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap()); + assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap()); + } + } + + #[test] + fn deterministic_same_inputs_same_graph() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + placement(1, (4, 4), PoliticalArchetype::Commission), + placement(2, (8, 30), PoliticalArchetype::Pioneer), + placement(3, (28, 8), PoliticalArchetype::Industrial), + placement(4, (20, 50), PoliticalArchetype::Corporate), + placement(5, (15, 20), PoliticalArchetype::Pioneer), + ]; + let run = || { + build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ) + }; + assert_eq!(run(), run(), "road graph must be deterministic"); + } + + #[test] + fn water_blocks_routing_between_landmasses() { + // Two land bands split by an ocean stripe across the middle rows. + let (w, h) = (64u32, 32u32); + let mut data = vec![0.6f32; (w * h) as usize]; + for r in 14..18 { + for c in 0..w { + data[(r * w + c) as usize] = 0.05; // below sea level → ocean + } + } + let hm = BodyHeightmap { + body_id: "split".into(), + width: w, + height: h, + data, + sea_level: 0.3, + }; + let ta = ta_for(&hm); + let placements = vec![ + placement(1, (5, 10), PoliticalArchetype::Pioneer), // north band + placement(2, (28, 10), PoliticalArchetype::Pioneer), // south band + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + w, + h, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert_eq!(g.edges.len(), 0, "no land route across the ocean stripe"); + } + + #[test] + fn maintenance_authority_from_endpoints() { + let scale = 100.0; + // State endpoint dominates. + assert_eq!( + maintenance_authority( + PoliticalArchetype::Commission, + PoliticalArchetype::Pioneer, + 10.0, + scale, + &TerritorialStatus::FrontierUnclaimed + ), + MaintenanceAuthority::Administrative + ); + // Corporate when no state endpoint. + assert_eq!( + maintenance_authority( + PoliticalArchetype::Corporate, + PoliticalArchetype::Pioneer, + 10.0, + scale, + &TerritorialStatus::FrontierUnclaimed + ), + MaintenanceAuthority::Corporate + ); + // Long non-state haul → Trade. + assert_eq!( + maintenance_authority( + PoliticalArchetype::Pioneer, + PoliticalArchetype::Industrial, + 50.0, + scale, + &TerritorialStatus::FrontierUnclaimed + ), + MaintenanceAuthority::Trade + ); + // Short non-state link → Communal. + assert_eq!( + maintenance_authority( + PoliticalArchetype::Pioneer, + PoliticalArchetype::Industrial, + 5.0, + scale, + &TerritorialStatus::FrontierUnclaimed + ), + MaintenanceAuthority::Communal + ); + // Derelict province abandons all roads. + assert_eq!( + maintenance_authority( + PoliticalArchetype::Commission, + PoliticalArchetype::Commission, + 10.0, + scale, + &TerritorialStatus::Derelict + ), + MaintenanceAuthority::Abandoned + ); + } + + #[test] + fn named_routes_join_longest_first() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + placement(1, (5, 2), PoliticalArchetype::Pioneer), + placement(2, (5, 58), PoliticalArchetype::Pioneer), // far → longest trunk + placement(3, (8, 6), PoliticalArchetype::Pioneer), // near node 1 + ]; + let routes = vec![NamedRoute { + route_id: "split/hwy-1".into(), + is_rail: false, + }]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &routes, + ); + let named: Vec<&RoadEdge> = g + .edges + .iter() + .filter(|e| e.named_route_id.is_some()) + .collect(); + assert_eq!(named.len(), 1, "exactly one route in the pool is assigned"); + // The named one is the longest edge. + let longest = g.edges.iter().map(|e| e.length_cells).max().unwrap(); + assert_eq!(named[0].length_cells, longest); + } + + #[test] + fn waypoint_on_long_edge() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + // Two cities far apart in both axes (so the column wrap can't shortcut + // the route) → routed length > 12 cells → one midpoint waypoint. + let placements = vec![ + placement(1, (4, 8), PoliticalArchetype::Pioneer), + placement(2, (28, 34), PoliticalArchetype::Pioneer), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let waypoints = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Waypoint) + .count(); + assert_eq!(waypoints, 1, "one long edge → one midpoint waypoint"); + let wp = g + .nodes + .iter() + .find(|n| n.kind == RoadNodeKind::Waypoint) + .unwrap(); + assert!(wp.parent_edge.is_some()); + assert!(wp.city_id.is_none()); + } + + #[test] + fn rivers_are_preferred_routes() { + // A river corridor running straight between two cities should be taken + // (½ cost) even though a straight overland path is also available. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let river: Vec<(u16, u16)> = (2..40).map(|c| (16u16, c)).collect(); + let placements = vec![ + placement(1, (16, 3), PoliticalArchetype::Pioneer), + placement(2, (16, 38), PoliticalArchetype::Pioneer), + ]; + let g = build_road_graph( + &placements, + &ta, + &river, + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert_eq!(g.edges.len(), 1); + // The routed path should hug row 16 (the river line), give or take the + // downsample block. + let on_river = g.edges[0].path.iter().filter(|(r, _)| *r == 16).count(); + assert!( + on_river >= g.edges[0].path.len() / 2, + "road should follow the river corridor" + ); + } + + #[test] + fn junction_detection() { + // A hub city wired to several others becomes a high-connectivity junction. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + placement(1, (16, 32), PoliticalArchetype::Pioneer), // central hub + placement(2, (4, 8), PoliticalArchetype::Pioneer), + placement(3, (4, 56), PoliticalArchetype::Pioneer), + placement(4, (28, 8), PoliticalArchetype::Pioneer), + placement(5, (28, 56), PoliticalArchetype::Pioneer), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + // The central node should accumulate degree ≥ JUNCTION_DEGREE. + let junctions = g.high_connectivity_junctions(); + assert!( + !junctions.is_empty(), + "a central hub should be a high-connectivity junction" + ); + } +} diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 1fae8b37c..3778ff419 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -373,7 +373,7 @@ pub enum SettlementClass { /// Dominant power structure of a settlement and its physical spatial expression. /// Derived from TerritorialStatus + economic_role at generation time. /// Source: D-214 -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum PoliticalArchetype { /// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement. Commission, @@ -406,6 +406,29 @@ pub enum FoundingOrientation { Free { bearing_degrees: u16 }, } +/// Who maintains an inter-settlement road/rail edge — readable in the road's +/// condition (Ozzie: "you can tell who controls territory by the state of the +/// roads"). Derived at Layer-2 road generation from the endpoint settlements' +/// political archetypes + the edge's haul length (no separate authoring). +/// Five-value vocabulary frozen by the cascade workshop (workshop-outcomes.md +/// "OQ-R3-4"); see [`crate::atlas::road_graph::maintenance_authority`]. +/// Source: D-211 (Layer-2 transport), D-212 (territorial status input). +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MaintenanceAuthority { + /// Government-built/maintained — a Commission (or strategic Military) endpoint. + Administrative, + /// Corporation-maintained — a Corporate endpoint (e.g. road to a corp mine). + Corporate, + /// Locally maintained short links between neighbouring small settlements. + Communal, + /// Long-haul inter-regional routes between distant non-state settlements. + Trade, + /// Roads that exist but are no longer maintained (a Derelict-province + /// endpoint, or runtime abandonment). Not reachable from the current + /// authored data alone (D-212 Derelict is deferred) — wired for forward use. + Abandoned, +} + /// Territory control status for a province (drainage basin). Priority-ordered derivation. /// Source: D-212 (amended 2026-06-05 — see `AutonomistHeld` + the dominant_faction /// mapping note on the variant docs and in `attractor_matching::territorial_status_from_faction`).