feat(simulation): add Layer-2 inter-settlement road/rail graph (T-1038)
New deterministic cascade layer (CascadeLayer::RoadGraph) building the transport topology between Layer-3 settlement placements, stored on BodyWorldState.road_graph (session-cached, never serialized). - MST force-connect (strict Kruskal over squared distance) + secondary links across a 30-60% body-scale band where the MST detours >1.5x. - A* over the Layer-1 terrain-cost field on a coarse ~64-wide routing grid: ocean/lake impassable, river corridors 1/2 cost, slope >=35deg (D-210 roughness ~0.7) 10x, else the D-210 terrain_modification_cost. Columns wrap (equirectangular); integer costs + index tie-break keep A* deterministic. - MaintenanceAuthority per edge (new generator.rs enum) from endpoint political archetypes + haul length; waypoint nodes at long-edge midpoints; named-route identity join (designed-for, pool empty post-D-223); junction detection (high_connectivity_junctions, degree>=3). Wired into the cascade (gen_queue requests CascadeLayer::RoadGraph). Adds Copy to PoliticalArchetype. 10 road_graph unit tests + a cascade e2e test; full suite + clippy --all-targets -D warnings green. Deferred to T-1076 (gated on the D-242 settlement model): the RailHeadFacing junction pass (needs a D-213 FoundingOrientation amendment + Layer-4 handling) and the hub-selection refinement (needs baked population/specialization). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::region_profile::{RegionPos, RegionProfile};
|
||||
use crate::atlas::road_graph::RoadGraph;
|
||||
use crate::simulation::generator::{
|
||||
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
|
||||
};
|
||||
@@ -84,6 +85,10 @@ pub struct BodyWorldState {
|
||||
/// Settlement placements (D-211, #955). Attractor-matched city positions.
|
||||
/// Empty until the Layer-3 placement task completes.
|
||||
pub placements: Vec<CityPlacement>,
|
||||
/// Inter-settlement road/rail graph (D-211, T-1038). Session-cached, never
|
||||
/// serialized — re-derived from `(placements, terrain, seed)` on resume.
|
||||
/// Empty until the Layer-2 road task completes (needs placements + terrain).
|
||||
pub road_graph: RoadGraph,
|
||||
/// Quarter-level world state, keyed by `QuarterId` (D-230).
|
||||
///
|
||||
/// Populated by `GenCompletion::SkeletonGenerated` after the plan phase
|
||||
@@ -216,6 +221,7 @@ mod tests {
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
road_graph: RoadGraph::default(),
|
||||
quarters: BTreeMap::new(),
|
||||
regions: BTreeMap::new(),
|
||||
last_accessed: tick,
|
||||
|
||||
+145
-18
@@ -28,6 +28,7 @@ use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile};
|
||||
use crate::atlas::road_graph::{self, RoadGraph};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
||||
|
||||
@@ -49,6 +50,12 @@ pub enum CascadeLayer {
|
||||
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
|
||||
/// declaration order (= Ord) is preserved — never reorder (D-010).
|
||||
RegionProfile,
|
||||
/// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function
|
||||
/// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer
|
||||
/// 2", but it depends only on Settlement + Topography, so it is **appended
|
||||
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
|
||||
/// the RegionProfile layer; requesting it runs RegionProfile first, harmlessly).
|
||||
RoadGraph,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
@@ -70,6 +77,9 @@ pub struct CascadeSnapshot {
|
||||
/// RegionProfile layer — ~1 km carriers. `Some` once
|
||||
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
|
||||
pub layer_region: Option<LayerRegionOutput>,
|
||||
/// Layer 2 — inter-settlement road/rail graph. `Some` once
|
||||
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
|
||||
pub road_graph: Option<RoadGraph>,
|
||||
}
|
||||
|
||||
/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain
|
||||
@@ -98,6 +108,7 @@ impl CascadeSnapshot {
|
||||
};
|
||||
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
|
||||
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
|
||||
let road_graph = self.road_graph.unwrap_or_default();
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
@@ -107,6 +118,7 @@ impl CascadeSnapshot {
|
||||
drainage_basins,
|
||||
attractors,
|
||||
placements,
|
||||
road_graph,
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
regions,
|
||||
last_accessed: 0,
|
||||
@@ -173,6 +185,7 @@ pub fn run_cascade_from_heightmap(
|
||||
layer1: None,
|
||||
layer3: None,
|
||||
layer_region: None,
|
||||
road_graph: None,
|
||||
};
|
||||
|
||||
// TerritorialStatus is derived once per body from the system's dominant
|
||||
@@ -210,28 +223,35 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.layer3 = Some(l3);
|
||||
}
|
||||
|
||||
// RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params +
|
||||
// terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass.
|
||||
// Layer 1 already ran drainage inside run_layer1, but neither the drainage
|
||||
// result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both
|
||||
// here. Pure → determinism preserved, but the drainage re-run is NOT free at
|
||||
// the ~6 000-regions/body working scale (D-203).
|
||||
// RegionProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a
|
||||
// TerrainAnalysis, which needs a drainage pass. Layer 1 already ran drainage
|
||||
// inside run_layer1, but neither result is stored on Layer1Output, so we re-run
|
||||
// both here once and share them. Pure → determinism preserved, but the drainage
|
||||
// re-run is NOT free at the ~6 000-regions/body working scale (D-203).
|
||||
// PERF/TODO(T-1044): cache TerrainAnalysis on Layer1Output to drop this
|
||||
// redundant drainage pass, and validate the combined cost against the D-239 §10
|
||||
// ~45 ms/body budget in the T-1031 verification harness. This is now a LIVE
|
||||
// ~45 ms/body budget in the T-1031 verification harness. This is a LIVE
|
||||
// production cost: T-1032 wired the real body_params read, so every analyzed
|
||||
// body runs this path. If body_params is None, the region layer is skipped
|
||||
// (e.g. unit tests without DB).
|
||||
if up_to >= CascadeLayer::RegionProfile {
|
||||
// body runs this path.
|
||||
//
|
||||
// The terrain analysis is computed only when it will actually be used:
|
||||
// body_params present (RegionProfile) or RoadGraph requested. RegionProfile
|
||||
// skips silently without body_params (e.g. unit tests without DB), but the
|
||||
// road graph needs no body params, so RoadGraph runs regardless.
|
||||
if up_to >= CascadeLayer::RegionProfile
|
||||
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
|
||||
{
|
||||
use crate::atlas::drainage;
|
||||
let dr = drainage::analyze(
|
||||
&snapshot.heightmap.data,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
snapshot.heightmap.sea_level,
|
||||
);
|
||||
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
|
||||
|
||||
// RegionProfile layer — pure derivation from body params + terrain.
|
||||
if let Some(params) = body_params {
|
||||
use crate::atlas::drainage;
|
||||
let dr = drainage::analyze(
|
||||
&snapshot.heightmap.data,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
snapshot.heightmap.sea_level,
|
||||
);
|
||||
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
|
||||
// ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions;
|
||||
// at full working resolution the budget is ~6 000/body (D-203).
|
||||
const CELLS_PER_REGION: usize = 8;
|
||||
@@ -239,6 +259,33 @@ pub fn run_cascade_from_heightmap(
|
||||
region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION);
|
||||
snapshot.layer_region = Some(LayerRegionOutput { regions });
|
||||
}
|
||||
|
||||
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
|
||||
// function of (Layer-3 placements, Layer-1 terrain). The named-route pool
|
||||
// is empty for now (atlas_roads/atlas_railroads carry no rows post-D-223),
|
||||
// so the named-route identity join is a designed-for no-op.
|
||||
if up_to >= CascadeLayer::RoadGraph {
|
||||
let placements = snapshot
|
||||
.layer3
|
||||
.as_ref()
|
||||
.map(|l3| l3.placements.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let river_cells = snapshot
|
||||
.layer1
|
||||
.as_ref()
|
||||
.map(|l1| l1.river_network.river_cells.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let graph = road_graph::build_road_graph(
|
||||
placements,
|
||||
&ta,
|
||||
river_cells,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
&territorial_status,
|
||||
&[],
|
||||
);
|
||||
snapshot.road_graph = Some(graph);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot
|
||||
@@ -371,6 +418,7 @@ mod tests {
|
||||
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
|
||||
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
|
||||
assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile);
|
||||
assert!(CascadeLayer::RegionProfile < CascadeLayer::RoadGraph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -511,4 +559,83 @@ mod tests {
|
||||
placement_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Layer 2 — road graph runs through the full cascade, connects the placed
|
||||
/// cities, is deterministic, and propagates into BodyWorldState (T-1038).
|
||||
#[test]
|
||||
fn road_graph_layer_connects_cities_deterministically() {
|
||||
use crate::atlas::attractor_matching::CityRecord;
|
||||
use crate::atlas::road_graph::RoadNodeKind;
|
||||
use crate::simulation::generator::SettlementClass;
|
||||
|
||||
// Several inland cities (the slope heightmap is land away from the low
|
||||
// corner) so the MST has real edges to route.
|
||||
let cities: Vec<CityRecord> = [
|
||||
(1u64, "A", 800_000i64),
|
||||
(2, "B", 400_000),
|
||||
(3, "C", 200_000),
|
||||
(4, "D", 150_000),
|
||||
]
|
||||
.iter()
|
||||
.map(|(id, name, pop)| CityRecord {
|
||||
city_id: *id,
|
||||
name: (*name).into(),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: *pop,
|
||||
economic_role: "manufacturing".into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let run = || {
|
||||
run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&cities,
|
||||
Some("independent"),
|
||||
None, // body_params — road graph needs none
|
||||
CascadeLayer::RoadGraph,
|
||||
)
|
||||
};
|
||||
let snap = run();
|
||||
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
|
||||
let settlements = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.kind == RoadNodeKind::Settlement)
|
||||
.count();
|
||||
assert_eq!(settlements, cities.len(), "one road node per placed city");
|
||||
assert!(
|
||||
!graph.edges.is_empty(),
|
||||
"placed cities on shared land must be connected"
|
||||
);
|
||||
// Every edge endpoint is a settlement node and the path snaps to it.
|
||||
for e in &graph.edges {
|
||||
assert!(e.from < e.to);
|
||||
assert_eq!(graph.nodes[e.from].position, *e.path.first().unwrap());
|
||||
assert_eq!(graph.nodes[e.to].position, *e.path.last().unwrap());
|
||||
}
|
||||
|
||||
// Determinism: identical inputs → identical graph.
|
||||
let key = |s: &CascadeSnapshot| {
|
||||
let g = s.road_graph.as_ref().unwrap();
|
||||
(
|
||||
g.nodes
|
||||
.iter()
|
||||
.map(|n| (n.city_id, n.position, n.degree))
|
||||
.collect::<Vec<_>>(),
|
||||
g.edges
|
||||
.iter()
|
||||
.map(|e| (e.from, e.to, e.length_cells, e.maintenance))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(key(&snap), key(&run()), "road graph must be deterministic");
|
||||
|
||||
// Propagates into the hot-cache BodyWorldState.
|
||||
let edge_count = graph.edges.len();
|
||||
assert_eq!(
|
||||
snap.into_body_world_state().road_graph.edges.len(),
|
||||
edge_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,15 +374,13 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
} else {
|
||||
hm
|
||||
};
|
||||
// Run through RegionProfile (T-1023, D-239 §1): includes Settlement
|
||||
// and all prior layers. RegionProfile > Settlement in CascadeLayer ord
|
||||
// so Settlement also runs when body_params is Some. When body_params
|
||||
// is None the cascade falls back to Settlement as the terminal layer.
|
||||
let up_to = if body_params.is_some() {
|
||||
CascadeLayer::RegionProfile
|
||||
} else {
|
||||
CascadeLayer::Settlement
|
||||
};
|
||||
// Run the full cascade through RoadGraph (Layer 2, T-1038), the
|
||||
// terminal layer. It subsumes Settlement, RegionProfile (T-1023),
|
||||
// and all prior layers. RegionProfile derivation still gates on
|
||||
// body_params internally (skipped when absent — e.g. a body with no
|
||||
// params row), but the road graph needs no body params, so it runs
|
||||
// for every analyzed body.
|
||||
let up_to = CascadeLayer::RoadGraph;
|
||||
let snapshot = run_cascade_from_heightmap(
|
||||
*body_seed,
|
||||
working,
|
||||
|
||||
@@ -270,6 +270,7 @@ mod tests {
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
regions: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod layer1;
|
||||
pub mod layer_proxy;
|
||||
pub mod plugin;
|
||||
pub mod region_profile;
|
||||
pub mod road_graph;
|
||||
pub mod skeleton_gen;
|
||||
pub mod source_resolver;
|
||||
pub mod subbiome;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -373,7 +373,7 @@ pub enum SettlementClass {
|
||||
/// Dominant power structure of a settlement and its physical spatial expression.
|
||||
/// Derived from TerritorialStatus + economic_role at generation time.
|
||||
/// Source: D-214
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PoliticalArchetype {
|
||||
/// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement.
|
||||
Commission,
|
||||
@@ -406,6 +406,29 @@ pub enum FoundingOrientation {
|
||||
Free { bearing_degrees: u16 },
|
||||
}
|
||||
|
||||
/// Who maintains an inter-settlement road/rail edge — readable in the road's
|
||||
/// condition (Ozzie: "you can tell who controls territory by the state of the
|
||||
/// roads"). Derived at Layer-2 road generation from the endpoint settlements'
|
||||
/// political archetypes + the edge's haul length (no separate authoring).
|
||||
/// Five-value vocabulary frozen by the cascade workshop (workshop-outcomes.md
|
||||
/// "OQ-R3-4"); see [`crate::atlas::road_graph::maintenance_authority`].
|
||||
/// Source: D-211 (Layer-2 transport), D-212 (territorial status input).
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum MaintenanceAuthority {
|
||||
/// Government-built/maintained — a Commission (or strategic Military) endpoint.
|
||||
Administrative,
|
||||
/// Corporation-maintained — a Corporate endpoint (e.g. road to a corp mine).
|
||||
Corporate,
|
||||
/// Locally maintained short links between neighbouring small settlements.
|
||||
Communal,
|
||||
/// Long-haul inter-regional routes between distant non-state settlements.
|
||||
Trade,
|
||||
/// Roads that exist but are no longer maintained (a Derelict-province
|
||||
/// endpoint, or runtime abandonment). Not reachable from the current
|
||||
/// authored data alone (D-212 Derelict is deferred) — wired for forward use.
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
/// Territory control status for a province (drainage basin). Priority-ordered derivation.
|
||||
/// Source: D-212 (amended 2026-06-05 — see `AutonomistHeld` + the dominant_faction
|
||||
/// mapping note on the variant docs and in `attractor_matching::territorial_status_from_faction`).
|
||||
|
||||
Reference in New Issue
Block a user