//! 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, /// 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, /// `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, /// `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, pub edges: Vec, } 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 { 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 = 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 = 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 { let mut kept: Vec = Vec::with_capacity(placements.len()); for p in placements { if let Some(prev) = kept.iter().find(|k| k.name == p.name) { warn!( name = %p.name, kept_city_id = prev.city_id, dropped_city_id = p.city_id, "road_graph: co-located duplicate settlement collapsed — \ duplicate (body, name) groups should not exist post-D-242" ); continue; } kept.push(p.clone()); } // Keep-lowest-id: placements arrive in `atlas_city_names.id` order from // the readers, so first-seen == lowest city_id. Guard the assumption: // if a lower id appears later (caller-reordered input), swap it in. for p in placements { if let Some(slot) = kept .iter_mut() .find(|k| k.name == p.name && p.city_id < k.city_id) { *slot = p.clone(); } } kept } /// T-1076 §1 — the trunk-hub index set: top hub-cap settlements by /// `(population DESC, city_id ASC)` among non-standalone-HQ placements. /// Returns indices into `placements`, sorted ascending. Falls back to ranking /// ALL placements when every settlement on the body is a standalone HQ. fn select_hubs(placements: &[CityPlacement], body_scale: f64) -> Vec { let cap = ((body_scale / HUB_SPACING_DIAG_PX) as usize).max(HUB_CAP_MIN); let mut eligible: Vec = (0..placements.len()) .filter(|&i| !placements[i].is_standalone_hq) .collect(); if eligible.is_empty() { // HQ-only body: the graph must still connect — rank the HQs. eligible = (0..placements.len()).collect(); } eligible.sort_by(|&a, &b| { placements[b] .population .cmp(&placements[a].population) .then(placements[a].city_id.cmp(&placements[b].city_id)) }); let mut chosen: Vec = eligible.into_iter().take(cap).collect(); chosen.sort_unstable(); chosen } /// Normalize an edge to the `from < to` invariant, reversing the path when the /// endpoints swap (the path always runs `nodes[from] → nodes[to]`). fn norm_edge(mut e: RoadEdge) -> RoadEdge { if e.from > e.to { std::mem::swap(&mut e.from, &mut e.to); e.path.reverse(); } e } /// T-1076 §3 — attach every minor settlement (non-hub node) to the network: /// snap onto the nearest point of an existing edge when within /// [`SNAP_MAX_PX`] (splitting that edge at a new [`RoadNodeKind::Junction`] /// node), else A\*-spur to the nearest hub. Minors attach in ascending node /// order; each attachment's new edges are visible to later minors (roads /// accrete deterministically). #[allow(clippy::too_many_arguments)] fn attach_minors( nodes: &mut Vec, edges: &mut Vec, placements: &[CityPlacement], hub_indices: &[usize], grid: &RouteGrid, body_scale: f64, body_status: &TerritorialStatus, ) { let minor_indices: Vec = (0..placements.len()) .filter(|i| !hub_indices.contains(i)) .collect(); for &mi in &minor_indices { let mpos = nodes[mi].position; let march = placements[mi].political_archetype; // --- nearest point on any existing edge polyline -------------------- let mut best: Option<(f64, usize, usize, (u16, u16))> = None; // (dist², edge, seg, proj) for (ei, e) in edges.iter().enumerate() { for si in 0..e.path.len().saturating_sub(1) { let (d2, proj) = project_onto_segment(mpos, e.path[si], e.path[si + 1]); // Strictly-less keeps the first-found (lowest edge/segment // index) on exact ties — deterministic given fixed iteration. if best.is_none() || d2 < best.unwrap().0 { best = Some((d2, ei, si, proj)); } } } if let Some((d2, ei, si, proj)) = best { if d2 <= SNAP_MAX_PX * SNAP_MAX_PX { // Snap: junction at the projection — unless it lands exactly on // an existing endpoint node, in which case attach there (no // degenerate zero-length split halves). let attach_node = if proj == edges[ei].path[0] { edges[ei].from } else if proj == *edges[ei].path.last().unwrap() { edges[ei].to } else { split_edge_at(nodes, edges, ei, si, proj) }; let apos = nodes[attach_node].position; let spur_len = euclid( (apos.0 as f64, apos.1 as f64), (mpos.0 as f64, mpos.1 as f64), ); // A snapped spur is ≤ SNAP_MAX_PX — a straight local road, not // worth an A* run. Maintenance is credited to the minor it // serves (a junction has no archetype of its own). let maintenance = maintenance_authority(march, march, spur_len, body_scale, body_status); edges.push(norm_edge(RoadEdge { from: attach_node, to: mi, path: vec![apos, mpos], length_cells: 0, maintenance, named_route_id: None, is_rail: false, })); continue; } } // --- no snap: A*-spur to the nearest hub ---------------------------- let mut best_hub: Option<(i64, usize)> = None; for &hi in hub_indices { if hi == mi { continue; } let hpos = nodes[hi].position; let dr = hpos.0 as i64 - mpos.0 as i64; let dc = hpos.1 as i64 - mpos.1 as i64; let d2 = dr * dr + dc * dc; if best_hub.is_none() || d2 < best_hub.unwrap().0 { best_hub = Some((d2, hi)); } } let Some((_, hi)) = best_hub else { continue; // no hubs at all (single-node graphs return earlier) }; let hpos = nodes[hi].position; let mposf = (mpos.0 as f64, mpos.1 as f64); let hposf = (hpos.0 as f64, hpos.1 as f64); let Some((route_cells, length_cells)) = grid.astar(mposf, hposf) else { debug!( minor = mi, hub = hi, "road_graph: minor settlement unroutable to its nearest hub (water) — left isolated" ); continue; }; let path = grid.to_grid_path(mpos, hpos, &route_cells); let maintenance = maintenance_authority( march, placements[hi].political_archetype, euclid(mposf, hposf), body_scale, body_status, ); edges.push(norm_edge(RoadEdge { from: mi, to: hi, path, length_cells, maintenance, named_route_id: None, is_rail: false, })); } } /// Project grid point `p` onto the segment `a→b` (f64, clamped to the segment). /// Returns `(squared distance, projected point rounded to grid coords)`. fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u16, u16)) { let (pr, pc) = (p.0 as f64, p.1 as f64); let (ar, ac) = (a.0 as f64, a.1 as f64); let (br, bc) = (b.0 as f64, b.1 as f64); let (dr, dc) = (br - ar, bc - ac); let len2 = dr * dr + dc * dc; let t = if len2 == 0.0 { 0.0 } else { (((pr - ar) * dr + (pc - ac) * dc) / len2).clamp(0.0, 1.0) }; let (jr, jc) = (ar + t * dr, ac + t * dc); let d2 = (pr - jr) * (pr - jr) + (pc - jc) * (pc - jc); (d2, (jr.round() as u16, jc.round() as u16)) } /// 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, edges: &mut Vec, ei: usize, si: usize, jpos: (u16, u16), ) -> usize { let jn = nodes.len(); nodes.push(RoadNode { city_id: None, position: jpos, kind: RoadNodeKind::Junction, degree: 0, parent_edge: None, is_hub: false, }); let old = edges[ei].clone(); let mut path1: Vec<(u16, u16)> = old.path[..=si].to_vec(); if path1.last() != Some(&jpos) { path1.push(jpos); } let mut path2: Vec<(u16, u16)> = vec![jpos]; if old.path[si + 1..].first() == Some(&jpos) { path2.extend_from_slice(&old.path[si + 2..]); } else { path2.extend_from_slice(&old.path[si + 1..]); } let (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 = (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, 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> { let mut adj: Vec> = 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, size: Vec, } 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, } 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 = vec![usize::MAX; n]; gscore[s] = 0; // Min-heap on (f, cell-index) — index tie-break keeps A* deterministic. let mut open: BinaryHeap> = 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 + '_ { 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 = vec![ placement(1, (16, 4), PoliticalArchetype::Pioneer), placement(2, (16, 60), PoliticalArchetype::Pioneer), placement(3, (4, 4), PoliticalArchetype::Pioneer), placement(4, (4, 60), PoliticalArchetype::Pioneer), placement(5, (28, 4), PoliticalArchetype::Pioneer), placement(6, (28, 60), PoliticalArchetype::Pioneer), ]; // Give the six hubs clear population dominance; the 7th is below-cap. for p in placements.iter_mut() { p.population = 1_000_000; } let mut minor = placement(7, (13, 32), PoliticalArchetype::Pioneer); minor.population = 10_000; // below the six → minor by cap placements.push(minor); let g = build_road_graph( &placements, &ta, &[], 64, 32, &TerritorialStatus::FrontierUnclaimed, &[], ); let minor_node = g.nodes.iter().find(|n| n.city_id == Some(7)).unwrap(); assert!(!minor_node.is_hub, "7th settlement is beyond the hub cap"); let junctions: Vec<&RoadNode> = g .nodes .iter() .filter(|n| n.kind == RoadNodeKind::Junction) .collect(); assert_eq!( junctions.len(), 1, "the minor within snap range splits exactly one edge" ); // Invariants hold for every edge, including the split halves + spur. for e in &g.edges { assert!(e.from < e.to, "from < to must hold after splits"); assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap()); assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap()); } // The junction carries trunk halves + the spur → degree 3. let ji = g .nodes .iter() .position(|n| n.kind == RoadNodeKind::Junction) .unwrap(); assert_eq!(g.nodes[ji].degree, 3, "two split halves + one spur"); } #[test] fn minor_far_from_edges_spurs_to_nearest_hub() { // 7 settlements; the 7th (below-cap minor) sits far from every trunk // edge → it gets an A*-routed spur to its nearest hub, no junction. let hm = flat_hm(64, 32); let ta = ta_for(&hm); let mut placements: Vec = vec![ placement(1, (4, 4), PoliticalArchetype::Pioneer), placement(2, (4, 30), PoliticalArchetype::Pioneer), placement(3, (4, 56), PoliticalArchetype::Pioneer), placement(4, (12, 4), PoliticalArchetype::Pioneer), placement(5, (12, 30), PoliticalArchetype::Pioneer), placement(6, (12, 56), PoliticalArchetype::Pioneer), ]; for p in placements.iter_mut() { p.population = 1_000_000; } let mut minor = placement(7, (30, 30), PoliticalArchetype::Pioneer); minor.population = 10_000; placements.push(minor); let g = build_road_graph( &placements, &ta, &[], 64, 32, &TerritorialStatus::FrontierUnclaimed, &[], ); assert!( g.nodes.iter().all(|n| n.kind != RoadNodeKind::Junction), "far minor must not split any edge" ); let mi = g.nodes.iter().position(|n| n.city_id == Some(7)).unwrap(); let spur = g .edges .iter() .find(|e| e.from == mi || e.to == mi) .expect("minor must be connected by a spur"); let other = if spur.from == mi { spur.to } else { spur.from }; assert!(g.nodes[other].is_hub, "the spur lands on a hub"); // Nearest hub to (30,30) is node 5 at (12,30). assert_eq!(g.nodes[other].city_id, Some(5)); } // ─── T-1076 §4 — RailHeadFacing assignment ─────────────────────────────── #[test] fn octant_bearing_snaps_to_compass_octants() { // Rows grow south, columns grow east; 0 = N, clockwise. assert_eq!(octant_bearing((10, 10), (0, 10)), 0); // due north assert_eq!(octant_bearing((10, 10), (0, 20)), 45); // north-east assert_eq!(octant_bearing((10, 10), (10, 20)), 90); // due east assert_eq!(octant_bearing((10, 10), (20, 20)), 135); // south-east assert_eq!(octant_bearing((10, 10), (20, 10)), 180); // due south assert_eq!(octant_bearing((10, 10), (20, 0)), 225); // south-west assert_eq!(octant_bearing((10, 10), (10, 0)), 270); // due west assert_eq!(octant_bearing((10, 10), (0, 0)), 315); // north-west assert_eq!(octant_bearing((10, 10), (10, 10)), 0); // degenerate } #[test] fn railhead_orientation_assigned_at_high_connectivity_junctions() { // The junction_detection topology: a central hub wired to 4 others. let hm = flat_hm(64, 32); let ta = ta_for(&hm); let mut placements = vec![ placement(1, (16, 32), PoliticalArchetype::Pioneer), // central hub placement(2, (4, 8), PoliticalArchetype::Pioneer), placement(3, (4, 56), PoliticalArchetype::Pioneer), placement(4, (28, 8), PoliticalArchetype::Pioneer), placement(5, (28, 56), PoliticalArchetype::Pioneer), ]; let g = build_road_graph( &placements, &ta, &[], 64, 32, &TerritorialStatus::FrontierUnclaimed, &[], ); let junctions = g.high_connectivity_junctions(); assert!(!junctions.is_empty(), "central hub is a junction"); assign_railhead_orientations(&mut placements, &g); for &ji in &junctions { let cid = g.nodes[ji].city_id.unwrap(); let p = placements.iter().find(|p| p.city_id == cid).unwrap(); assert!( matches!( p.founding_orientation, FoundingOrientation::RailHeadFacing { .. } ), "junction settlement {cid} gets RailHeadFacing" ); if let FoundingOrientation::RailHeadFacing { bearing_degrees } = p.founding_orientation { assert!(bearing_degrees < 360 && bearing_degrees % 45 == 0); } } // Non-junction settlements keep their attractor-derived orientation. for p in placements.iter().filter(|p| { !junctions .iter() .any(|&ji| g.nodes[ji].city_id == Some(p.city_id)) }) { assert!(matches!( p.founding_orientation, FoundingOrientation::Cardinal )); } } // ─── 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 = 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 = 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" ); } }