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>
2423 lines
98 KiB
Rust
2423 lines
98 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;
|
||
|
||
/// 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"
|
||
);
|
||
}
|
||
}
|