From 98991284bf298068d7ba1bf673eb0244c9114186 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 16 Jul 2026 16:23:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20road-graph=20hub=20refineme?= =?UTF-8?q?nt=20=E2=80=94=20scaled-cap=20hubs,=20co-location=20collapse,?= =?UTF-8?q?=20minor-attach=20edge=20split,=20RailHeadFacing=20(T-1076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_standalone_hq threaded through CityRecord/CityPlacement via corporations LEFT JOIN (both readers); hubs = top-cap by population among non-HQ settlements (HUB_SPACING_DIAG_PX=64, HUB_CAP_MIN=6); exact-name collapse keeping lowest city_id with loud warn; nearest-point-on-polyline snap (SNAP_MAX_PX=8) with Junction edge-split preserving from=3 junctions (octant bearing toward dominant incident edge), consumed by skeleton_gen railhead_edge() through the D-234b flush-frontage machinery; believability reader now honours baked settlement_class (stale hardcode since T-1075). 11 new targeted tests; existing suites green. Co-Authored-By: Claude Fable 5 --- server/src/atlas/attractor_matching.rs | 23 +- server/src/atlas/believability.rs | 31 +- server/src/atlas/cascade.rs | 13 + server/src/atlas/city_context_reader.rs | 125 +++- server/src/atlas/layer_proxy.rs | 5 + server/src/atlas/plugin.rs | 17 + server/src/atlas/road_graph.rs | 781 +++++++++++++++++++++++- server/src/atlas/skeleton_gen.rs | 51 +- server/src/simulation/generator.rs | 10 + 9 files changed, 993 insertions(+), 63 deletions(-) diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index 1b00c7464..aa9abbf9a 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -44,6 +44,16 @@ pub struct CityRecord { /// harness's own settlement read) selects `COALESCE(kind,'city')`, so an /// un-authored `kind` yields `false` — there is no kind-less source left. pub is_capital: bool, + /// `true` if this settlement is a D-242 Standalone corp-HQ company town — + /// its `atlas_city_names` row matches a `corporations` row with + /// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and + /// `proper_name = name` (both readers join it identically). Threaded onto + /// [`CityPlacement`] for the T-1076 road-graph hub rule: standalone HQs + /// are minor nodes regardless of population — "hubs are the significant + /// cities, standalone HQs are typically minor nodes" (D-242). Placement + /// itself (`match_cities`) does NOT read this flag — an HQ competes for + /// attractors on equal terms (the PR #177 invariant tests below hold). + pub is_standalone_hq: bool, } // --------------------------------------------------------------------------- @@ -69,13 +79,18 @@ pub struct CityPlacement { /// Spatial arrangement governing district adjacency (D-215). pub arrangement_pattern: ArrangementPattern, /// Primary street-grid axis (D-213). Derived from the anchoring attractor - /// type; pioneer/open-terrain bearings are seed-varied. + /// type; pioneer/open-terrain bearings are seed-varied. The road-graph + /// pass may override it to `RailHeadFacing` post-hoc (T-1076 §4, + /// `road_graph::assign_railhead_orientations`). pub founding_orientation: FoundingOrientation, /// Carried straight from [`CityRecord::population`] (T-960 §2 — the Atlas /// `SettlementLayer` derives its coarse size class from this). pub population: i64, /// Carried straight from [`CityRecord::is_capital`] (T-960 §2). pub is_capital: bool, + /// Carried straight from [`CityRecord::is_standalone_hq`] (T-1076 §1 — + /// the road-graph hub rule reads it off the placement). + pub is_standalone_hq: bool, } // --------------------------------------------------------------------------- @@ -396,6 +411,7 @@ pub fn match_cities( founding_orientation: orientation, population: cities[ci].population, is_capital: cities[ci].is_capital, + is_standalone_hq: cities[ci].is_standalone_hq, }); } } @@ -466,6 +482,7 @@ pub fn match_cities( founding_orientation: orientation, population: cities[ci].population, is_capital: cities[ci].is_capital, + is_standalone_hq: cities[ci].is_standalone_hq, }); } } @@ -503,6 +520,7 @@ pub fn match_cities( founding_orientation: orientation, population: city.population, is_capital: city.is_capital, + is_standalone_hq: city.is_standalone_hq, }); } @@ -707,6 +725,7 @@ mod tests { population: pop, economic_role: "manufacturing".to_string(), is_capital: false, + is_standalone_hq: false, } } @@ -937,6 +956,7 @@ mod tests { population: 60_000, economic_role: "agricultural".to_string(), is_capital: false, + is_standalone_hq: false, }, CityRecord { city_id: 2, @@ -945,6 +965,7 @@ mod tests { population: 80_000, economic_role: "transit_hub".to_string(), is_capital: false, + is_standalone_hq: false, }, ]; let attractors = vec![ diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 6c754303a..fb6a30cca 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -534,28 +534,45 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result Result, String> { let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?; let mut stmt = conn .prepare( - "SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0), - COALESCE(kind,'city') - FROM atlas_city_names WHERE body_id = ?1 ORDER BY id", + "SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'), + COALESCE(acn.population,0), COALESCE(acn.kind,'city'), + acn.settlement_class, c.corp_id IS NOT NULL + FROM atlas_city_names AS acn + LEFT JOIN corporations AS c + ON c.hq_placement = 'Standalone' + AND c.headquarters_body = acn.body_id + AND c.proper_name = acn.name + WHERE acn.body_id = ?1 ORDER BY acn.id", ) .map_err(|e| format!("prepare city query: {e}"))?; let rows = stmt .query_map([body_id], |r| { let kind: String = r.get(4)?; + let sclass: Option = r.get(5)?; + let settlement_class = match sclass.as_deref() { + Some("NameLocked") => SettlementClass::NameLocked, + Some("EconomicTriggered") => SettlementClass::EconomicTriggered, + Some("OrganicGrowth") => SettlementClass::OrganicGrowth, + // "PopulationBudget", NULL, or unknown → the default class. + _ => SettlementClass::PopulationBudget, + }; Ok(CityRecord { city_id: r.get::<_, i64>(0)? as u64, name: r.get(1)?, - settlement_class: SettlementClass::PopulationBudget, + settlement_class, economic_role: r.get(2)?, population: r.get(3)?, is_capital: kind == "capital", + is_standalone_hq: r.get(6)?, }) }) .map_err(|e| format!("city query: {e}"))? diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 2d14d9220..70c9691c8 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -311,6 +311,16 @@ pub fn run_cascade_from_heightmap( &territorial_status, &[], ); + // RailHeadFacing pass (T-1076 §4, D-213 amended): settlements + // that are high-connectivity junctions (degree ≥ 3) get their + // founding_orientation overridden toward the dominant incident + // edge. Mutates the Layer-3 placements post-hoc — orientation + // is a Layer-3 output, but rail-head facing is only knowable + // once Layer 2 exists. Deterministic: a pure function of the + // (already deterministic) graph. + if let Some(l3) = snapshot.layer3.as_mut() { + road_graph::assign_railhead_orientations(&mut l3.placements, &graph); + } snapshot.road_graph = Some(graph); } } @@ -527,6 +537,7 @@ mod tests { population: 2_000_000, economic_role: "financial".into(), is_capital: true, + is_standalone_hq: false, }, CityRecord { city_id: 2, @@ -535,6 +546,7 @@ mod tests { population: 120_000, economic_role: "agricultural".into(), is_capital: false, + is_standalone_hq: false, }, ]; let run = || { @@ -621,6 +633,7 @@ mod tests { population: *pop, economic_role: "manufacturing".into(), is_capital: false, + is_standalone_hq: false, }) .collect(); diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 0cd2ee12e..0d8d703fb 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -265,6 +265,15 @@ impl CityContextReader { /// Unlike [`read_set`](Self::read_set) (the strict D-199 path that aborts on /// any malformed field), this method is best-effort: it never fails on row /// content, only on a DB/connection error. + /// + /// `is_standalone_hq` (T-1076 §1): a LEFT JOIN against `corporations` + /// marks the rows that are D-242 Standalone corp-HQ company towns + /// (`hq_placement = 'Standalone'`, `headquarters_body = body_id`, + /// `proper_name = name` — the exact shape `populate_standalone_hq_settlements` + /// emits them with). The road-graph hub rule demotes these to minor nodes + /// regardless of population. The believability harness's own settlement + /// read (`believability::read_cities`) performs the identical join — keep + /// the two in lockstep. pub fn read_body_settlements( &self, body_id: &str, @@ -275,11 +284,16 @@ impl CityContextReader { .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; let mut stmt = conn .prepare( - "SELECT id, name, economic_role, population, settlement_class, - COALESCE(kind, 'city') - FROM atlas_city_names - WHERE body_id = ?1 - ORDER BY id", + "SELECT acn.id, acn.name, acn.economic_role, acn.population, + acn.settlement_class, COALESCE(acn.kind, 'city'), + c.corp_id IS NOT NULL + FROM atlas_city_names AS acn + LEFT JOIN corporations AS c + ON c.hq_placement = 'Standalone' + AND c.headquarters_body = acn.body_id + AND c.proper_name = acn.name + WHERE acn.body_id = ?1 + ORDER BY acn.id", ) .map_err(|e| CityContextReadError::Db(e.to_string()))?; let rows = stmt @@ -291,13 +305,14 @@ impl CityContextReader { row.get::<_, i64>(3)?, row.get::<_, Option>(4)?, row.get::<_, String>(5)?, + row.get::<_, bool>(6)?, )) }) .map_err(|e| CityContextReadError::Db(e.to_string()))?; let mut out = Vec::new(); for r in rows { - let (id, name, role, population, sclass, kind) = + let (id, name, role, population, sclass, kind, is_standalone_hq) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?; let city_id = id as u64; let settlement_class = match sclass.as_deref() { @@ -318,6 +333,7 @@ impl CityContextReader { population, economic_role: role.unwrap_or_else(|| "residential".to_string()), is_capital: kind == "capital", + is_standalone_hq, }); } Ok(out) @@ -1039,7 +1055,11 @@ mod tests { // ─── read_body_settlements (#955) ──────────────────────────────────────── /// Build a db with several settlements on one body, returning its path. Some - /// rows have a NULL `settlement_class` (the pre-placement state). + /// rows have a NULL `settlement_class` (the pre-placement state). Includes + /// an EMPTY `corporations` table — `read_body_settlements`' T-1076 + /// standalone-HQ LEFT JOIN references it, so the fixture schema must carry + /// it (empty ⇒ every row reads `is_standalone_hq = false`). Use + /// `add_standalone_corp` to mark one. fn make_settlements_db(rows: &[(&str, &str, i64, Option<&str>)]) -> PathBuf { let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_ctxst_{}_{n}.db", std::process::id())); @@ -1054,6 +1074,12 @@ mod tests { economic_role TEXT, population INTEGER NOT NULL, settlement_class TEXT + ); + CREATE TABLE corporations ( + corp_id TEXT PRIMARY KEY, + proper_name TEXT NOT NULL, + hq_placement TEXT, + headquarters_body TEXT );", ) .expect("create table"); @@ -1069,6 +1095,18 @@ mod tests { path } + /// Register `name` on `body_id` as a D-242 Standalone corp HQ in the + /// fixture's `corporations` table (the shape the T-1076 join matches). + fn add_standalone_corp(db: &PathBuf, corp_id: &str, name: &str, body_id: &str) { + let conn = Connection::open(db).expect("reopen"); + conn.execute( + "INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body) + VALUES (?1, ?2, 'Standalone', ?3)", + rusqlite::params![corp_id, name, body_id], + ) + .expect("insert corp"); + } + #[test] fn read_body_settlements_defaults_null_class_to_population_budget() { // NULL settlement_class is the pre-placement state. It must default to @@ -1154,20 +1192,19 @@ mod tests { ); } - // ─── D-242 corp-HQ settlement model (T-1074/T-1075) ────────────────────── + // ─── D-242 corp-HQ settlement model (T-1074/T-1075/T-1076) ─────────────── // // A Standalone-HQ settlement (economy_import/corporations.py: // populate_standalone_hq_settlements) is inserted into atlas_city_names as - // an ORDINARY row — same schema, same fields, no corp linkage marker (the - // corp<->settlement relationship lives on corporations.headquarters_body, - // not on atlas_city_names). This module's test fixture schema - // (make_settlements_db, above) never had a corp_id column to begin with — - // this test makes that design invariant explicit: read_body_settlements - // (the D-211 placement pipeline's input) cannot distinguish a - // corp-Standalone-HQ row from a wiki-pooled city, by construction. If a - // future change ever needs to special-case corp-originated settlements, - // it will have to be threaded through explicitly — this test fails the - // moment that stops being true silently. + // an ORDINARY row — same schema, same fields, no corp linkage marker on + // the row itself (the corp<->settlement relationship lives on + // corporations.headquarters_body). The T-1074 invariant test below + // predicted that any future special-casing "will have to be threaded + // through explicitly" — T-1076 §1 is exactly that threading: an explicit + // LEFT JOIN against corporations now derives `is_standalone_hq`, consumed + // ONLY by the road-graph hub rule. The D-211 placement fields stay + // identical to an ordinary city's (the equal-terms attractor invariant in + // attractor_matching.rs still holds). #[test] fn read_body_settlements_treats_standalone_hq_row_identically_to_pooled_city() { @@ -1175,8 +1212,11 @@ mod tests { // Standalone-HQ row (name = corp proper_name, economic_role from // corp_hq_placement.toml's standalone_economic_role, population from // the T-1075 Zipf bake, settlement_class from the same bake/override - // path as any other city) — indistinguishable in shape from the + // path as any other city) — identical in D-211 placement shape to the // ordinary pooled cities "Tributarium"/"Ruhr" it sits alongside. + // (Without a corporations row registering it, the T-1076 join also + // leaves is_standalone_hq = false — the flag comes only from the + // corporations side, never from the city row.) let db = make_settlements_db(&[ ( "Tributarium", @@ -1206,11 +1246,56 @@ mod tests { .find(|c| c.name == "Gate Corporation") .expect("Standalone-HQ row must be present"); // Every CityRecord field the D-211 pipeline reads is populated exactly - // like an ordinary city's — nothing marks this row as corp-originated. + // like an ordinary city's. assert_eq!(hq.economic_role, "manufacturing"); assert_eq!(hq.population, 909_090_165); assert_eq!(hq.settlement_class, SettlementClass::NameLocked); assert!(!hq.is_capital, "Standalone HQ is not a capital by default"); + assert!( + !hq.is_standalone_hq, + "no corporations row registers this name — the flag must stay false" + ); + } + + #[test] + fn read_body_settlements_flags_standalone_hq_via_corporations_join() { + // T-1076 §1: the corporations LEFT JOIN marks exactly the rows whose + // (headquarters_body, proper_name) matches a Standalone corp — the + // shape populate_standalone_hq_settlements emits. + let db = make_settlements_db(&[ + ( + "Tributarium", + "manufacturing", + 2_000_000, + Some("PopulationBudget"), + ), + ( + "Gate Corporation", + "manufacturing", + 909_090_165, + Some("PopulationBudget"), + ), + ]); + add_standalone_corp(&db, "gate-corporation", "Gate Corporation", "PlanetX"); + // A Standalone corp on a DIFFERENT body with the same proper_name must + // NOT mark PlanetX's row (the join keys on headquarters_body too). + add_standalone_corp(&db, "other-corp", "Tributarium", "PlanetY"); + + let reader = CityContextReader::open(&db).expect("open"); + let cities = reader.read_body_settlements("PlanetX").expect("read"); + let hq = cities + .iter() + .find(|c| c.name == "Gate Corporation") + .unwrap(); + let pool = cities.iter().find(|c| c.name == "Tributarium").unwrap(); + assert!( + hq.is_standalone_hq, + "registered Standalone corp row is flagged" + ); + assert!( + !pool.is_standalone_hq, + "same-name corp on another body must not leak the flag across bodies" + ); } // ─── read_body_city_names (T-949) ──────────────────────────────────────── diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 621209562..253221afd 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -549,6 +549,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: None, @@ -556,6 +557,7 @@ mod tests { kind: RoadNodeKind::Waypoint, degree: 0, parent_edge: Some(0), + is_hub: false, }, ], edges: vec![RoadEdge { @@ -617,6 +619,7 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population, is_capital, + is_standalone_hq: false, }; let mut state = blank_state("GJ1c"); @@ -696,6 +699,7 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population: 2_000_000, is_capital: true, + is_standalone_hq: false, }]; state.road_graph = RoadGraph { nodes: vec![RoadNode { @@ -704,6 +708,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 0, parent_edge: None, + is_hub: false, }], edges: vec![RoadEdge { from: 0, diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index cce7eb40f..69551e4a0 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -984,6 +984,7 @@ mod tests { founding_orientation: orientation, population: 100_000, is_capital: false, + is_standalone_hq: false, } } @@ -1005,6 +1006,7 @@ mod tests { founding_orientation: orientation, population: 100_000, is_capital: false, + is_standalone_hq: false, } } @@ -1110,6 +1112,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 2, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(90), @@ -1117,6 +1120,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(91), @@ -1124,6 +1128,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, ], edges: vec![ @@ -1460,6 +1465,7 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population: 100_000, is_capital: false, + is_standalone_hq: false, }; assert_eq!( @@ -1592,6 +1598,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(2), @@ -1599,6 +1606,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, ], edges: vec![RoadEdge { @@ -1634,6 +1642,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 2, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(11), @@ -1641,6 +1650,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(12), @@ -1648,6 +1658,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, ], edges: vec![ @@ -1694,6 +1705,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 2, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(2), @@ -1701,6 +1713,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(3), @@ -1708,6 +1721,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, ], edges: vec![ @@ -1756,6 +1770,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, RoadNode { city_id: Some(6), @@ -1763,6 +1778,7 @@ mod tests { kind: RoadNodeKind::Settlement, degree: 1, parent_edge: None, + is_hub: false, }, ], edges: vec![RoadEdge { @@ -1788,6 +1804,7 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population: 100_000, is_capital: false, + is_standalone_hq: false, }; let GenWorkItem::GenerateSkeleton { diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index c9ce7ea31..c90d8c3f2 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -45,12 +45,14 @@ use std::cmp::Reverse; use std::collections::{BTreeSet, BinaryHeap}; use serde::{Deserialize, Serialize}; -use tracing::debug; +use tracing::{debug, warn}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::features::TerrainAnalysis; use crate::atlas::subbiome; -use crate::simulation::generator::{MaintenanceAuthority, PoliticalArchetype, TerritorialStatus}; +use crate::simulation::generator::{ + FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, TerritorialStatus, +}; // --------------------------------------------------------------------------- // Tunables (workshop-anchored) @@ -100,6 +102,28 @@ const LONG_HAUL_FRAC: f64 = 0.30; /// junctions (the candidates a `RailHeadFacing` second pass would target). pub const JUNCTION_DEGREE: u16 = 3; +/// One trunk hub is allowed per this many grid-px of body diagonal (T-1076 §1, +/// D-242 "scaled-cap hubs"). The working planet grid is a fixed 512×256, whose +/// diagonal (including the ×8 routing downsample) is ≈572 px → a cap of ≈8 +/// trunk hubs on a planet; smaller grids (tests, future sub-planet bodies) +/// scale down and are floored by [`HUB_CAP_MIN`]. "Body size" here is the grid +/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other +/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`, +/// `LONG_HAUL_FRAC`); physical radius is not available to this layer. +const HUB_SPACING_DIAG_PX: f64 = 64.0; + +/// Floor for the hub cap: small grids keep at least this many trunk hubs so +/// the trunk stays a real network (an MST of ≥3 nodes) rather than degenerating +/// to hub-and-spoke on every test-sized body. +const HUB_CAP_MIN: usize = 6; + +/// A minor settlement within this many grid-px of an existing road edge snaps +/// onto it (nearest-point-on-polyline; the edge is split at the junction) +/// instead of earning its own spur to a hub (T-1076 §3). Chosen as ~half of +/// D-211's 15-px minimum city spacing: a road already passing within half a +/// city-spacing of a settlement realistically serves it. +const SNAP_MAX_PX: f64 = 8.0; + // --------------------------------------------------------------------------- // Output types // --------------------------------------------------------------------------- @@ -111,21 +135,33 @@ pub enum RoadNodeKind { Settlement, /// A sub-settlement waypoint at the midpoint of a long edge. Waypoint, + /// A topological split point where a minor settlement's spur joins an + /// existing edge (T-1076 §3). Carries no `city_id`. The Atlas client's + /// marker overlay filters to `Settlement` kind and skips these, like + /// waypoints. + Junction, } -/// One node in the road graph — a settlement or a waypoint. +/// One node in the road graph — a settlement, a waypoint, or a spur junction. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoadNode { - /// The placed city's id; `None` for waypoints. + /// The placed city's id; `None` for waypoints/junctions. 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). + /// are the candidates for the `RailHeadFacing` pass (T-1038 §6, T-1076 §4). pub degree: u16, - /// For a waypoint: the edge it sits on. `None` for settlements. + /// For a waypoint: the edge it sits on. `None` for settlements/junctions. pub parent_edge: Option, + /// `true` if this settlement is a trunk hub (T-1076 §1): among the top + /// hub-cap non-standalone-HQ settlements by `(population DESC, city_id + /// ASC)`. The trunk (MST + secondary links) connects hubs only; every + /// other settlement — standalone corp HQs regardless of population, and + /// below-cap cities — is a minor node attached by snap-or-spur (§3). + /// Always `false` for waypoints/junctions. + pub is_hub: bool, } /// One inter-settlement road/rail edge. @@ -180,7 +216,8 @@ impl RoadGraph { // Public entry point // --------------------------------------------------------------------------- -/// Build the inter-settlement road/rail graph for one body (D-211, T-1038). +/// Build the inter-settlement road/rail graph for one body (D-211, T-1038; +/// hub/minor refinement T-1076). /// /// `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 @@ -188,6 +225,25 @@ impl RoadGraph { /// `TerritorialStatus`, D-212) and the endpoint archetypes drive /// `MaintenanceAuthority`. `named_routes` is the `systems.db` named-route pool /// (empty today — D-223). +/// +/// **T-1076 flow:** +/// 0. Co-located duplicates (exact name match — post-D-242 this should never +/// fire) collapse to one node keeping the lowest `city_id`, with a loud +/// warning (§2). +/// 1. Settlements partition into **hubs** — the top hub-cap by `(population +/// DESC, city_id ASC)` among non-standalone-HQ settlements — and +/// **minors** (standalone corp HQs regardless of population, and +/// below-cap cities). The cap scales with the grid diagonal +/// ([`HUB_SPACING_DIAG_PX`], floored by [`HUB_CAP_MIN`]). A body whose +/// settlements are ALL standalone HQs falls back to ranking the HQs +/// themselves (the graph must still connect; an HQ-only body's biggest +/// HQ is its de-facto hub). +/// 2. The trunk (MST + secondary links, A\*-routed) connects hubs only. +/// 3. Each minor then attaches (§3): snapped onto the nearest point of an +/// existing edge if within [`SNAP_MAX_PX`] (the edge is split at a new +/// [`RoadNodeKind::Junction`] node), else A\*-spurred to the nearest hub. +/// Minors attach in node-index order and may snap onto edges created by +/// earlier attachments (roads accrete). pub fn build_road_graph( placements: &[CityPlacement], ta: &TerrainAnalysis, @@ -201,43 +257,55 @@ pub fn build_road_graph( return RoadGraph::default(); } - // Settlement nodes, one per placement, in placement order (deterministic). + // --- (0) co-location collapse (T-1076 §2) ------------------------------- + let placements = collapse_colocated(placements); + + // --- hub / minor partition (T-1076 §1) ---------------------------------- + // Nodes stay in placement order (deterministic); hubs are flagged. + let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h); + let body_scale = grid.body_scale(); + let hub_indices = select_hubs(&placements, body_scale); + let mut nodes: Vec = placements .iter() - .map(|p| RoadNode { + .enumerate() + .map(|(i, p)| RoadNode { city_id: Some(p.city_id), position: p.position, kind: RoadNodeKind::Settlement, degree: 0, parent_edge: None, + is_hub: hub_indices.contains(&i), }) .collect(); - let n = nodes.len(); // Single city — nothing to connect. - if n == 1 { + if nodes.len() == 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); + // --- (1) trunk MST over HUB Euclidean distances (strict Kruskal) -------- + let hub_positions: Vec<(f64, f64)> = hub_indices.iter().map(|&i| positions[i]).collect(); + let mst_local = minimum_spanning_tree(&hub_positions); + let mst_pairs: Vec<(usize, usize)> = mst_local + .iter() + .map(|&(a, b)| (hub_indices[a], hub_indices[b])) + .collect(); + + // --- (2) secondary links among hubs: long MST detours in the band ------- + let tree_dist = all_pairs_tree_distance(nodes.len(), &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 { + for (a, &i) in hub_indices.iter().enumerate() { + for &j in hub_indices.iter().skip(a + 1) { + let (i, j) = if i < j { (i, j) } else { (j, i) }; let euclid = euclid(positions[i], positions[j]); if euclid < SECONDARY_MIN_FRAC * body_scale || euclid > SECONDARY_MAX_FRAC * body_scale { @@ -250,7 +318,7 @@ pub fn build_road_graph( } } - // --- (3) A* route every wanted edge; drop unroutable pairs (oceans) ---- + // --- (3) A* route every trunk edge; drop unroutable pairs (oceans) ------ let mut edges: Vec = Vec::new(); let mut dropped = 0usize; for (i, j) in wanted { @@ -268,7 +336,7 @@ pub fn build_road_graph( body_scale, body_status, ); - edges.push(RoadEdge { + edges.push(norm_edge(RoadEdge { from: i, to: j, path, @@ -276,31 +344,397 @@ pub fn build_road_graph( 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)" + "road_graph: some hub 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) minor-settlement attach: snap-to-edge or spur-to-hub (§3) ------ + attach_minors( + &mut nodes, + &mut edges, + &placements, + &hub_indices, + &grid, + body_scale, + body_status, + ); + + // --- degrees recomputed from the final edge list ------------------------ + for node in nodes.iter_mut() { + node.degree = 0; + } + let incident: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect(); + for (f, t) in incident { + nodes[f].degree += 1; + nodes[t].degree += 1; } - // --- (4) named-route identity join: trunk (longest) edges first -------- + // --- (5) named-route identity join: trunk (longest) edges first --------- assign_named_routes(&mut edges, named_routes); - // --- (5) waypoints at midpoints of long edges -------------------------- + // --- (6) waypoints at midpoints of long edges --------------------------- add_waypoints(&mut nodes, &edges); RoadGraph { nodes, edges } } +/// T-1076 §2 — collapse exact-name duplicate placements to one graph node, +/// keeping the lowest `city_id`. Post-D-242 the pool has no duplicate +/// `(body_id, name)` groups (verified on every regen), so this should never +/// fire — when it does, it means upstream data regressed, hence the loud +/// warning rather than a silent dedupe. +fn collapse_colocated(placements: &[CityPlacement]) -> Vec { + let mut kept: Vec = Vec::with_capacity(placements.len()); + for p in placements { + if let Some(prev) = kept.iter().find(|k| k.name == p.name) { + warn!( + name = %p.name, + kept_city_id = prev.city_id, + dropped_city_id = p.city_id, + "road_graph: co-located duplicate settlement collapsed — \ + duplicate (body, name) groups should not exist post-D-242" + ); + continue; + } + kept.push(p.clone()); + } + // Keep-lowest-id: placements arrive in `atlas_city_names.id` order from + // the readers, so first-seen == lowest city_id. Guard the assumption: + // if a lower id appears later (caller-reordered input), swap it in. + for p in placements { + if let Some(slot) = kept + .iter_mut() + .find(|k| k.name == p.name && p.city_id < k.city_id) + { + *slot = p.clone(); + } + } + kept +} + +/// T-1076 §1 — the trunk-hub index set: top hub-cap settlements by +/// `(population DESC, city_id ASC)` among non-standalone-HQ placements. +/// Returns indices into `placements`, sorted ascending. Falls back to ranking +/// ALL placements when every settlement on the body is a standalone HQ. +fn select_hubs(placements: &[CityPlacement], body_scale: f64) -> Vec { + let cap = ((body_scale / HUB_SPACING_DIAG_PX) as usize).max(HUB_CAP_MIN); + let mut eligible: Vec = (0..placements.len()) + .filter(|&i| !placements[i].is_standalone_hq) + .collect(); + if eligible.is_empty() { + // HQ-only body: the graph must still connect — rank the HQs. + eligible = (0..placements.len()).collect(); + } + eligible.sort_by(|&a, &b| { + placements[b] + .population + .cmp(&placements[a].population) + .then(placements[a].city_id.cmp(&placements[b].city_id)) + }); + let mut chosen: Vec = eligible.into_iter().take(cap).collect(); + chosen.sort_unstable(); + chosen +} + +/// Normalize an edge to the `from < to` invariant, reversing the path when the +/// endpoints swap (the path always runs `nodes[from] → nodes[to]`). +fn norm_edge(mut e: RoadEdge) -> RoadEdge { + if e.from > e.to { + std::mem::swap(&mut e.from, &mut e.to); + e.path.reverse(); + } + e +} + +/// T-1076 §3 — attach every minor settlement (non-hub node) to the network: +/// snap onto the nearest point of an existing edge when within +/// [`SNAP_MAX_PX`] (splitting that edge at a new [`RoadNodeKind::Junction`] +/// node), else A\*-spur to the nearest hub. Minors attach in ascending node +/// order; each attachment's new edges are visible to later minors (roads +/// accrete deterministically). +#[allow(clippy::too_many_arguments)] +fn attach_minors( + nodes: &mut Vec, + edges: &mut Vec, + placements: &[CityPlacement], + hub_indices: &[usize], + grid: &RouteGrid, + body_scale: f64, + body_status: &TerritorialStatus, +) { + let minor_indices: Vec = (0..placements.len()) + .filter(|i| !hub_indices.contains(i)) + .collect(); + + for &mi in &minor_indices { + let mpos = nodes[mi].position; + let march = placements[mi].political_archetype; + + // --- nearest point on any existing edge polyline -------------------- + let mut best: Option<(f64, usize, usize, (u16, u16))> = None; // (dist², edge, seg, proj) + for (ei, e) in edges.iter().enumerate() { + for si in 0..e.path.len().saturating_sub(1) { + let (d2, proj) = project_onto_segment(mpos, e.path[si], e.path[si + 1]); + // Strictly-less keeps the first-found (lowest edge/segment + // index) on exact ties — deterministic given fixed iteration. + if best.is_none() || d2 < best.unwrap().0 { + best = Some((d2, ei, si, proj)); + } + } + } + + if let Some((d2, ei, si, proj)) = best { + if d2 <= SNAP_MAX_PX * SNAP_MAX_PX { + // Snap: junction at the projection — unless it lands exactly on + // an existing endpoint node, in which case attach there (no + // degenerate zero-length split halves). + let attach_node = if proj == edges[ei].path[0] { + edges[ei].from + } else if proj == *edges[ei].path.last().unwrap() { + edges[ei].to + } else { + split_edge_at(nodes, edges, ei, si, proj) + }; + let apos = nodes[attach_node].position; + let spur_len = euclid( + (apos.0 as f64, apos.1 as f64), + (mpos.0 as f64, mpos.1 as f64), + ); + // A snapped spur is ≤ SNAP_MAX_PX — a straight local road, not + // worth an A* run. Maintenance is credited to the minor it + // serves (a junction has no archetype of its own). + let maintenance = + maintenance_authority(march, march, spur_len, body_scale, body_status); + edges.push(norm_edge(RoadEdge { + from: attach_node, + to: mi, + path: vec![apos, mpos], + length_cells: 0, + maintenance, + named_route_id: None, + is_rail: false, + })); + continue; + } + } + + // --- no snap: A*-spur to the nearest hub ---------------------------- + let mut best_hub: Option<(i64, usize)> = None; + for &hi in hub_indices { + if hi == mi { + continue; + } + let hpos = nodes[hi].position; + let dr = hpos.0 as i64 - mpos.0 as i64; + let dc = hpos.1 as i64 - mpos.1 as i64; + let d2 = dr * dr + dc * dc; + if best_hub.is_none() || d2 < best_hub.unwrap().0 { + best_hub = Some((d2, hi)); + } + } + let Some((_, hi)) = best_hub else { + continue; // no hubs at all (single-node graphs return earlier) + }; + let hpos = nodes[hi].position; + let mposf = (mpos.0 as f64, mpos.1 as f64); + let hposf = (hpos.0 as f64, hpos.1 as f64); + let Some((route_cells, length_cells)) = grid.astar(mposf, hposf) else { + debug!( + minor = mi, + hub = hi, + "road_graph: minor settlement unroutable to its nearest hub (water) — left isolated" + ); + continue; + }; + let path = grid.to_grid_path(mpos, hpos, &route_cells); + let maintenance = maintenance_authority( + march, + placements[hi].political_archetype, + euclid(mposf, hposf), + body_scale, + body_status, + ); + edges.push(norm_edge(RoadEdge { + from: mi, + to: hi, + path, + length_cells, + maintenance, + named_route_id: None, + is_rail: false, + })); + } +} + +/// Project grid point `p` onto the segment `a→b` (f64, clamped to the segment). +/// Returns `(squared distance, projected point rounded to grid coords)`. +fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u16, u16)) { + let (pr, pc) = (p.0 as f64, p.1 as f64); + let (ar, ac) = (a.0 as f64, a.1 as f64); + let (br, bc) = (b.0 as f64, b.1 as f64); + let (dr, dc) = (br - ar, bc - ac); + let len2 = dr * dr + dc * dc; + let t = if len2 == 0.0 { + 0.0 + } else { + (((pr - ar) * dr + (pc - ac) * dc) / len2).clamp(0.0, 1.0) + }; + let (jr, jc) = (ar + t * dr, ac + t * dc); + let d2 = (pr - jr) * (pr - jr) + (pc - jc) * (pc - jc); + (d2, (jr.round() as u16, jc.round() as u16)) +} + +/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`] +/// node replaces the single edge with two halves meeting at the junction. +/// Both halves inherit the parent's maintenance/named-route identity; +/// `length_cells` splits proportionally by polyline vertex count (a routed- +/// length proxy — the halves' true routed lengths are not re-measured). +/// Returns the junction's node index. Caller guarantees `jpos` is not an +/// endpoint of the edge's path (guarded at the call site). +fn split_edge_at( + nodes: &mut Vec, + edges: &mut Vec, + ei: usize, + si: usize, + jpos: (u16, u16), +) -> usize { + let jn = nodes.len(); + nodes.push(RoadNode { + city_id: None, + position: jpos, + kind: RoadNodeKind::Junction, + degree: 0, + parent_edge: None, + is_hub: false, + }); + + let old = edges[ei].clone(); + let mut path1: Vec<(u16, u16)> = old.path[..=si].to_vec(); + if path1.last() != Some(&jpos) { + path1.push(jpos); + } + let mut path2: Vec<(u16, u16)> = vec![jpos]; + if old.path[si + 1..].first() == Some(&jpos) { + path2.extend_from_slice(&old.path[si + 2..]); + } else { + path2.extend_from_slice(&old.path[si + 1..]); + } + + let total_segs = (old.path.len() - 1).max(1) as u32; + let l1 = old.length_cells * (path1.len().saturating_sub(1) as u32) / total_segs; + let l2 = old.length_cells - l1; + + edges[ei] = norm_edge(RoadEdge { + from: old.from, + to: jn, + path: path1, + length_cells: l1, + maintenance: old.maintenance, + named_route_id: old.named_route_id.clone(), + is_rail: old.is_rail, + }); + edges.push(norm_edge(RoadEdge { + from: jn, + to: old.to, + path: path2, + length_cells: l2, + maintenance: old.maintenance, + named_route_id: old.named_route_id, + is_rail: old.is_rail, + })); + jn +} + +// --------------------------------------------------------------------------- +// RailHeadFacing assignment (T-1076 §4, D-213 amended) +// --------------------------------------------------------------------------- + +/// Octant-snapped compass bearing (0 = N, clockwise, one of +/// {0, 45, 90, 135, 180, 225, 270, 315}) from `from` toward `to` in grid +/// coordinates (rows grow south, columns grow east). Integer-only (D-010): +/// the diagonal band is `|minor| * 2 > |major|` (sector boundaries at +/// ≈26.6°/63.4° instead of the exact 22.5°/67.5° — a deliberate integer +/// approximation; the consumer snaps to quarter-edges anyway, so the +/// half-octant boundary shift never changes a rendered outcome class). +fn octant_bearing(from: (u16, u16), to: (u16, u16)) -> u16 { + let dr = to.0 as i64 - from.0 as i64; // + = south + let dc = to.1 as i64 - from.1 as i64; // + = east + if dr == 0 && dc == 0 { + return 0; + } + let (adr, adc) = (dr.abs(), dc.abs()); + let diagonal = adr.min(adc) * 2 > adr.max(adc); + match (diagonal, dr.signum(), dc.signum()) { + (false, _, _) if adr >= adc => { + if dr < 0 { + 0 // N + } else { + 180 // S + } + } + (false, _, _) => { + if dc > 0 { + 90 // E + } else { + 270 // W + } + } + (true, r, c) => match (r < 0, c > 0) { + (true, true) => 45, // NE + (false, true) => 135, // SE + (false, false) => 225, // SW + (true, false) => 315, // NW + }, + } +} + +/// T-1076 §4 — override `founding_orientation` to +/// [`FoundingOrientation::RailHeadFacing`] for every settlement that is a +/// high-connectivity junction (`degree ≥` [`JUNCTION_DEGREE`], the D-213 +/// workshop criterion — paula-round3.md). The bearing faces the junction's +/// **dominant incident edge** — longest `length_cells`, ties broken by lowest +/// edge index — toward that edge's other endpoint, octant-snapped +/// ([`octant_bearing`]). Runs AFTER [`build_road_graph`] in the cascade +/// (orientation is a Layer-3 output, but rail-head facing is knowable only +/// once Layer 2 exists); the Layer-4 skeleton picks the override up at +/// dispatch (`plugin.rs` copies `placement.founding_orientation` into the +/// generation context). +pub fn assign_railhead_orientations(placements: &mut [CityPlacement], graph: &RoadGraph) { + for idx in graph.high_connectivity_junctions() { + let node = &graph.nodes[idx]; + let Some(city_id) = node.city_id else { + continue; // settlement junctions always carry a city_id + }; + // Dominant incident edge: longest; first (lowest index) on ties. + let mut dominant: Option<(u32, usize)> = None; + for (ei, e) in graph.edges.iter().enumerate() { + if e.from != idx && e.to != idx { + continue; + } + if dominant.is_none() || e.length_cells > dominant.unwrap().0 { + dominant = Some((e.length_cells, ei)); + } + } + let Some((_, ei)) = dominant else { + continue; // degree ≥ 3 guarantees incident edges; defensive + }; + let e = &graph.edges[ei]; + let other = if e.from == idx { e.to } else { e.from }; + let bearing = octant_bearing(node.position, graph.nodes[other].position); + if let Some(p) = placements.iter_mut().find(|p| p.city_id == city_id) { + p.founding_orientation = FoundingOrientation::RailHeadFacing { + bearing_degrees: bearing, + }; + } + } +} + // --------------------------------------------------------------------------- // MaintenanceAuthority derivation (workshop OQ-R3-4) // --------------------------------------------------------------------------- @@ -373,6 +807,7 @@ fn add_waypoints(nodes: &mut Vec, edges: &[RoadEdge]) { kind: RoadNodeKind::Waypoint, degree: 0, parent_edge: Some(ei), + is_hub: false, }); } } @@ -749,9 +1184,18 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population: 100_000, is_capital: false, + is_standalone_hq: false, } } + /// `placement` variant marked as a D-242 standalone corp HQ (T-1076 §1). + fn hq_placement(city_id: u64, pos: (u16, u16), population: i64) -> CityPlacement { + let mut p = placement(city_id, pos, PoliticalArchetype::Corporate); + p.population = population; + p.is_standalone_hq = true; + p + } + #[test] fn empty_input_empty_graph() { let hm = flat_hm(64, 32); @@ -1065,4 +1509,279 @@ mod tests { "a central hub should be a high-connectivity junction" ); } + + // ─── T-1076 §1 — hub selection excludes standalone HQs ────────────────── + + #[test] + fn standalone_hq_is_minor_regardless_of_population() { + // Gate-Corporation shape: an HQ with a HUGE population must still be a + // minor node — hubs are the significant CITIES (D-242). The two small + // ordinary cities are the hubs; the HQ attaches with a single edge. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + hq_placement(1, (16, 48), 909_090_165), // massive HQ, far from the cities + placement(2, (8, 8), PoliticalArchetype::Pioneer), // pop 100k + placement(3, (24, 8), PoliticalArchetype::Pioneer), // pop 100k + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let hq = g.nodes.iter().find(|n| n.city_id == Some(1)).unwrap(); + assert!(!hq.is_hub, "standalone HQ must never be a trunk hub"); + assert_eq!(hq.degree, 1, "the HQ hangs off the network by one spur"); + for n in g.nodes.iter().filter(|n| matches!(n.city_id, Some(2 | 3))) { + assert!(n.is_hub, "ordinary cities are the hubs"); + } + } + + #[test] + fn hq_only_body_falls_back_to_hq_hubs() { + // A body whose settlements are ALL standalone HQs still gets a + // connected trunk (the HQs are its de-facto hubs). + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + hq_placement(1, (8, 8), 500_000), + hq_placement(2, (24, 40), 200_000), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert!( + g.nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .all(|n| n.is_hub), + "fallback: HQs become hubs (waypoints are never hubs)" + ); + assert_eq!(g.edges.len(), 1, "two hubs → one trunk edge"); + } + + // ─── T-1076 §2 — co-location collapse ──────────────────────────────────── + + #[test] + fn colocated_duplicates_collapse_to_lowest_id() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + // Two placements with the SAME name (the post-D-242 impossible case) + + // one distinct city. The duplicate collapses to the lowest city_id. + let mut dup_hi = placement(7, (10, 10), PoliticalArchetype::Pioneer); + dup_hi.name = "Twinned".into(); + let mut dup_lo = placement(2, (12, 12), PoliticalArchetype::Pioneer); + dup_lo.name = "Twinned".into(); + let other = placement(3, (24, 40), PoliticalArchetype::Pioneer); + let g = build_road_graph( + &[dup_hi, dup_lo, other], + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let settlements: Vec<&RoadNode> = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .collect(); + assert_eq!(settlements.len(), 2, "duplicate collapsed to one node"); + assert!( + settlements.iter().any(|n| n.city_id == Some(2)), + "the LOWEST city_id survives the collapse" + ); + assert!( + settlements.iter().all(|n| n.city_id != Some(7)), + "the higher-id duplicate is dropped" + ); + } + + // ─── T-1076 §3 — hybrid minor-settlement attach ────────────────────────── + + #[test] + fn minor_snaps_onto_nearby_trunk_edge_with_split() { + // Trunk between two far-apart hubs runs roughly along a row; a minor + // (7th settlement, beyond the test-grid hub cap of 6) sits within + // SNAP_MAX_PX of it → the edge splits at a Junction and the minor + // spurs to it. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements: Vec = vec![ + placement(1, (16, 4), PoliticalArchetype::Pioneer), + placement(2, (16, 60), PoliticalArchetype::Pioneer), + placement(3, (4, 4), PoliticalArchetype::Pioneer), + placement(4, (4, 60), PoliticalArchetype::Pioneer), + placement(5, (28, 4), PoliticalArchetype::Pioneer), + placement(6, (28, 60), PoliticalArchetype::Pioneer), + ]; + // Give the six hubs clear population dominance; the 7th is below-cap. + for p in placements.iter_mut() { + p.population = 1_000_000; + } + let mut minor = placement(7, (13, 32), PoliticalArchetype::Pioneer); + minor.population = 10_000; // below the six → minor by cap + placements.push(minor); + + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let minor_node = g.nodes.iter().find(|n| n.city_id == Some(7)).unwrap(); + assert!(!minor_node.is_hub, "7th settlement is beyond the hub cap"); + let junctions: Vec<&RoadNode> = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Junction) + .collect(); + assert_eq!( + junctions.len(), + 1, + "the minor within snap range splits exactly one edge" + ); + // Invariants hold for every edge, including the split halves + spur. + for e in &g.edges { + assert!(e.from < e.to, "from < to must hold after splits"); + assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap()); + assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap()); + } + // The junction carries trunk halves + the spur → degree 3. + let ji = g + .nodes + .iter() + .position(|n| n.kind == RoadNodeKind::Junction) + .unwrap(); + assert_eq!(g.nodes[ji].degree, 3, "two split halves + one spur"); + } + + #[test] + fn minor_far_from_edges_spurs_to_nearest_hub() { + // 7 settlements; the 7th (below-cap minor) sits far from every trunk + // edge → it gets an A*-routed spur to its nearest hub, no junction. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements: Vec = vec![ + placement(1, (4, 4), PoliticalArchetype::Pioneer), + placement(2, (4, 30), PoliticalArchetype::Pioneer), + placement(3, (4, 56), PoliticalArchetype::Pioneer), + placement(4, (12, 4), PoliticalArchetype::Pioneer), + placement(5, (12, 30), PoliticalArchetype::Pioneer), + placement(6, (12, 56), PoliticalArchetype::Pioneer), + ]; + for p in placements.iter_mut() { + p.population = 1_000_000; + } + let mut minor = placement(7, (30, 30), PoliticalArchetype::Pioneer); + minor.population = 10_000; + placements.push(minor); + + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert!( + g.nodes.iter().all(|n| n.kind != RoadNodeKind::Junction), + "far minor must not split any edge" + ); + let mi = g.nodes.iter().position(|n| n.city_id == Some(7)).unwrap(); + let spur = g + .edges + .iter() + .find(|e| e.from == mi || e.to == mi) + .expect("minor must be connected by a spur"); + let other = if spur.from == mi { spur.to } else { spur.from }; + assert!(g.nodes[other].is_hub, "the spur lands on a hub"); + // Nearest hub to (30,30) is node 5 at (12,30). + assert_eq!(g.nodes[other].city_id, Some(5)); + } + + // ─── T-1076 §4 — RailHeadFacing assignment ─────────────────────────────── + + #[test] + fn octant_bearing_snaps_to_compass_octants() { + // Rows grow south, columns grow east; 0 = N, clockwise. + assert_eq!(octant_bearing((10, 10), (0, 10)), 0); // due north + assert_eq!(octant_bearing((10, 10), (0, 20)), 45); // north-east + assert_eq!(octant_bearing((10, 10), (10, 20)), 90); // due east + assert_eq!(octant_bearing((10, 10), (20, 20)), 135); // south-east + assert_eq!(octant_bearing((10, 10), (20, 10)), 180); // due south + assert_eq!(octant_bearing((10, 10), (20, 0)), 225); // south-west + assert_eq!(octant_bearing((10, 10), (10, 0)), 270); // due west + assert_eq!(octant_bearing((10, 10), (0, 0)), 315); // north-west + assert_eq!(octant_bearing((10, 10), (10, 10)), 0); // degenerate + } + + #[test] + fn railhead_orientation_assigned_at_high_connectivity_junctions() { + // The junction_detection topology: a central hub wired to 4 others. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut 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, + &[], + ); + let junctions = g.high_connectivity_junctions(); + assert!(!junctions.is_empty(), "central hub is a junction"); + + assign_railhead_orientations(&mut placements, &g); + for &ji in &junctions { + let cid = g.nodes[ji].city_id.unwrap(); + let p = placements.iter().find(|p| p.city_id == cid).unwrap(); + assert!( + matches!( + p.founding_orientation, + FoundingOrientation::RailHeadFacing { .. } + ), + "junction settlement {cid} gets RailHeadFacing" + ); + if let FoundingOrientation::RailHeadFacing { bearing_degrees } = p.founding_orientation + { + assert!(bearing_degrees < 360 && bearing_degrees % 45 == 0); + } + } + // Non-junction settlements keep their attractor-derived orientation. + for p in placements.iter().filter(|p| { + !junctions + .iter() + .any(|&ji| g.nodes[ji].city_id == Some(p.city_id)) + }) { + assert!(matches!( + p.founding_orientation, + FoundingOrientation::Cardinal + )); + } + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index b90577673..610c15f96 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -813,6 +813,25 @@ fn coastal_edge(orientation: &FoundingOrientation) -> Option { } } +/// The quarter edge facing the settlement's rail head, from a +/// `RailHeadFacing` founding orientation (T-1076 §4, D-213 amended). Same +/// octant→cardinal snap as [`coastal_edge`]: the bearing points toward the +/// dominant incident road/rail edge, and the blocks on that quarter edge +/// present flush freight frontage to it — the rail-head analogue of the +/// D-234b waterfront quay rule, reusing the same flush-margin machinery. +fn railhead_edge(orientation: &FoundingOrientation) -> Option { + if let FoundingOrientation::RailHeadFacing { bearing_degrees } = orientation { + Some(match bearing_degrees % 360 { + d if !(45..315).contains(&d) => Edge::North, + d if d < 135 => Edge::East, + d if d < 225 => Edge::South, + _ => Edge::West, + }) + } else { + None + } +} + /// Whether block `(row, col)` sits on the quarter's `edge` (4×4 grid). fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool { match edge { @@ -1029,15 +1048,19 @@ pub fn assign_all_block_tags( chain: SeedChain, exterior_catalog: &ExteriorCatalog, ) -> BTreeMap<(u8, u8), Vec> { - // Water-facing quarter edge from the settlement's coastal founding - // orientation (D-234b); blocks on it present flush to the quay. - let water_edge = coastal_edge(&context.founding_orientation); + // Flush-frontage quarter edge: water-facing from a coastal founding + // orientation (D-234b — blocks present flush to the quay), or rail-facing + // from a RailHeadFacing orientation (T-1076 §4 — blocks present flush + // freight frontage to the rail head; same machinery). The two are + // mutually exclusive by construction (one orientation per settlement). + let flush_edge = coastal_edge(&context.founding_orientation) + .or_else(|| railhead_edge(&context.founding_orientation)); let mut map = BTreeMap::new(); for row in 0..4u8 { for col in 0..4u8 { let block = &skeleton.blocks[row as usize][col as usize]; let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64); - let waterfront = water_edge.filter(|&e| block_on_quarter_edge(row, col, e)); + let waterfront = flush_edge.filter(|&e| block_on_quarter_edge(row, col, e)); let tags = assign_block_tags( block, skeleton, @@ -2303,6 +2326,26 @@ mod tests { assert_eq!(coastal_edge(&FoundingOrientation::Cardinal), None); } + #[test] + fn railhead_edge_maps_bearing_to_cardinal() { + // T-1076 §4: same octant→cardinal snap as the coastal quay rule, keyed + // on RailHeadFacing. Non-rail orientations yield no rail edge, and a + // coastal orientation is not a rail edge (the two flush-frontage + // sources are distinct variants). + let r = |d| railhead_edge(&FoundingOrientation::RailHeadFacing { bearing_degrees: d }); + assert_eq!(r(0), Some(Edge::North)); + assert_eq!(r(45), Some(Edge::East)); + assert_eq!(r(90), Some(Edge::East)); + assert_eq!(r(180), Some(Edge::South)); + assert_eq!(r(270), Some(Edge::West)); + assert_eq!(r(315), Some(Edge::North)); + assert_eq!(railhead_edge(&FoundingOrientation::Cardinal), None); + assert_eq!( + railhead_edge(&FoundingOrientation::Coastal { facing_degrees: 0 }), + None + ); + } + #[test] fn waterfront_footprints_present_to_the_quay() { // The water-facing (north) edge drops its setback → buildings sit flush diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index f496cf0a4..4e694fa87 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -410,6 +410,16 @@ pub enum FoundingOrientation { Cardinal, /// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359. Free { bearing_degrees: u16 }, + /// Street grid faces the settlement's rail head (D-213 amended, T-1076 §4). + /// Assigned POST-placement by the Layer-2 road-graph pass + /// (`road_graph::assign_railhead_orientations`) — never by the attractor + /// match — to settlements that are high-connectivity junctions + /// (`RoadGraph::high_connectivity_junctions`, degree ≥ 3, per the D-213 + /// workshop source paula-round3.md). `bearing_degrees`: octant-snapped + /// compass bearing (0–359, 0 = N, clockwise; integer octants only, D-010) + /// from the settlement toward the dominant (longest) incident road/rail + /// edge — the direction the freight frontage faces. + RailHeadFacing { bearing_degrees: u16 }, } /// Who maintains an inter-settlement road/rail edge — readable in the road's