H1: split_edge_at now splits length_cells geometric-distance proportionally (fixed-order f64 polyline sums, halves sum exactly to parent) — subsumes the 2-point-parent all-or-nothing bug. H2: chained-snap test (minor onto minor spur) + determinism assertion actually exercising attach_minors + direct 2-point split unit test. H3: cascade-level RailHeadFacing end-to-end assertion on a plus-shaped landmass fixture (2 deterministic degree-3 settlement junctions). H4+T2: STANDALONE_HQ_JOIN_SQL shared constant, both readers compose it; believability lockstep unit test. T1: HUB_SPACING_DIAG_PX doc cross-refs D-243 elastic seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1952 lines
75 KiB
Rust
1952 lines
75 KiB
Rust
//! Layer 2 — inter-settlement road/rail graph (D-211, T-1038).
|
||
//!
|
||
//! The transport topology drawn onto the world *between* settlements: the
|
||
//! workshopped-but-never-built link from settlement placement (Layer 3,
|
||
//! [`crate::atlas::attractor_matching`]) to everything that consumes transport
|
||
//! topology (road-entry octants, TerritorialStatus road-coverage thresholds,
|
||
//! hinterland fill, Atlas overlays).
|
||
//!
|
||
//! **Algorithm** (fully decided — `docs/workshops/planet-down-cascade/tyre-round1.md`
|
||
//! §"Road graph generation", `round-1-notes.md` Layer 2):
|
||
//! 1. **MST force-connect** all city placements (Euclidean), Kruskal over a
|
||
//! strict `(dist², i, j)` order so the tree is unique and deterministic.
|
||
//! 2. **Secondary links**: city pairs whose straight-line distance is within
|
||
//! `[30%, 60%]` of body scale *and* whose MST-tree path exceeds `1.5×` the
|
||
//! straight line get a direct road (cuts long detours).
|
||
//! 3. **A\*** routes each edge over the L1 terrain-cost field — `ocean`/`lake`
|
||
//! impassable, river corridors preferred (½ cost, rivers as valley routes),
|
||
//! steep cells (`slope ≥ 35°` ≈ D-210 roughness 0.7) penalised 10×, all
|
||
//! other land at its D-210 `terrain_modification_cost`. Routing runs on a
|
||
//! coarse ~64-wide regional grid (the workshop's "2 048 cells is trivial").
|
||
//! 4. **MaintenanceAuthority** per edge from endpoint political archetypes +
|
||
//! haul length ([`maintenance_authority`]).
|
||
//! 5. **Waypoint** sub-settlement nodes at the midpoints of long edges.
|
||
//! 6. **Named-route identity**: trunk edges join `systems.db` named routes via
|
||
//! an optional `named_route_id` (the pool is supplied by the caller; it is
|
||
//! empty today — `atlas_roads`/`atlas_railroads` carry no rows post-D-223 —
|
||
//! so the join is a designed-for no-op until that pool is repopulated).
|
||
//!
|
||
//! **Storage:** the result lives on `BodyWorldState` (D-203), session-cached,
|
||
//! **never serialized** — it is re-derived from `(placements, terrain, seed)` on
|
||
//! resume. The `atlas_road_nodes`/`atlas_road_edges` schema shape carries forward
|
||
//! (tyre-round1.md) but the data is runtime Rust, not committed to systems.db.
|
||
//!
|
||
//! **Determinism (D-010):** no RNG. MST is a strictly-ordered Kruskal, A* breaks
|
||
//! ties by cell index, waypoints are geometric midpoints. f64 distance math is
|
||
//! correctly-rounded (IEEE-754) and summed in a fixed order, so the output is
|
||
//! bit-reproducible.
|
||
//!
|
||
//! Coordinates: every stored position/polyline is in the **working heightmap
|
||
//! grid** (`grid_w × grid_h`, the same space as Layer-1 rivers/attractors and
|
||
//! Layer-3 placements), so downstream Atlas overlays need no rescaling. A\*
|
||
//! routes on an internal coarse grid and the path is upscaled back.
|
||
|
||
use std::cmp::Reverse;
|
||
use std::collections::{BTreeSet, BinaryHeap};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use tracing::{debug, warn};
|
||
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::atlas::features::TerrainAnalysis;
|
||
use crate::atlas::subbiome;
|
||
use crate::simulation::generator::{
|
||
FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, TerritorialStatus,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tunables (workshop-anchored)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Target width of the internal A\* routing grid (the workshop's ~64×32 regional
|
||
/// grid — "A\* on 2 048 cells is trivial"). The working heightmap (512×256) is
|
||
/// downsampled to ≤ this width for routing; paths are upscaled back to grid space.
|
||
const ROUTE_W: u32 = 64;
|
||
|
||
/// Movement-cost multipliers (integer, D-010). A step into a cell costs
|
||
/// `cell_cost × ORTHO` (orthogonal) or `cell_cost × DIAG` (diagonal); 3/2 ≈ √2.
|
||
const ORTHO: u32 = 2;
|
||
const DIAG: u32 = 3;
|
||
|
||
/// Per-cell A\* costs (percent of grassland baseline; flat grassland = 100, the
|
||
/// D-210 `terrain_modification_cost` of `TemperateGrassland`).
|
||
const RIVER_COST: u32 = 50; // 0.5× — rivers are preferred valley routes.
|
||
const MOUNTAIN_COST: u32 = 1000; // 10× — D-210 roughness > 0.7 (atan(0.7) ≈ 35°).
|
||
const IMPASSABLE: u32 = u32::MAX; // ocean / lake — no road.
|
||
|
||
/// `slope_deg` (= `atan(max |Δelev|)`, [`TerrainAnalysis`]) at/above which a cell
|
||
/// is treated as mountain. `atan(0.7) ≈ 34.99°`, i.e. the workshop's roughness 0.7.
|
||
const MOUNTAIN_SLOPE_DEG: f32 = 35.0;
|
||
|
||
/// Cheapest possible cell cost — the A\* heuristic's per-step lower bound. Must be
|
||
/// `≤` every traversable `cell_cost` for the heuristic to stay admissible.
|
||
const MIN_CELL_COST: u32 = RIVER_COST;
|
||
|
||
/// A routed edge longer than this (in routing cells) earns a midpoint waypoint
|
||
/// (tyre-round1.md: "edge length > ~12 regional cells").
|
||
const WAYPOINT_THRESHOLD_CELLS: u32 = 12;
|
||
|
||
/// Secondary-link candidate band: straight-line distance within `[30%, 60%]` of
|
||
/// body scale (round-1-notes.md).
|
||
const SECONDARY_MIN_FRAC: f64 = 0.30;
|
||
const SECONDARY_MAX_FRAC: f64 = 0.60;
|
||
/// A secondary link is added when the MST-tree path exceeds this multiple of the
|
||
/// straight line ("MST path exceeds 1.5× Euclidean").
|
||
const SECONDARY_DETOUR: f64 = 1.5;
|
||
|
||
/// A non-state edge longer than this fraction of body scale is a `Trade` haul;
|
||
/// shorter ones between neighbours are `Communal`.
|
||
const LONG_HAUL_FRAC: f64 = 0.30;
|
||
|
||
/// Settlement nodes with at least this many incident edges are high-connectivity
|
||
/// junctions (the candidates a `RailHeadFacing` second pass would target).
|
||
pub const JUNCTION_DEGREE: u16 = 3;
|
||
|
||
/// 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.
|
||
/// Fixed-grid premise: revisit when [D-243]'s elastic planetary seam gives
|
||
/// bodies varying grid sizes (`round(2πR / 204.8 km)` regions) — the diagonal
|
||
/// then genuinely scales with `body_radius_km` and this constant starts doing
|
||
/// real per-body work (PR #178 T1).
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// What a [`RoadNode`] represents.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum RoadNodeKind {
|
||
/// A placed city (carries its `city_id`).
|
||
Settlement,
|
||
/// A sub-settlement waypoint at the midpoint of a long edge.
|
||
Waypoint,
|
||
/// 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, a waypoint, or a spur junction.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadNode {
|
||
/// 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 the `RailHeadFacing` pass (T-1038 §6, T-1076 §4).
|
||
pub degree: u16,
|
||
/// 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.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadEdge {
|
||
/// `nodes` index of the endpoint settlements (`from < to` by construction).
|
||
pub from: usize,
|
||
pub to: usize,
|
||
/// Routed polyline in working-heightmap-grid coordinates `(row, col)`,
|
||
/// endpoints snapped to the exact settlement positions.
|
||
pub path: Vec<(u16, u16)>,
|
||
/// Routed length in routing-grid cells (drives the waypoint threshold and is
|
||
/// a cheap display proxy for path length).
|
||
pub length_cells: u32,
|
||
pub maintenance: MaintenanceAuthority,
|
||
/// Joined `systems.db` named-route id, if this trunk edge was assigned one.
|
||
/// `None` until the named-route pool is repopulated (D-223 emptied it).
|
||
pub named_route_id: Option<String>,
|
||
/// `true` if the joined named route is a railroad; `false` (a road) otherwise.
|
||
pub is_rail: bool,
|
||
}
|
||
|
||
/// A named transport route from `systems.db` (`atlas_roads` / `atlas_railroads`),
|
||
/// the pool the post-generation identity join draws from. Empty today (D-223).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct NamedRoute {
|
||
pub route_id: String,
|
||
pub is_rail: bool,
|
||
}
|
||
|
||
/// The inter-settlement transport graph for one body (D-211, T-1038).
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadGraph {
|
||
pub nodes: Vec<RoadNode>,
|
||
pub edges: Vec<RoadEdge>,
|
||
}
|
||
|
||
impl RoadGraph {
|
||
/// Settlement node indices with `degree ≥ JUNCTION_DEGREE` — high-connectivity
|
||
/// junctions (the `RailHeadFacing` candidates, T-1038 §6).
|
||
pub fn high_connectivity_junctions(&self) -> Vec<usize> {
|
||
self.nodes
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, n)| n.kind == RoadNodeKind::Settlement && n.degree >= JUNCTION_DEGREE)
|
||
.map(|(i, _)| i)
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Build the inter-settlement road/rail graph for one body (D-211, T-1038;
|
||
/// 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
|
||
/// coords) used as preferred valley routes. `body_status` (the body's uniform
|
||
/// `TerritorialStatus`, D-212) and the endpoint archetypes drive
|
||
/// `MaintenanceAuthority`. `named_routes` is the `systems.db` named-route pool
|
||
/// (empty today — D-223).
|
||
///
|
||
/// **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,
|
||
river_cells: &[(u16, u16)],
|
||
grid_w: u32,
|
||
grid_h: u32,
|
||
body_status: &TerritorialStatus,
|
||
named_routes: &[NamedRoute],
|
||
) -> RoadGraph {
|
||
if placements.is_empty() || grid_w == 0 || grid_h == 0 {
|
||
return RoadGraph::default();
|
||
}
|
||
|
||
// --- (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()
|
||
.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();
|
||
|
||
// Single city — nothing to connect.
|
||
if nodes.len() == 1 {
|
||
return RoadGraph {
|
||
nodes,
|
||
edges: Vec::new(),
|
||
};
|
||
}
|
||
|
||
let positions: Vec<(f64, f64)> = nodes
|
||
.iter()
|
||
.map(|node| (node.position.0 as f64, node.position.1 as f64))
|
||
.collect();
|
||
|
||
// --- (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 (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
|
||
{
|
||
continue;
|
||
}
|
||
let td = tree_dist[i][j];
|
||
if td.is_finite() && td > SECONDARY_DETOUR * euclid {
|
||
wanted.insert((i, j));
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- (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 {
|
||
let Some((route_cells, length_cells)) = grid.astar(positions[i], positions[j]) else {
|
||
dropped += 1;
|
||
continue;
|
||
};
|
||
let path = grid.to_grid_path(nodes[i].position, nodes[j].position, &route_cells);
|
||
let archetype_i = placements[i].political_archetype;
|
||
let archetype_j = placements[j].political_archetype;
|
||
let maintenance = maintenance_authority(
|
||
archetype_i,
|
||
archetype_j,
|
||
euclid(positions[i], positions[j]),
|
||
body_scale,
|
||
body_status,
|
||
);
|
||
edges.push(norm_edge(RoadEdge {
|
||
from: i,
|
||
to: j,
|
||
path,
|
||
length_cells,
|
||
maintenance,
|
||
named_route_id: None,
|
||
is_rail: false,
|
||
}));
|
||
}
|
||
if dropped > 0 {
|
||
debug!(
|
||
dropped,
|
||
kept = edges.len(),
|
||
"road_graph: some hub pairs are unroutable by land (separated by water)"
|
||
);
|
||
}
|
||
|
||
// --- (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;
|
||
}
|
||
|
||
// --- (5) named-route identity join: trunk (longest) edges first ---------
|
||
assign_named_routes(&mut edges, named_routes);
|
||
|
||
// --- (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))
|
||
}
|
||
|
||
/// Geometric length of a polyline in grid px — Euclidean segment lengths
|
||
/// summed in path order (fixed order + correctly-rounded f64 ops, so the sum
|
||
/// is bit-reproducible per the module's determinism doctrine).
|
||
fn polyline_len(path: &[(u16, u16)]) -> f64 {
|
||
let mut total = 0.0;
|
||
for w in path.windows(2) {
|
||
total += euclid(
|
||
(w[0].0 as f64, w[0].1 as f64),
|
||
(w[1].0 as f64, w[1].1 as f64),
|
||
);
|
||
}
|
||
total
|
||
}
|
||
|
||
/// 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 **geometric-distance proportionally** — each half
|
||
/// gets the parent's routed length scaled by its polyline's share of the
|
||
/// total geometry (PR #178 H1: the earlier vertex-count proxy went
|
||
/// all-or-nothing on 2-point parents — `l1 = full, l2 = 0` wherever the
|
||
/// junction fell — and 2-point parents are routine: every snap spur is one,
|
||
/// so chained snaps always hit it). The halves always sum exactly to the
|
||
/// parent's `length_cells` (l2 is the remainder), and an interior junction
|
||
/// on a parent with `length_cells > 0` gives both halves a non-zero share
|
||
/// whenever its geometry does. 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 (len1, len2) = (polyline_len(&path1), polyline_len(&path2));
|
||
let total = len1 + len2;
|
||
let l1 = if total > 0.0 {
|
||
((old.length_cells as f64 * len1 / total).round() as u32).min(old.length_cells)
|
||
} else {
|
||
0 // fully degenerate geometry (all points coincide) — nothing to apportion
|
||
};
|
||
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)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Derive an edge's [`MaintenanceAuthority`] from its endpoint political
|
||
/// archetypes + haul length (tyre-round1.md step 4; ozzie-round1.md "read who
|
||
/// controls territory by the state of the roads").
|
||
///
|
||
/// Priority: a Derelict province abandons all roads; else a state endpoint
|
||
/// (Commission or strategic Military) → `Administrative`; else a Corporate
|
||
/// endpoint → `Corporate`; else (purely Pioneer/Industrial/Academic) a long
|
||
/// inter-regional haul → `Trade`, a short neighbour link → `Communal`.
|
||
pub fn maintenance_authority(
|
||
a: PoliticalArchetype,
|
||
b: PoliticalArchetype,
|
||
euclid_dist: f64,
|
||
body_scale: f64,
|
||
body_status: &TerritorialStatus,
|
||
) -> MaintenanceAuthority {
|
||
if matches!(body_status, TerritorialStatus::Derelict) {
|
||
return MaintenanceAuthority::Abandoned;
|
||
}
|
||
let has = |k: PoliticalArchetype| a == k || b == k;
|
||
if has(PoliticalArchetype::Commission) || has(PoliticalArchetype::Military) {
|
||
MaintenanceAuthority::Administrative
|
||
} else if has(PoliticalArchetype::Corporate) {
|
||
MaintenanceAuthority::Corporate
|
||
} else if euclid_dist > LONG_HAUL_FRAC * body_scale {
|
||
MaintenanceAuthority::Trade
|
||
} else {
|
||
MaintenanceAuthority::Communal
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Named-route join + waypoints
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Assign named-route identities to trunk edges (longest first; ties by edge
|
||
/// index for determinism), one per available route until the pool is exhausted.
|
||
fn assign_named_routes(edges: &mut [RoadEdge], named_routes: &[NamedRoute]) {
|
||
if named_routes.is_empty() {
|
||
return;
|
||
}
|
||
let mut order: Vec<usize> = (0..edges.len()).collect();
|
||
order.sort_by(|&x, &y| {
|
||
edges[y]
|
||
.length_cells
|
||
.cmp(&edges[x].length_cells)
|
||
.then(x.cmp(&y))
|
||
});
|
||
for (route, &ei) in named_routes.iter().zip(order.iter()) {
|
||
edges[ei].named_route_id = Some(route.route_id.clone());
|
||
edges[ei].is_rail = route.is_rail;
|
||
}
|
||
}
|
||
|
||
/// Append a waypoint node at the geometric midpoint of every edge whose routed
|
||
/// length exceeds [`WAYPOINT_THRESHOLD_CELLS`] (tyre-round1.md). Waypoints are
|
||
/// points-of-interest on the through-edge, not topology splits.
|
||
fn add_waypoints(nodes: &mut Vec<RoadNode>, edges: &[RoadEdge]) {
|
||
for (ei, e) in edges.iter().enumerate() {
|
||
if e.length_cells <= WAYPOINT_THRESHOLD_CELLS || e.path.len() < 2 {
|
||
continue;
|
||
}
|
||
let mid = e.path[e.path.len() / 2];
|
||
nodes.push(RoadNode {
|
||
city_id: None,
|
||
position: mid,
|
||
kind: RoadNodeKind::Waypoint,
|
||
degree: 0,
|
||
parent_edge: Some(ei),
|
||
is_hub: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Geometry helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[inline]
|
||
fn euclid(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||
let dr = a.0 - b.0;
|
||
let dc = a.1 - b.1;
|
||
(dr * dr + dc * dc).sqrt()
|
||
}
|
||
|
||
/// Kruskal MST over the complete graph of `positions`, edges ordered by
|
||
/// `(squared distance, i, j)` so the tree is unique and platform-independent
|
||
/// (integer squared-distance comparisons — no float in the connectivity choice).
|
||
/// Returns the chosen `(i, j)` pairs with `i < j`.
|
||
fn minimum_spanning_tree(positions: &[(f64, f64)]) -> Vec<(usize, usize)> {
|
||
let n = positions.len();
|
||
let mut candidates: Vec<(i64, usize, usize)> = Vec::with_capacity(n * (n - 1) / 2);
|
||
for i in 0..n {
|
||
for j in (i + 1)..n {
|
||
let dr = (positions[i].0 - positions[j].0) as i64;
|
||
let dc = (positions[i].1 - positions[j].1) as i64;
|
||
candidates.push((dr * dr + dc * dc, i, j));
|
||
}
|
||
}
|
||
candidates.sort_unstable();
|
||
|
||
let mut uf = UnionFind::new(n);
|
||
let mut tree = Vec::with_capacity(n - 1);
|
||
for (_, i, j) in candidates {
|
||
if uf.union(i, j) {
|
||
tree.push((i, j));
|
||
if tree.len() == n - 1 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
tree
|
||
}
|
||
|
||
/// All-pairs shortest-path distance over the MST tree (BFS from each node along
|
||
/// tree edges, summing Euclidean edge lengths). `f64::INFINITY` if disconnected
|
||
/// (cannot happen for a spanning tree, but guarded).
|
||
fn all_pairs_tree_distance(
|
||
n: usize,
|
||
tree: &[(usize, usize)],
|
||
positions: &[(f64, f64)],
|
||
) -> Vec<Vec<f64>> {
|
||
let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
|
||
for &(i, j) in tree {
|
||
let d = euclid(positions[i], positions[j]);
|
||
adj[i].push((j, d));
|
||
adj[j].push((i, d));
|
||
}
|
||
let mut dist = vec![vec![f64::INFINITY; n]; n];
|
||
// BFS from each source `s` along tree edges; each source only ever writes its
|
||
// own row, so iterate the rows mutably (clippy: needless_range_loop).
|
||
for (s, row) in dist.iter_mut().enumerate() {
|
||
row[s] = 0.0;
|
||
let mut stack = vec![s];
|
||
while let Some(u) = stack.pop() {
|
||
for &(v, d) in &adj[u] {
|
||
if row[v] == f64::INFINITY {
|
||
row[v] = row[u] + d;
|
||
stack.push(v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
dist
|
||
}
|
||
|
||
/// Union-Find with path compression + union by size (deterministic).
|
||
struct UnionFind {
|
||
parent: Vec<usize>,
|
||
size: Vec<usize>,
|
||
}
|
||
|
||
impl UnionFind {
|
||
fn new(n: usize) -> Self {
|
||
Self {
|
||
parent: (0..n).collect(),
|
||
size: vec![1; n],
|
||
}
|
||
}
|
||
fn find(&mut self, x: usize) -> usize {
|
||
let mut r = x;
|
||
while self.parent[r] != r {
|
||
r = self.parent[r];
|
||
}
|
||
// Path compression.
|
||
let mut cur = x;
|
||
while self.parent[cur] != r {
|
||
let next = self.parent[cur];
|
||
self.parent[cur] = r;
|
||
cur = next;
|
||
}
|
||
r
|
||
}
|
||
/// Returns `true` if the two sets were merged (were previously disjoint).
|
||
fn union(&mut self, a: usize, b: usize) -> bool {
|
||
let (ra, rb) = (self.find(a), self.find(b));
|
||
if ra == rb {
|
||
return false;
|
||
}
|
||
let (big, small) = if self.size[ra] >= self.size[rb] {
|
||
(ra, rb)
|
||
} else {
|
||
(rb, ra)
|
||
};
|
||
self.parent[small] = big;
|
||
self.size[big] += self.size[small];
|
||
true
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Routing grid + A*
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Coarse routing grid: a downsampled terrain-cost field for A\* (the workshop's
|
||
/// ~64×32 regional grid). Built once per body from the Layer-1 `TerrainAnalysis`.
|
||
struct RouteGrid {
|
||
rw: usize,
|
||
rh: usize,
|
||
/// Native-grid cells per routing cell (downsample factor, ≥ 1).
|
||
scale: usize,
|
||
grid_w: u32,
|
||
grid_h: u32,
|
||
/// Per-routing-cell movement cost; [`IMPASSABLE`] for ocean/lake.
|
||
cost: Vec<u32>,
|
||
}
|
||
|
||
impl RouteGrid {
|
||
fn build(ta: &TerrainAnalysis, river_cells: &[(u16, u16)], grid_w: u32, grid_h: u32) -> Self {
|
||
let scale = (grid_w.div_ceil(ROUTE_W)).max(1) as usize;
|
||
let rw = (grid_w as usize).div_ceil(scale).max(1);
|
||
let rh = (grid_h as usize).div_ceil(scale).max(1);
|
||
let nw = grid_w as usize;
|
||
let nh = grid_h as usize;
|
||
|
||
// Aggregate the native TerrainAnalysis into routing cells.
|
||
let mut water_count = vec![0u32; rw * rh];
|
||
let mut total_count = vec![0u32; rw * rh];
|
||
let mut max_slope = vec![0.0f32; rw * rh];
|
||
for nr in 0..nh.min(ta.h) {
|
||
for nc in 0..nw.min(ta.w) {
|
||
let ri = (nr / scale) * rw + (nc / scale);
|
||
let i = nr * ta.w + nc;
|
||
total_count[ri] += 1;
|
||
if ta.ocean_mask[i] || ta.lake_mask[i] {
|
||
water_count[ri] += 1;
|
||
}
|
||
if ta.slope_deg[i] > max_slope[ri] {
|
||
max_slope[ri] = ta.slope_deg[i];
|
||
}
|
||
}
|
||
}
|
||
|
||
// River corridors: project river cells onto routing cells.
|
||
let mut is_river = vec![false; rw * rh];
|
||
for &(r, c) in river_cells {
|
||
let (r, c) = (r as usize, c as usize);
|
||
if r < nh && c < nw {
|
||
is_river[(r / scale) * rw + (c / scale)] = true;
|
||
}
|
||
}
|
||
|
||
// Final per-cell cost: water (majority) impassable > river ½ > mountain
|
||
// 10× > D-210 terrain_modification_cost at the block centre.
|
||
let mut cost = vec![100u32; rw * rh];
|
||
for rr in 0..rh {
|
||
for rc in 0..rw {
|
||
let ri = rr * rw + rc;
|
||
cost[ri] = if total_count[ri] == 0 || water_count[ri] * 2 > total_count[ri] {
|
||
IMPASSABLE
|
||
} else if is_river[ri] {
|
||
RIVER_COST
|
||
} else if max_slope[ri] >= MOUNTAIN_SLOPE_DEG {
|
||
MOUNTAIN_COST
|
||
} else {
|
||
let cr = ((rr * scale) + scale / 2).min(ta.h.saturating_sub(1));
|
||
let cc = ((rc * scale) + scale / 2).min(ta.w.saturating_sub(1));
|
||
let (_, base) = subbiome::classify(ta, cr, cc);
|
||
base.max(1) as u32
|
||
};
|
||
}
|
||
}
|
||
|
||
Self {
|
||
rw,
|
||
rh,
|
||
scale,
|
||
grid_w,
|
||
grid_h,
|
||
cost,
|
||
}
|
||
}
|
||
|
||
/// Body scale in routing cells — the grid diagonal (max sensible distance).
|
||
fn body_scale(&self) -> f64 {
|
||
((self.rw * self.rw + self.rh * self.rh) as f64).sqrt() * self.scale as f64
|
||
}
|
||
|
||
/// Project a grid-space `(row, col)` to a routing-cell index, clamped.
|
||
fn to_route_cell(&self, pos: (f64, f64)) -> (usize, usize) {
|
||
let r = ((pos.0 as usize) / self.scale).min(self.rh - 1);
|
||
let c = ((pos.1 as usize) / self.scale).min(self.rw - 1);
|
||
(r, c)
|
||
}
|
||
|
||
/// A\* from `start` to `goal` (grid-space coords) over the cost field. Columns
|
||
/// wrap (equirectangular); rows clamp. Returns the routing-cell path and its
|
||
/// length in routing cells, or `None` if unroutable (no land path).
|
||
fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> {
|
||
let (sr, sc) = self.to_route_cell(start);
|
||
let (gr, gc) = self.to_route_cell(goal);
|
||
let s = sr * self.rw + sc;
|
||
let g = gr * self.rw + gc;
|
||
if self.cost[s] == IMPASSABLE || self.cost[g] == IMPASSABLE {
|
||
return None;
|
||
}
|
||
if s == g {
|
||
return Some((vec![(sr, sc)], 0));
|
||
}
|
||
|
||
let n = self.rw * self.rh;
|
||
let mut gscore = vec![u32::MAX; n];
|
||
let mut came: Vec<usize> = vec![usize::MAX; n];
|
||
gscore[s] = 0;
|
||
// Min-heap on (f, cell-index) — index tie-break keeps A* deterministic.
|
||
let mut open: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::new();
|
||
open.push(Reverse((self.heuristic(s, g), s)));
|
||
|
||
while let Some(Reverse((_, cur))) = open.pop() {
|
||
if cur == g {
|
||
return Some(self.reconstruct(&came, g));
|
||
}
|
||
let cr = cur / self.rw;
|
||
let cc = cur % self.rw;
|
||
let gc_cur = gscore[cur];
|
||
for (nr, nc, diagonal) in self.neighbors(cr, cc) {
|
||
let ni = nr * self.rw + nc;
|
||
let cell = self.cost[ni];
|
||
if cell == IMPASSABLE {
|
||
continue;
|
||
}
|
||
let step = cell.saturating_mul(if diagonal { DIAG } else { ORTHO });
|
||
let tentative = gc_cur.saturating_add(step);
|
||
if tentative < gscore[ni] {
|
||
gscore[ni] = tentative;
|
||
came[ni] = cur;
|
||
let f = tentative.saturating_add(self.heuristic(ni, g));
|
||
open.push(Reverse((f, ni)));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 8-neighbours of `(r, c)`; columns wrap, rows clamp. Yields `(nr, nc, diagonal)`.
|
||
fn neighbors(&self, r: usize, c: usize) -> impl Iterator<Item = (usize, usize, bool)> + '_ {
|
||
const STEPS: [(i32, i32); 8] = [
|
||
(-1, 0),
|
||
(1, 0),
|
||
(0, -1),
|
||
(0, 1),
|
||
(-1, -1),
|
||
(-1, 1),
|
||
(1, -1),
|
||
(1, 1),
|
||
];
|
||
let (rh, rw) = (self.rh, self.rw);
|
||
STEPS.iter().filter_map(move |&(dr, dc)| {
|
||
let nr = r as i32 + dr;
|
||
if nr < 0 || nr >= rh as i32 {
|
||
return None;
|
||
}
|
||
let nc = (c as i32 + dc).rem_euclid(rw as i32) as usize;
|
||
Some((nr as usize, nc, dr != 0 && dc != 0))
|
||
})
|
||
}
|
||
|
||
/// Admissible octile heuristic in cost units (lower bound: cheapest cell cost
|
||
/// on the fewest possible diagonal+orthogonal steps). Columns wrap.
|
||
fn heuristic(&self, a: usize, g: usize) -> u32 {
|
||
let (ar, ac) = (a / self.rw, a % self.rw);
|
||
let (gr, gc) = (g / self.rw, g % self.rw);
|
||
let dr = ar.abs_diff(gr) as u32;
|
||
let raw = ac.abs_diff(gc) as u32;
|
||
let dc = raw.min(self.rw as u32 - raw); // wrapped column distance
|
||
let diag = dr.min(dc);
|
||
let straight = dr.max(dc) - diag;
|
||
diag * DIAG * MIN_CELL_COST + straight * ORTHO * MIN_CELL_COST
|
||
}
|
||
|
||
fn reconstruct(&self, came: &[usize], goal: usize) -> (Vec<(usize, usize)>, u32) {
|
||
let mut cells = Vec::new();
|
||
let mut cur = goal;
|
||
while cur != usize::MAX {
|
||
cells.push((cur / self.rw, cur % self.rw));
|
||
cur = came[cur];
|
||
}
|
||
cells.reverse();
|
||
let len = cells.len().saturating_sub(1) as u32;
|
||
(cells, len)
|
||
}
|
||
|
||
/// Upscale a routing-cell path to grid coords, snapping the endpoints to the
|
||
/// exact settlement positions and dropping consecutive duplicates.
|
||
fn to_grid_path(
|
||
&self,
|
||
start: (u16, u16),
|
||
end: (u16, u16),
|
||
route_cells: &[(usize, usize)],
|
||
) -> Vec<(u16, u16)> {
|
||
let mut path: Vec<(u16, u16)> = Vec::with_capacity(route_cells.len() + 2);
|
||
path.push(start);
|
||
for &(rr, rc) in route_cells {
|
||
let gr = ((rr * self.scale) + self.scale / 2).min(self.grid_h as usize - 1) as u16;
|
||
let gc = ((rc * self.scale) + self.scale / 2).min(self.grid_w as usize - 1) as u16;
|
||
if path.last() != Some(&(gr, gc)) {
|
||
path.push((gr, gc));
|
||
}
|
||
}
|
||
if path.last() != Some(&end) {
|
||
path.push(end);
|
||
}
|
||
path
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::BodyHeightmap;
|
||
use crate::simulation::generator::{ArrangementPattern, AttractorType, FoundingOrientation};
|
||
|
||
/// A flat all-land heightmap (no water, no mountains) so A* always connects.
|
||
fn flat_hm(w: u32, h: u32) -> BodyHeightmap {
|
||
BodyHeightmap {
|
||
body_id: "road_test".into(),
|
||
width: w,
|
||
height: h,
|
||
data: vec![0.6; (w * h) as usize],
|
||
sea_level: 0.3,
|
||
}
|
||
}
|
||
|
||
fn ta_for(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
TerrainAnalysis::analyze(hm, &dr)
|
||
}
|
||
|
||
fn placement(city_id: u64, pos: (u16, u16), archetype: PoliticalArchetype) -> CityPlacement {
|
||
CityPlacement {
|
||
city_id,
|
||
name: format!("City{city_id}"),
|
||
position: pos,
|
||
attractor_type: AttractorType::PlainCenter,
|
||
score: 1000,
|
||
synthetic: false,
|
||
political_archetype: archetype,
|
||
arrangement_pattern: ArrangementPattern::RibbonDevelopment,
|
||
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);
|
||
let ta = ta_for(&hm);
|
||
let g = build_road_graph(
|
||
&[],
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
assert!(g.nodes.is_empty() && g.edges.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn single_city_no_edges() {
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
let g = build_road_graph(
|
||
&[placement(1, (10, 10), PoliticalArchetype::Pioneer)],
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
assert_eq!(g.nodes.len(), 1);
|
||
assert!(g.edges.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mst_connects_n_cities_with_n_minus_1_edges() {
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
// Four spread-out cities on flat land — MST gives exactly 3 edges, and on
|
||
// open terrain no long detours, so no secondary links.
|
||
let placements = vec![
|
||
placement(1, (5, 5), PoliticalArchetype::Pioneer),
|
||
placement(2, (5, 25), PoliticalArchetype::Pioneer),
|
||
placement(3, (25, 5), PoliticalArchetype::Pioneer),
|
||
placement(4, (25, 25), PoliticalArchetype::Pioneer),
|
||
];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
let settlements = g
|
||
.nodes
|
||
.iter()
|
||
.filter(|n| n.kind == RoadNodeKind::Settlement)
|
||
.count();
|
||
assert_eq!(settlements, 4);
|
||
assert_eq!(g.edges.len(), 3, "MST of 4 nodes has 3 edges");
|
||
// Every edge endpoint resolves to a settlement node, path snaps to it.
|
||
for e in &g.edges {
|
||
assert!(e.from < e.to);
|
||
assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap());
|
||
assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn deterministic_same_inputs_same_graph() {
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
let placements = vec![
|
||
placement(1, (4, 4), PoliticalArchetype::Commission),
|
||
placement(2, (8, 30), PoliticalArchetype::Pioneer),
|
||
placement(3, (28, 8), PoliticalArchetype::Industrial),
|
||
placement(4, (20, 50), PoliticalArchetype::Corporate),
|
||
placement(5, (15, 20), PoliticalArchetype::Pioneer),
|
||
];
|
||
let run = || {
|
||
build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
)
|
||
};
|
||
assert_eq!(run(), run(), "road graph must be deterministic");
|
||
}
|
||
|
||
#[test]
|
||
fn water_blocks_routing_between_landmasses() {
|
||
// Two land bands split by an ocean stripe across the middle rows.
|
||
let (w, h) = (64u32, 32u32);
|
||
let mut data = vec![0.6f32; (w * h) as usize];
|
||
for r in 14..18 {
|
||
for c in 0..w {
|
||
data[(r * w + c) as usize] = 0.05; // below sea level → ocean
|
||
}
|
||
}
|
||
let hm = BodyHeightmap {
|
||
body_id: "split".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.3,
|
||
};
|
||
let ta = ta_for(&hm);
|
||
let placements = vec![
|
||
placement(1, (5, 10), PoliticalArchetype::Pioneer), // north band
|
||
placement(2, (28, 10), PoliticalArchetype::Pioneer), // south band
|
||
];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
w,
|
||
h,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
assert_eq!(g.edges.len(), 0, "no land route across the ocean stripe");
|
||
}
|
||
|
||
#[test]
|
||
fn maintenance_authority_from_endpoints() {
|
||
let scale = 100.0;
|
||
// State endpoint dominates.
|
||
assert_eq!(
|
||
maintenance_authority(
|
||
PoliticalArchetype::Commission,
|
||
PoliticalArchetype::Pioneer,
|
||
10.0,
|
||
scale,
|
||
&TerritorialStatus::FrontierUnclaimed
|
||
),
|
||
MaintenanceAuthority::Administrative
|
||
);
|
||
// Corporate when no state endpoint.
|
||
assert_eq!(
|
||
maintenance_authority(
|
||
PoliticalArchetype::Corporate,
|
||
PoliticalArchetype::Pioneer,
|
||
10.0,
|
||
scale,
|
||
&TerritorialStatus::FrontierUnclaimed
|
||
),
|
||
MaintenanceAuthority::Corporate
|
||
);
|
||
// Long non-state haul → Trade.
|
||
assert_eq!(
|
||
maintenance_authority(
|
||
PoliticalArchetype::Pioneer,
|
||
PoliticalArchetype::Industrial,
|
||
50.0,
|
||
scale,
|
||
&TerritorialStatus::FrontierUnclaimed
|
||
),
|
||
MaintenanceAuthority::Trade
|
||
);
|
||
// Short non-state link → Communal.
|
||
assert_eq!(
|
||
maintenance_authority(
|
||
PoliticalArchetype::Pioneer,
|
||
PoliticalArchetype::Industrial,
|
||
5.0,
|
||
scale,
|
||
&TerritorialStatus::FrontierUnclaimed
|
||
),
|
||
MaintenanceAuthority::Communal
|
||
);
|
||
// Derelict province abandons all roads.
|
||
assert_eq!(
|
||
maintenance_authority(
|
||
PoliticalArchetype::Commission,
|
||
PoliticalArchetype::Commission,
|
||
10.0,
|
||
scale,
|
||
&TerritorialStatus::Derelict
|
||
),
|
||
MaintenanceAuthority::Abandoned
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn named_routes_join_longest_first() {
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
let placements = vec![
|
||
placement(1, (5, 2), PoliticalArchetype::Pioneer),
|
||
placement(2, (5, 58), PoliticalArchetype::Pioneer), // far → longest trunk
|
||
placement(3, (8, 6), PoliticalArchetype::Pioneer), // near node 1
|
||
];
|
||
let routes = vec![NamedRoute {
|
||
route_id: "split/hwy-1".into(),
|
||
is_rail: false,
|
||
}];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&routes,
|
||
);
|
||
let named: Vec<&RoadEdge> = g
|
||
.edges
|
||
.iter()
|
||
.filter(|e| e.named_route_id.is_some())
|
||
.collect();
|
||
assert_eq!(named.len(), 1, "exactly one route in the pool is assigned");
|
||
// The named one is the longest edge.
|
||
let longest = g.edges.iter().map(|e| e.length_cells).max().unwrap();
|
||
assert_eq!(named[0].length_cells, longest);
|
||
}
|
||
|
||
#[test]
|
||
fn waypoint_on_long_edge() {
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
// Two cities far apart in both axes (so the column wrap can't shortcut
|
||
// the route) → routed length > 12 cells → one midpoint waypoint.
|
||
let placements = vec![
|
||
placement(1, (4, 8), PoliticalArchetype::Pioneer),
|
||
placement(2, (28, 34), PoliticalArchetype::Pioneer),
|
||
];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
let waypoints = g
|
||
.nodes
|
||
.iter()
|
||
.filter(|n| n.kind == RoadNodeKind::Waypoint)
|
||
.count();
|
||
assert_eq!(waypoints, 1, "one long edge → one midpoint waypoint");
|
||
let wp = g
|
||
.nodes
|
||
.iter()
|
||
.find(|n| n.kind == RoadNodeKind::Waypoint)
|
||
.unwrap();
|
||
assert!(wp.parent_edge.is_some());
|
||
assert!(wp.city_id.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rivers_are_preferred_routes() {
|
||
// A river corridor running straight between two cities should be taken
|
||
// (½ cost) even though a straight overland path is also available.
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
let river: Vec<(u16, u16)> = (2..40).map(|c| (16u16, c)).collect();
|
||
let placements = vec![
|
||
placement(1, (16, 3), PoliticalArchetype::Pioneer),
|
||
placement(2, (16, 38), PoliticalArchetype::Pioneer),
|
||
];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&river,
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
assert_eq!(g.edges.len(), 1);
|
||
// The routed path should hug row 16 (the river line), give or take the
|
||
// downsample block.
|
||
let on_river = g.edges[0].path.iter().filter(|(r, _)| *r == 16).count();
|
||
assert!(
|
||
on_river >= g.edges[0].path.len() / 2,
|
||
"road should follow the river corridor"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn junction_detection() {
|
||
// A hub city wired to several others becomes a high-connectivity junction.
|
||
let hm = flat_hm(64, 32);
|
||
let ta = ta_for(&hm);
|
||
let placements = vec![
|
||
placement(1, (16, 32), PoliticalArchetype::Pioneer), // central hub
|
||
placement(2, (4, 8), PoliticalArchetype::Pioneer),
|
||
placement(3, (4, 56), PoliticalArchetype::Pioneer),
|
||
placement(4, (28, 8), PoliticalArchetype::Pioneer),
|
||
placement(5, (28, 56), PoliticalArchetype::Pioneer),
|
||
];
|
||
let g = build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
);
|
||
// The central node should accumulate degree ≥ JUNCTION_DEGREE.
|
||
let junctions = g.high_connectivity_junctions();
|
||
assert!(
|
||
!junctions.is_empty(),
|
||
"a central hub should be a high-connectivity junction"
|
||
);
|
||
}
|
||
|
||
// ─── 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
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── PR #178 H1/H2 — edge-split length arithmetic ────────────────────────
|
||
|
||
/// Direct unit test of the H1 bug shape: a 2-POINT parent path with real
|
||
/// `length_cells`. The old vertex-count proxy computed
|
||
/// `l1 = length × (path1.len()-1) / total_segs` = all-or-nothing whenever
|
||
/// `total_segs == 1`; the geometric split apportions by where the junction
|
||
/// actually falls.
|
||
#[test]
|
||
fn split_edge_at_two_point_parent_splits_geometrically() {
|
||
let mk_node = |pos: (u16, u16)| RoadNode {
|
||
city_id: Some(99),
|
||
position: pos,
|
||
kind: RoadNodeKind::Settlement,
|
||
degree: 0,
|
||
parent_edge: None,
|
||
is_hub: true,
|
||
};
|
||
// Midpoint split: 10 cells → 5 + 5.
|
||
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
|
||
let mut edges = vec![RoadEdge {
|
||
from: 0,
|
||
to: 1,
|
||
path: vec![(10, 10), (10, 30)],
|
||
length_cells: 10,
|
||
maintenance: MaintenanceAuthority::Communal,
|
||
named_route_id: None,
|
||
is_rail: false,
|
||
}];
|
||
let jn = split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 20));
|
||
assert_eq!(nodes[jn].kind, RoadNodeKind::Junction);
|
||
assert_eq!(edges.len(), 2);
|
||
assert_eq!(edges[0].length_cells, 5, "midpoint → equal halves");
|
||
assert_eq!(edges[1].length_cells, 5);
|
||
|
||
// Quarter-point split: 10 cells at t=0.25 → 2/3 + remainder split, but
|
||
// NEVER all-or-nothing: both halves non-zero, summing to the parent.
|
||
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
|
||
let mut edges = vec![RoadEdge {
|
||
from: 0,
|
||
to: 1,
|
||
path: vec![(10, 10), (10, 30)],
|
||
length_cells: 10,
|
||
maintenance: MaintenanceAuthority::Communal,
|
||
named_route_id: None,
|
||
is_rail: false,
|
||
}];
|
||
split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 15));
|
||
let (l1, l2) = (edges[0].length_cells, edges[1].length_cells);
|
||
assert_eq!(l1 + l2, 10, "halves always sum to the parent");
|
||
assert!(l1 > 0 && l2 > 0, "interior split is never all-or-nothing");
|
||
assert!(l1 < l2, "the shorter geometric half gets the smaller share");
|
||
}
|
||
|
||
/// H2(a) — chained snap: minor B snaps onto minor A's already-created spur
|
||
/// (the accreting behavior), splitting a 2-point spur path. Also the H2(b)
|
||
/// determinism pin on a scenario that actually exercises attach_minors
|
||
/// (the pre-existing determinism test's 5 placements all become hubs under
|
||
/// HUB_CAP_MIN = 6, so it never reaches the attach path).
|
||
#[test]
|
||
fn chained_snap_onto_earlier_spur_and_attach_determinism() {
|
||
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),
|
||
];
|
||
for p in placements.iter_mut() {
|
||
p.population = 1_000_000; // the six trunk hubs
|
||
}
|
||
// Minor A: 8 px above the row-16 trunk edge → boundary snap; its spur
|
||
// runs down col 32 from the trunk to (8,32).
|
||
let mut minor_a = placement(7, (8, 32), PoliticalArchetype::Pioneer);
|
||
minor_a.population = 10_000;
|
||
// Minor B: 3 px from A's spur segment, 4 px from the trunk → B's
|
||
// nearest edge is the spur an earlier attachment created.
|
||
let mut minor_b = placement(8, (12, 35), PoliticalArchetype::Pioneer);
|
||
minor_b.population = 5_000;
|
||
placements.push(minor_a);
|
||
placements.push(minor_b);
|
||
|
||
let run = || {
|
||
build_road_graph(
|
||
&placements,
|
||
&ta,
|
||
&[],
|
||
64,
|
||
32,
|
||
&TerritorialStatus::FrontierUnclaimed,
|
||
&[],
|
||
)
|
||
};
|
||
let g = run();
|
||
|
||
let junctions: Vec<usize> = g
|
||
.nodes
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, n)| n.kind == RoadNodeKind::Junction)
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
assert_eq!(
|
||
junctions.len(),
|
||
2,
|
||
"A splits the trunk, B splits A's spur — two junctions"
|
||
);
|
||
// B's junction sits ON A's spur (col 32, strictly between the trunk
|
||
// row and A's row) — proving the chain hit a 2-point spur parent.
|
||
let chained = junctions
|
||
.iter()
|
||
.map(|&ji| g.nodes[ji].position)
|
||
.find(|p| p.1 == 32 && p.0 > 8 && p.0 < 16)
|
||
.expect("a junction must sit interior to A's spur on col 32");
|
||
assert_eq!(chained.1, 32);
|
||
// Both minors are connected; every edge honours the invariants
|
||
// (including the split halves of the 2-point spur).
|
||
for mi in [6usize, 7usize] {
|
||
assert!(
|
||
g.edges.iter().any(|e| e.from == mi || e.to == mi),
|
||
"minor node {mi} must be attached"
|
||
);
|
||
}
|
||
for e in &g.edges {
|
||
assert!(e.from < e.to);
|
||
assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap());
|
||
assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap());
|
||
}
|
||
// H2(b): the whole accreting attach sequence is deterministic.
|
||
assert_eq!(run(), run(), "attach_minors must be deterministic");
|
||
}
|
||
}
|