feat(simulation): road-graph hub refinement — scaled-cap hubs, co-location collapse, minor-attach edge split, RailHeadFacing (T-1076)

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<to via norm_edge, else A*-spur to nearest hub; FoundingOrientation::RailHeadFacing{bearing_degrees} assigned at degree>=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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:23:07 +02:00
co-authored by Claude Fable 5
parent a0ebdbe6b8
commit 98991284bf
9 changed files with 993 additions and 63 deletions
+22 -1
View File
@@ -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![
+24 -7
View File
@@ -534,28 +534,45 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
Ok(snapshot.into_body_world_state())
}
/// Read a body's settlements from `atlas_city_names`. Per-city `settlement_class` is
/// unset in the committed pool, so it defaults to `PopulationBudget` (what the
/// city-context read-set assumes).
/// Read a body's settlements from `atlas_city_names`, mirroring
/// `CityContextReader::read_body_settlements` (keep the two in lockstep):
/// baked `settlement_class` is honoured (D-242/T-1075 bakes it on every row;
/// unknown/NULL falls back to `PopulationBudget`), and the T-1076
/// `is_standalone_hq` flag comes from the identical `corporations` LEFT JOIN.
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, 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<String> = 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}"))?
+13
View File
@@ -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();
+105 -20
View File
@@ -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<String>>(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) ────────────────────────────────────────
+5
View File
@@ -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,
+17
View File
@@ -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 {
+750 -31
View File
@@ -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<u64>,
/// 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<usize>,
/// `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<RoadNode> = 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<RoadEdge> = 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<CityPlacement> {
let mut kept: Vec<CityPlacement> = 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<usize> {
let cap = ((body_scale / HUB_SPACING_DIAG_PX) as usize).max(HUB_CAP_MIN);
let mut eligible: Vec<usize> = (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<usize> = 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<RoadNode>,
edges: &mut Vec<RoadEdge>,
placements: &[CityPlacement],
hub_indices: &[usize],
grid: &RouteGrid,
body_scale: f64,
body_status: &TerritorialStatus,
) {
let minor_indices: Vec<usize> = (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<RoadNode>,
edges: &mut Vec<RoadEdge>,
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<RoadNode>, 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<CityPlacement> = 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<CityPlacement> = 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
));
}
}
}
+47 -4
View File
@@ -813,6 +813,25 @@ fn coastal_edge(orientation: &FoundingOrientation) -> Option<Edge> {
}
}
/// 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<Edge> {
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<BuildingPropertyTag>> {
// 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
+10
View File
@@ -410,6 +410,16 @@ pub enum FoundingOrientation {
Cardinal,
/// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0359.
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 (0359, 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