Files
settled-reach/server/src/atlas/road_graph.rs
T
jpmschweitzerandClaude Opus 5 d5e617eff6 docs(simulation): PR #218 round 3 — the round-2 fix outran its own documentation
Three findings, all doc-accuracy, and all the same root cause: folding the
spacing predicate into the ring walk changed what three comments describe, and
two of those comments were written by this same PR one round earlier.

TYRE 1 — road_graph.rs's T-1206 gap-closure comment cited `nearest_land_cell`,
which round 2 made `#[cfg(test)]`. A reader chasing that name lands on a
test-only function and reasonably wonders whether they are looking at dead
code. Repointed to `nearest_cell_matching`, and the paragraph's closing claim
that "T-1206 guarantees the placement pixel is land" is corrected: it has been
land-AND-spacing-or-skip since round 2.

TYRE 2 — `max_land_search_ring`'s doc named the same test-only wrapper as the
thing that walks the bound. It now names the production consumer and both
callers.

TYRE 3 — the D-211 amendment was written in round 1, before round 2 existed,
and still described a land-only correction. It now carries a dated refinement
recording what the code actually does: the walk satisfies BOTH of step 4's
promises in one search, and SKIP therefore also fires where land exists but
none of it clears spacing within the bound. The no-re-decision conclusion is
unaffected — position remains a deterministic, non-fabricated function of seed
and terrain — and the refinement notes the spacing promise is step 4's alone,
since Tier A/B/C placements sit on their matched attractor and were never
subject to it.

HOSHE's three findings were the same three hunks, observed uncommitted while
the review ran: accurate content, but not in the branch tip, so the PR would
have merged a governance record that misdescribes its own commit. That is this
commit.

Both reviewers independently confirmed what the round-2 fix claims. Tyre traced
the ring geometry and tie-break order by hand against the spacing predicate;
Hoshe re-ran the full 267-body corpus scan live (850s) and reproduced the
figures exactly — 267 bodies, 267 reaching Layer 3, 344 placements, 109
synthetic, 0 in water, 0 spacing violations.

The shared-ring-search-helper retraction is confirmed and settled, with NEW
grounds rather than a restatement: round 2 strengthened the case for keeping
them separate, since this walk is now parameterized by an arbitrary predicate
over native u16 terrain coordinates while road_graph's is a RouteGrid method
over downsampled routing cells with a fixed cost test and an unrelated bound.

22 module tests green; clippy and fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:53:33 +02:00

2423 lines
98 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
/// T-1116 — coastal-cell routing relaxation. A settlement's OWN routing cell can
/// be majority-water (`RouteGrid::build`'s `water_count * 2 > total_count` rule)
/// even though the settlement's exact placement pixel is (almost) always land:
/// every REAL attractor type — including `PlainCenter` — is extracted with an
/// explicit `!ta.ocean_mask[i]` guard (D-209, `features.rs::extract_attractors`,
/// e.g. the `CoastalAccess` filter at line 544 and the `PlainCenter` filter at
/// line 629). This is pure coastal-cell/downsample granularity (a routing cell
/// spans up to `scale`² native pixels, ROUTE_W=64 on a 512-wide grid ⇒ up to
/// 8² = 64 native cells folded into one routing cell). Without this relaxation,
/// one water-majority routing cell under a coastal city hard-fails A* for every
/// edge touching it (the documented T-1116 bug: GJ251c land 0.55 / GJ380c land
/// 0.588 — land-majority BODIES with 0 routable edges).
///
/// **Gap this relaxation also happened to cover, now CLOSED at the source
/// (T-1206, 2026-07-26):** D-211's Phase-4 **synthetic overflow** path
/// (`attractor_matching.rs::synthetic_attractor`) used to pick a position by
/// pure grid arithmetic (`grid_h/2, grid_w/4` then a fixed spacing-walk) with
/// **no terrain check at all** — a synthetic-overflow settlement could land
/// in open ocean (confirmed on 46 real bodies at world seed 42/"yolo" before
/// the fix). `synthetic_attractor` now takes an `Option<&TerrainAnalysis>`
/// and, when supplied, guards the arithmetic position: already-land stays
/// byte-identical (verified unmoved on all 63 real land-arithmetic
/// placements), water gets nudged to the nearest acceptable cell via a
/// deterministic ring-walk (`nearest_cell_matching`, the same tie-break
/// pattern as this file's own `nearest_passable_cell` below, at
/// native/working-grid resolution rather than the routing grid's downsample),
/// and a city with no such cell within the bounded search radius is SKIPPED
/// entirely (never fabricated, never a panic — reported via the existing
/// Phase-5 name-fulfillment warning). "Acceptable" is BOTH of D-211 step 4's
/// promises since the PR #218 round-2 fix: land AND at least `MIN_SPACING`
/// from every already-placed city, so correcting one can never quietly spend
/// the other. SKIP therefore also fires where land exists but none of it
/// clears spacing within the bound. This routing relaxation still degrades
/// gracefully for the coastal-cell/downsample case it was built for (anchors
/// to the nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves
/// it unrouted beyond that) — the two fixes are independent and both apply
/// (T-1206 guarantees the placement pixel is land and correctly spaced, or
/// that the city is skipped; this relaxation still covers the routing CELL
/// being water-majority at downsample granularity).
///
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
/// search, deterministic tie-break) rather than the settlement's own impassable
/// cell, and prices the gap as a short access-road surcharge
/// (`COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING` per ring) — a real quay/causeway
/// link is modeled as more expensive than being fully inland, never free. This
/// keeps `IMPASSABLE` an honest ocean/lake fact everywhere else in the cost
/// field (D-210's terrain_modification_cost else-branch is untouched) — only
/// the anchor LOOKUP for a start/goal settlement is relaxed, not open-ocean
/// transit.
const COASTAL_ANCHOR_MAX_RING: usize = 3;
/// Surcharge added to a routed edge's reported `length_cells` per ring-cell of
/// coastal anchor search (T-1116). `length_cells` is a pure HOP COUNT (the
/// number of routing-cell steps in the path, [`RouteGrid::reconstruct`]), NOT
/// a cost-unit quantity — so this constant is defined directly in hop units,
/// not derived from `ORTHO`/cell-cost scale. One ring = one extra hop: a
/// ring-1 anchor reports as if the path took one additional plain step,
/// noticeable in tie-breaks between near-identical routes and in
/// [`WAYPOINT_THRESHOLD_CELLS`], never prohibitive (worst case, both anchors
/// at [`COASTAL_ANCHOR_MAX_RING`], is `2 * 3 = 6` extra hops — half the
/// waypoint threshold, not double it).
const COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING: u32 = 1;
/// 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)
}
/// T-1116 — find the nearest PASSABLE routing cell to `(r, c)` by
/// ring-expansion search (ring 0 = the cell itself, ring `k` = the square
/// annulus at Chebyshev distance `k`), up to [`COASTAL_ANCHOR_MAX_RING`].
/// Returns `(row, col, ring)` for the first passable hit; deterministic
/// tie-break scans each ring in fixed row-major order (top edge
/// left→right, bottom edge left→right, then left/right edges
/// top→bottom), so identical inputs always pick the same cell. Columns
/// wrap (equirectangular); rows clamp. `None` if every cell within the
/// search radius is water — the settlement is truly water-locked at this
/// routing granularity (graceful degradation: the caller leaves it
/// unrouted rather than crossing open ocean).
fn nearest_passable_cell(&self, r: usize, c: usize) -> Option<(usize, usize, usize)> {
let idx = |rr: usize, cc: usize| rr * self.rw + cc;
if self.cost[idx(r, c)] != IMPASSABLE {
return Some((r, c, 0));
}
for ring in 1..=COASTAL_ANCHOR_MAX_RING {
let ring_i = ring as i32;
let (rr, cc) = (r as i32, c as i32);
let mut candidates: Vec<(usize, usize)> = Vec::new();
// Top edge, left→right.
if rr - ring_i >= 0 {
let nr = (rr - ring_i) as usize;
for dc in -ring_i..=ring_i {
let nc = (cc + dc).rem_euclid(self.rw as i32) as usize;
candidates.push((nr, nc));
}
}
// Bottom edge, left→right.
if (rr + ring_i) < self.rh as i32 {
let nr = (rr + ring_i) as usize;
for dc in -ring_i..=ring_i {
let nc = (cc + dc).rem_euclid(self.rw as i32) as usize;
candidates.push((nr, nc));
}
}
// Left/right edges (excluding corners already covered above),
// top→bottom.
for dr in (-ring_i + 1)..ring_i {
let nr_i = rr + dr;
if nr_i < 0 || nr_i >= self.rh as i32 {
continue;
}
let nr = nr_i as usize;
let nc_left = (cc - ring_i).rem_euclid(self.rw as i32) as usize;
let nc_right = (cc + ring_i).rem_euclid(self.rw as i32) as usize;
candidates.push((nr, nc_left));
candidates.push((nr, nc_right));
}
for (nr, nc) in candidates {
if self.cost[idx(nr, nc)] != IMPASSABLE {
return Some((nr, nc, ring));
}
}
}
None
}
/// 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).
///
/// T-1116: if `start`'s or `goal`'s own routing cell is water-majority
/// (coastal-cell granularity — the settlement's exact pixel is always
/// land, D-211/D-209), route to/from the nearest passable cell instead
/// of hard-failing, and add [`COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING`] hop
/// to `length_cells` per ring of search distance (an honest short
/// access-road cost, not a free pass through water — `length_cells` is a
/// hop count, so the surcharge is expressed directly in hops, never
/// converted through cost units). Still returns `None` when no passable
/// cell exists within [`COASTAL_ANCHOR_MAX_RING`] of either endpoint
/// (fully water-locked at this granularity) or when no path connects the
/// two anchors (genuinely separated by ocean).
fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> {
let (sr0, sc0) = self.to_route_cell(start);
let (gr0, gc0) = self.to_route_cell(goal);
let (sr, sc, s_ring) = self.nearest_passable_cell(sr0, sc0)?;
let (gr, gc, g_ring) = self.nearest_passable_cell(gr0, gc0)?;
let surcharge_hops =
(s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING);
let s = sr * self.rw + sc;
let g = gr * self.rw + gc;
if s == g {
return Some((vec![(sr, sc)], surcharge_hops));
}
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 {
let (path, length_cells) = self.reconstruct(&came, g);
// Surcharge folded into the reported length (already in hop
// units — see WAYPOINT_THRESHOLD_CELLS/maintenance_authority
// callers, which both read `length_cells` as a hop count).
return Some((path, length_cells.saturating_add(surcharge_hops)));
}
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");
}
/// T-1116 — real-body pin: GJ251c (land fraction 0.55) and GJ380c (land
/// fraction 0.588) are land-MAJORITY bodies whose entire settlement list
/// used to produce **zero** routable road edges, because every one of
/// their placements happens to land in a majority-water routing cell at
/// the ~64-wide routing-grid's downsample granularity (confirmed via a
/// throwaway diagnostic before this fix landed: GJ251c had all 3
/// placements IMPASSABLE at route_cell resolution; GJ380c had 2 of 3
/// passable but the third — Sethvale, route_cell (16,13) — IMPASSABLE,
/// enough to zero the graph with only 3 nodes total). The coastal-cell
/// routing relaxation (`RouteGrid::nearest_passable_cell` +
/// `astar`'s surrogate-anchor path) must flip both bodies to routable.
///
/// Uses the committed `systems.db` + `wiki/star-systems` heightmaps via
/// [`believability::cascade_snapshot_for_body`] — this IS
/// `build_road_graph` exercised through the real cascade entry point
/// (`cascade.rs:405`), not a synthetic harness.
#[test]
fn real_body_coastal_settlements_produce_routable_edges() {
// (body, expected node count, minimum edges after the fix). GJ251c's
// documented post-fix count is 2 edges (all 3 placements land in
// IMPASSABLE cells pre-fix); GJ380c's is 1 (2 of 3 placements are
// already passable, only Sethvale needed the relaxation) — tightened
// to the exact documented counts so a regression to fewer edges
// fails loudly (PR #215 review finding 2).
let cases = [("GJ251c", 3usize, 2usize), ("GJ380c", 3usize, 1usize)];
for (body, min_placements, min_edges) in cases {
let bws = crate::atlas::believability::cascade_for_body(42, body)
.unwrap_or_else(|e| panic!("{body}: {e}"));
assert!(
bws.placements.len() >= min_placements,
"{body}: expected at least {min_placements} placements, got {}",
bws.placements.len()
);
assert!(
!bws.road_graph.edges.is_empty(),
"{body}: road graph must have routable edges — coastal-cell \
relaxation should route around water-majority routing cells \
under land-majority settlements"
);
assert!(
bws.road_graph.edges.len() >= min_edges,
"{body}: expected >= {min_edges} edges, got {}",
bws.road_graph.edges.len()
);
}
}
/// Same seed, same body → same graph (D-010). Guards the surrogate-anchor
/// search (`nearest_passable_cell`'s ring order is a fixed scan, not a
/// distance sort, so ties must resolve identically every run).
#[test]
fn real_body_coastal_routing_is_deterministic() {
for body in ["GJ251c", "GJ380c"] {
let a = crate::atlas::believability::cascade_for_body(42, body).unwrap();
let b = crate::atlas::believability::cascade_for_body(42, body).unwrap();
assert_eq!(
a.road_graph, b.road_graph,
"{body}: identical seed must produce an identical road graph"
);
}
}
/// T-1116 boundary case: `RouteGrid::build`'s majority rule is **strict**
/// `water_count * 2 > total_count` — so a routing cell exactly AT the
/// 50/50 threshold resolves to LAND (passable), and only a true majority
/// (2 of 3, not 1 of 2) is IMPASSABLE. This test pins both sides of that
/// boundary explicitly: the tie-goes-to-land case stays routable outright
/// (no relaxation needed), and the true-majority case is IMPASSABLE and
/// must be routed AROUND via the coastal relaxation.
#[test]
fn routing_cell_at_majority_water_threshold_boundary() {
// grid_w=180 -> scale = 180.div_ceil(64) = 3, so each routing cell
// aggregates a 3x1 run of native columns — lets us hit both an exact
// 50/50 split (impossible at 3-wide, so we use a 2-wide sub-probe)
// and a true 2-of-3 majority in the same grid. grid_h=1 keeps the
// row dimension out of the aggregation entirely.
let w = 180u32;
let h = 1u32;
let mut hm = BodyHeightmap {
body_id: "threshold_test".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let idx = |r: u32, c: u32| (r * w + c) as usize;
// Routing cell (0, 3) covers native columns 9..12 (scale=3). Sink 2
// of 3 (cols 9,10) -> water_count=2, total_count=3: 2*2=4 > 3, a true
// majority -> IMPASSABLE.
hm.data[idx(0, 9)] = 0.1;
hm.data[idx(0, 10)] = 0.1;
// Routing cell (0, 6) covers native columns 18..21. Sink exactly 1 of
// 3 (col 18) -> water_count=1, total_count=3: 1*2=2 is NOT > 3, so
// this stays LAND despite being water-touched (tie/minority goes to
// land, matching the strict-`>` rule) — a contrasting control case.
hm.data[idx(0, 18)] = 0.1;
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let grid = RouteGrid::build(&ta, &[], w, h);
assert_eq!(
grid.scale, 3,
"grid_w=180 must downsample at scale=3 for ROUTE_W=64"
);
let majority_water_cell = 3; // row-major (row 0, col 3)
assert_eq!(
grid.cost[majority_water_cell], IMPASSABLE,
"2-of-3 native cells water (a true majority) must be IMPASSABLE"
);
let minority_water_cell = 6; // row-major (row 0, col 6)
assert_ne!(
grid.cost[minority_water_cell], IMPASSABLE,
"1-of-3 native cells water (a minority) must stay LAND — the \
strict `>` rule does not treat any water touch as impassable"
);
// The IMPASSABLE cell must be routed AROUND: a passable neighbour
// exists within COASTAL_ANCHOR_MAX_RING on this otherwise-flat body.
let hit = grid.nearest_passable_cell(0, 3);
assert!(
hit.is_some(),
"a passable neighbour must exist within the search radius on an \
otherwise-flat-land grid"
);
let (nr, nc, ring) = hit.unwrap();
assert_ne!(
(nr, nc),
(0, 3),
"the surrogate must NOT be the impassable cell itself"
);
assert!((1..=COASTAL_ANCHOR_MAX_RING).contains(&ring));
}
/// T-1116 boundary case: a settlement placement fully surrounded by
/// water-majority routing cells beyond `COASTAL_ANCHOR_MAX_RING` in every
/// direction has NO relaxation rescue — `nearest_passable_cell` must
/// return `None`, and `astar` must degrade gracefully (return `None`,
/// same as the pre-existing "separated by ocean" contract) rather than
/// silently routing through open water or panicking. The graph as a
/// whole must still build (the settlement is left an isolated node with
/// no incident edges — `build_road_graph`'s existing drop-and-log path,
/// unchanged by this fix).
#[test]
fn settlement_surrounded_by_water_beyond_search_radius_degrades_gracefully() {
// All-ocean grid: every routing cell is majority water.
let hm = BodyHeightmap {
body_id: "all_water_test".into(),
width: 64,
height: 32,
data: vec![0.1; 64 * 32], // below sea_level everywhere
sea_level: 0.3,
};
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let grid = RouteGrid::build(&ta, &[], 64, 32);
assert!(
grid.nearest_passable_cell(16, 32).is_none(),
"an all-water body has no passable cell within any search radius"
);
assert!(
grid.astar((16.0, 32.0), (10.0, 10.0)).is_none(),
"astar must return None (graceful degradation), not panic or \
fabricate a water route, when no passable anchor exists"
);
// The graph-level contract: build_road_graph must not panic and must
// leave the pair unconnected (0 edges), exactly like the pre-existing
// "separated by ocean" behaviour this ticket's fix must NOT change
// for a genuinely all-water body.
let placements = vec![
placement(1, (16, 32), PoliticalArchetype::Pioneer),
placement(2, (10, 10), PoliticalArchetype::Pioneer),
];
let g = build_road_graph(
&placements,
&ta,
&[],
64,
32,
&TerritorialStatus::FrontierUnclaimed,
&[],
);
assert_eq!(g.nodes.len(), 2);
assert!(
g.edges.is_empty(),
"an all-water body must still produce 0 edges — the relaxation \
only rescues coastal-granularity mismatches, not genuine \
water-world isolation"
);
}
/// T-1116 — the coastal relaxation must add a real, non-zero surcharge
/// (never free): a route ending at a coastal-cell settlement must cost
/// more than the equivalent route to a settlement whose own cell is
/// already passable at ring 0, all else equal.
#[test]
fn coastal_anchor_surcharge_is_never_free() {
// Same 180-wide/scale-3 setup as the threshold test: routing cell
// (0, 3) (native cols 9..12) is a true 2-of-3 majority -> IMPASSABLE.
let w = 180u32;
let h = 1u32;
let mut hm = BodyHeightmap {
body_id: "surcharge_test".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let idx = |r: u32, c: u32| (r * w + c) as usize;
hm.data[idx(0, 9)] = 0.1;
hm.data[idx(0, 10)] = 0.1;
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let grid = RouteGrid::build(&ta, &[], w, h);
assert_eq!(
grid.cost[3], IMPASSABLE,
"sanity: cell (0,3) must be water-majority"
);
let (surrogate_r, surrogate_c, ring) = grid
.nearest_passable_cell(0, 3)
.expect("a passable neighbour must exist");
assert!(
ring >= 1,
"the impassable cell must need a real search, ring 0 would be a no-op test"
);
// Route A: settlement anchored INSIDE the impassable cell (native col
// 11) — must go through the surrogate-anchor relaxation.
let start = (0.0, 0.0); // routing cell (0, 0), flat land
let (_, coastal_len) = grid
.astar(start, (0.0, 11.0))
.expect("coastal goal must still route via the surrogate anchor");
// Route B: the SAME surrogate cell as an explicit, directly-reachable
// goal (its own native centre) — ring 0 by construction, so this is
// exactly "coastal_len minus the surcharge" if the surcharge is
// correctly additive and nothing else differs.
let surrogate_native = (
(surrogate_r * grid.scale) as f64,
(surrogate_c * grid.scale + grid.scale / 2) as f64,
);
let (_, surrogate_len) = grid
.astar(start, surrogate_native)
.expect("the surrogate cell itself must be directly routable");
assert!(
coastal_len > surrogate_len,
"routing to a coastal-cell goal (via the ring-{ring} surrogate) \
must cost MORE than routing directly to that same surrogate \
cell with no relaxation involved: got coastal={coastal_len} \
surrogate={surrogate_len}"
);
let surcharge_hops = coastal_len - surrogate_len;
assert!(
surcharge_hops > 0,
"the coastal surcharge must be a strictly positive number of \
hops, never zero (never free)"
);
// PR #215 review finding 1: `length_cells` is a pure HOP COUNT, so the
// surcharge must be expressed directly in hops — pin the exact value
// (ring * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING) so a future unit
// mismatch (e.g. reintroducing a cost-unit conversion) fails loudly
// instead of merely "some positive number".
assert_eq!(
surcharge_hops,
ring as u32 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING,
"the goal-side surcharge must be EXACTLY ring * hops-per-ring \
(the start side contributes 0 here — start is ring 0)"
);
}
/// PR #215 review finding 1 — pins the surcharge formula directly (no
/// terrain, no A*, just the arithmetic in `astar`): the reported
/// `length_cells` surcharge for a start/goal pair is EXACTLY
/// `(start_ring + goal_ring) * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING`
/// hops — never a cost-unit quantity in disguise. Uses the `s == g`
/// short-circuit branch in `astar` (same routing cell for start and
/// goal) to read the surcharge in complete isolation from pathfinding.
#[test]
fn coastal_surcharge_is_exactly_hops_per_ring_times_ring_count() {
// Same 180-wide/scale-3 body as the other boundary tests: cell (0,3)
// is IMPASSABLE (2-of-3 majority), cell (0,6) is passable (1-of-3,
// stays land under the strict `>` rule).
let w = 180u32;
let h = 1u32;
let mut hm = BodyHeightmap {
body_id: "surcharge_formula_test".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let idx = |r: u32, c: u32| (r * w + c) as usize;
hm.data[idx(0, 9)] = 0.1;
hm.data[idx(0, 10)] = 0.1;
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let grid = RouteGrid::build(&ta, &[], w, h);
assert_eq!(
grid.cost[3], IMPASSABLE,
"sanity: cell (0,3) must be water-majority"
);
// Start AND goal both land in the same impassable cell (native cols
// 9 and 11, both inside routing cell (0,3)) — astar's `s == g`
// branch returns `surcharge_hops` directly with zero path-length
// noise, isolating the formula from A*'s own hop accounting.
let (_, ring) = {
let (r, c, ring) = grid.nearest_passable_cell(0, 3).unwrap();
((r, c), ring)
};
let (_, reported_hops) = grid
.astar((0.0, 9.0), (0.0, 11.0))
.expect("same-cell start/goal must resolve via the shared surrogate");
assert_eq!(
reported_hops,
(ring as u32) * 2 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING,
"start and goal share the same impassable cell -> same ring on \
both sides -> surcharge = 2 * ring * hops-per-ring, with ZERO \
actual path hops (s == g after anchoring)"
);
// With the constant fixed at 1 hop/ring (finding 1's chosen value),
// this resolves to a concrete number — pin it so a future change to
// COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING is a deliberate, visible act.
assert_eq!(
COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING, 1,
"if this changes, the assertion above must be re-derived, not \
just relaxed"
);
}
}