Files
settled-reach/server/src/atlas/cascade.rs
T
jpmschweitzerandClaude Opus 4.8 7c09f23b9a test(simulation): cover the Some(basin_dirs) threading + clarify on-demand default (T-1047)
Addresses PR #170 review (Hoshe H1/H2).

H1: the Some(basin_dirs) path through derive_all_districts was untested (tests
only used None). Added derive_all_districts_threads_supplied_basin_directions
(synthetic per-district directions propagate to DistrictProfile.basin_direction;
unmapped districts fall back to North), and added basin_direction to the cascade
determinism loop so the full run_layer1->district chain is guarded.

H2: clarified the derive_district on-demand comment — the North default is an
accepted limitation (a fallback, not a computed value); the batch path threads
the true D8 direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:11:55 +02:00

677 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generation cascade harness (#952, D-200).
//!
//! [`run_cascade`] runs the deterministic generation cascade for one body, from
//! Layer 0 (load the baked `heightmap.png`, D-202) up to a requested layer, and
//! returns a [`CascadeSnapshot`]. The harness is extensible: each new layer is
//! added to [`CascadeLayer`] and populated on the snapshot as it lands (#954+).
//! The golden-seed regression test (#952) diffs a snapshot against a stored
//! fixture.
//!
//! [`run_cascade_from_heightmap`] is the pure, in-memory core (no file I/O); the
//! path-loading [`run_cascade`] is a thin wrapper around it.
//!
//! **Determinism (D-010 #4):** for a fixed heightmap + [`SeedChain`], the
//! snapshot is reproducible. Layers 0–3 are RNG-free — Layers 0–1 are pure
//! functions of the heightmap, and Layer 3 is a pure function of
//! (attractors, cities). The carried `SeedChain` is reserved for the future
//! RNG-using layers (Layer 4+, D-224).
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::{
match_cities, territorial_status_from_faction, CityPlacement, CityRecord,
};
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProfile};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::atlas::road_graph::{self, RoadGraph};
use crate::atlas::scale;
use crate::seed::SeedChain;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
/// Cascade layers in execution order (D-200). [`run_cascade`] runs every layer
/// up to and including the requested one. Append new layers as they are built;
/// the `Ord` derive relies on declaration order, so only ever append.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum CascadeLayer {
/// Layer 0 — load the pre-baked 16-bit `heightmap.png` (D-202).
Heightmap,
/// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953).
Topography,
/// Layer 3 — settlement placement: attractor-matched city positions (#955, D-211).
/// Deterministic and RNG-free: a pure function of (attractors, cities) via
/// `match_cities`. The carried `SeedChain` is unused here; later stochastic
/// layers (Layer 4+) will consume it.
Settlement,
/// Layer — DistrictProfile (~1 km carriers, D-239 §1, T-1023). Pure function of
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
/// declaration order (= Ord) is preserved — never reorder (D-010).
DistrictProfile,
/// 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 DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
RoadGraph,
}
/// Output of the cascade for one body, up to the requested layer (#952).
///
/// Extensible: each layer's artifact is an `Option` that becomes `Some` once
/// that layer has run. Layer 0 (`heightmap`) is always present.
#[derive(Debug, Clone)]
pub struct CascadeSnapshot {
pub body_id: String,
/// This body's root in the deterministic seed tree (D-224). Unused by the
/// RNG-free Layers 0–1; carried for the RNG-using layers (Layer 3+).
pub seed: SeedChain,
/// Layer 0 — the loaded heightmap.
pub heightmap: BodyHeightmap,
/// Layer 1 — topography. `Some` once [`CascadeLayer::Topography`] has run.
pub layer1: Option<Layer1Output>,
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
pub layer3: Option<Layer3Output>,
/// DistrictProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::DistrictProfile`] has run (T-1023, D-239 §1).
pub layer_district: Option<LayerDistrictOutput>,
/// Layer 2 — inter-settlement road/rail graph. `Some` once
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
pub road_graph: Option<RoadGraph>,
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
/// both `DistrictProfile` and `RoadGraph` have consumed it.
///
/// NOT persisted on `BodyWorldState` or the LRU cache (D-203 / T-1048 size
/// concern — `TerrainAnalysis` is ~2 MB of full-grid Vecs). Callers that
/// need it after the cascade must re-derive from `run_layer1`.
pub terrain_analysis: Option<TerrainAnalysis>,
}
/// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain
/// profiles covering the whole body. Stored in `BodyWorldState.districts`.
#[derive(Debug, Clone, Default)]
pub struct LayerDistrictOutput {
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
}
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
#[derive(Debug, Clone, Default)]
pub struct Layer3Output {
pub placements: Vec<CityPlacement>,
}
impl CascadeSnapshot {
/// Convert into a [`BodyWorldState`] for the D-203 cache (#968). Moves the
/// heightmap raster and the Layer-1 outputs in; `last_accessed` starts at 0
/// (the cache stamps it on read). A snapshot that stopped at Layer 0 yields
/// empty river/basin/attractor data.
///
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors) = match self.layer1 {
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let districts = self
.layer_district
.map(|lr| lr.districts)
.unwrap_or_default();
let road_graph = self.road_graph.unwrap_or_default();
// terrain_analysis (transient) is intentionally dropped here.
let _ = self.terrain_analysis;
BodyWorldState {
body_id: self.body_id,
heightmap: self.heightmap.data,
heightmap_width: self.heightmap.width,
heightmap_height: self.heightmap.height,
river_network,
drainage_basins,
attractors,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
districts,
last_accessed: 0,
}
}
}
/// Layer 3 — settlement placement (#955, D-211). Pure: matches the body's
/// settlements to its Layer-1 attractors via the authored D-195 compatibility
/// matrix (the five-phase `match_cities` pipeline). Deterministic — a pure
/// function of (attractors, cities); no RNG.
///
/// `terrain_costs` is `None` for now (uniform 1.0); wiring sub-biome
/// `terrain_modification_cost` (D-234) is a follow-on refinement.
///
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
fn run_layer3(
attractors: &[GeographicAttractor],
cities: &[CityRecord],
territorial_status: &TerritorialStatus,
seed: SeedChain,
grid_w: u32,
grid_h: u32,
) -> Layer3Output {
let matrix = CompatibilityMatrix::d195();
let placements = match_cities(
cities,
attractors,
&matrix,
None,
grid_w,
grid_h,
territorial_status,
seed,
);
Layer3Output { placements }
}
/// Run the cascade from a heightmap already in memory, up to `up_to`.
///
/// Pure (no I/O); this is the testable core. `body_seed` is this body's
/// [`SeedChain`] position — the caller derives it from the world seed via
/// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`. `cities` are the
/// body's settlements (from `atlas_city_names`, supplied by the caller — the
/// cascade stays DB-free); empty until Layer 3 (`Settlement`) is requested.
/// `dominant_faction` is the body's authored system faction (D-237); it drives
/// the `TerritorialStatus` on each province and the per-settlement spatial
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
body_id: heightmap.body_id.clone(),
seed: body_seed,
heightmap,
layer1: None,
layer3: None,
layer_district: None,
road_graph: None,
terrain_analysis: None,
};
// TerritorialStatus is derived once per body from the system's dominant
// faction (D-212, #956). Uniform across the body's provinces for now.
let territorial_status = territorial_status_from_faction(dominant_faction);
// Layer 1 — topography (RNG-free; pure function of the heightmap).
// run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis
// is carried transiently on the snapshot so DistrictProfile + RoadGraph can
// reuse it without the former ~45 ms redundant drainage re-run (T-1044).
if up_to >= CascadeLayer::Topography {
let (mut l1, ta) = layer1::run_layer1(&snapshot.heightmap);
// Stamp the province TerritorialStatus (D-212) onto each basin.
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
snapshot.layer1 = Some(l1);
snapshot.terrain_analysis = Some(ta);
}
// Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which
// are present because Settlement > Topography in the layer order.
if up_to >= CascadeLayer::Settlement {
let attractors: &[GeographicAttractor] = match snapshot.layer1.as_ref() {
Some(l1) => &l1.attractors,
None => &[],
};
// cache seam: run_layer3 is a pure, deterministic function of
// (attractors, cities, territorial_status, seed) — wrap a persistent
// cache here when we add one (build-time bake or local cache; see #1021).
let l3 = run_layer3(
attractors,
cities,
&territorial_status,
body_seed,
snapshot.heightmap.width,
snapshot.heightmap.height,
);
snapshot.layer3 = Some(l3);
}
// DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need
// a TerrainAnalysis. The Layer-1 pass already produced one and stored it
// transiently on `snapshot.terrain_analysis` — reuse it here instead of
// re-running the full ~45 ms drainage pass (T-1044 eliminates the former
// PERF/TODO re-run). The analysis is valid as long as the heightmap has not
// changed, which is guaranteed by cascade invariant (pure, deterministic).
//
// The terrain_analysis is consumed after DistrictProfile + RoadGraph are
// built; it is dropped (not stored on BodyWorldState) per D-203/T-1048.
if up_to >= CascadeLayer::DistrictProfile
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
{
// Borrow the transient TerrainAnalysis produced by Layer 1. If Layer 1
// did not run (e.g. up_to < Topography — impossible given the guard
// above, since DistrictProfile > Topography in CascadeLayer Ord) this
// is None and both consumers below will short-circuit gracefully.
if let Some(ta) = snapshot.terrain_analysis.as_ref() {
// DistrictProfile layer — pure derivation from body params + terrain.
if let Some(params) = body_params {
// Canonical cells-per-district for the working grid (T-1039):
// shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs
// converts CityPlacement pixel coords with the same constant.
// body_id is required for the D-243 §4 climate edge-fuzz warp
// domain separation — derive_all_districts builds the region
// cache internally.
//
// district_basin_dirs from Layer1Output threads the true D8
// thalweg direction into each DistrictProfile.basin_direction
// (T-1047). Pass the map through derive_all_districts.
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs);
let districts = district_profile::derive_all_districts(
body_seed,
params,
ta,
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
&snapshot.body_id,
basin_dirs,
);
snapshot.layer_district = Some(LayerDistrictOutput { districts });
}
// 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);
}
}
// Drop the transient TerrainAnalysis — both consumers are done.
// Not stored on BodyWorldState (D-203/T-1048 size budget: ~2 MB per body).
snapshot.terrain_analysis = None;
}
snapshot
}
/// Run the cascade for one body, loading its baked `heightmap.png` from `path`.
///
/// `default_sea_level` is the fallback used when the PNG lacks a `sea_level`
/// tEXt chunk. Delegates to [`run_cascade_from_heightmap`] for the layer work.
pub fn run_cascade(
body_seed: SeedChain,
body_id: &str,
heightmap_path: &Path,
default_sea_level: f32,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
up_to: CascadeLayer,
) -> Result<CascadeSnapshot, HeightmapLoadError> {
// Layer 0 — the cascade's input; always loaded.
let heightmap = heightmap::load_heightmap_png(heightmap_path, body_id, default_sea_level)?;
Ok(run_cascade_from_heightmap(
body_seed,
heightmap,
cities,
dominant_faction,
body_params,
up_to,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::SeedDomain;
/// A small synthetic heightmap with a diagonal slope so drainage and feature
/// extraction have real structure to work on.
fn test_heightmap() -> BodyHeightmap {
let (width, height) = (64u32, 32u32);
let n = (width * height) as usize;
let data = (0..n)
.map(|i| {
let r = (i / width as usize) as f32 / height as f32;
let c = (i % width as usize) as f32 / width as f32;
(r * 0.6 + c * 0.4).min(1.0)
})
.collect();
BodyHeightmap {
body_id: "test_body".into(),
width,
height,
data,
sea_level: 0.3,
}
}
fn body_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
#[test]
fn heightmap_layer_skips_layer1() {
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
assert!(
snap.layer1.is_none(),
"Layer 1 must not run when up_to = Heightmap"
);
}
#[test]
fn topography_layer_runs_layer1() {
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
assert_eq!(l1.body_id, "test_body");
}
#[test]
fn cascade_is_deterministic() {
let extract = |s: CascadeSnapshot| {
let l1 = s.layer1.expect("layer1");
l1.attractors
.iter()
.map(|a| (a.position, a.attractor_type, a.sub_biome))
.collect::<Vec<_>>()
};
let a = extract(run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
CascadeLayer::Topography,
));
assert_eq!(
a, b,
"same heightmap must yield identical Layer-1 attractors"
);
}
#[test]
fn layers_are_ordered() {
// The `up_to >= CascadeLayer::Settlement` guards in the cascade rely on
// this declaration order — pin it explicitly so reordering the enum (or
// inserting a layer out of sequence) fails here instead of silently
// breaking which layers run.
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
}
#[test]
fn run_cascade_missing_file_is_err() {
// The file-loading path returns an error (not a panic) for a bad path.
let res = run_cascade(
body_seed(),
"missing",
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
0.3,
&[],
None,
None, // body_params
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
}
/// DistrictProfile layer runs, produces districts, and is deterministic (T-1023).
#[test]
fn district_profile_layer_runs_and_is_deterministic() {
use crate::atlas::district_profile::BodyParams;
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
..Default::default()
};
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
Some(&params),
CascadeLayer::DistrictProfile,
)
};
let snap1 = run();
let snap2 = run();
let lr1 = snap1.layer_district.expect("layer_district should be Some");
let lr2 = snap2.layer_district.expect("layer_district should be Some");
assert!(!lr1.districts.is_empty(), "districts map must not be empty");
assert_eq!(
lr1.districts.len(),
lr2.districts.len(),
"district count deterministic"
);
// BTreeMap iteration order is deterministic — compare all entries.
for (pos, p1) in &lr1.districts {
let p2 = lr2.districts.get(pos).expect("matching pos in second run");
assert_eq!(p1.river_threshold, p2.river_threshold);
assert_eq!(p1.tectonic_class, p2.tectonic_class);
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
// basin_direction threads run_layer1 -> district_basin_dirs -> here;
// guard the full chain's determinism (T-1047).
assert_eq!(p1.basin_direction, p2.basin_direction);
}
}
/// Layer 3 — settlement placement runs, places the body's settlements onto
/// attractors, and is deterministic (#955, D-211).
#[test]
fn settlement_layer_places_cities_deterministically() {
use crate::atlas::attractor_matching::CityRecord;
use crate::simulation::generator::SettlementClass;
let cities = vec![
CityRecord {
city_id: 1,
name: "Capital".into(),
settlement_class: SettlementClass::NameLocked,
population: 2_000_000,
economic_role: "financial".into(),
},
CityRecord {
city_id: 2,
name: "Farm Town".into(),
settlement_class: SettlementClass::OrganicGrowth,
population: 120_000,
economic_role: "agricultural".into(),
},
];
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&cities,
Some("concord_assembly"),
None, // body_params
CascadeLayer::Settlement,
)
};
let snap = run();
let placement_count = {
let l3 = snap.layer3.as_ref().expect("Layer 3 should have run");
assert!(
!l3.placements.is_empty(),
"settlements must be placed when Layer 1 produced attractors"
);
l3.placements.len()
};
// Determinism: same inputs → identical placements (positions + city_ids).
let key = |s: &CascadeSnapshot| {
s.layer3
.as_ref()
.unwrap()
.placements
.iter()
.map(|p| (p.city_id, p.position, p.attractor_type, p.synthetic))
.collect::<Vec<_>>()
};
assert_eq!(key(&snap), key(&run()), "placement must be deterministic");
// #956 enrichment propagates: a concord_assembly body is
// CommissionControlled, so every placement derives the Commission
// archetype + RadialCore arrangement (D-212/214/215).
{
use crate::simulation::generator::{
ArrangementPattern, PoliticalArchetype, TerritorialStatus,
};
let l3 = snap.layer3.as_ref().unwrap();
for p in &l3.placements {
assert_eq!(p.political_archetype, PoliticalArchetype::Commission);
assert_eq!(p.arrangement_pattern, ArrangementPattern::RadialCore);
}
// TerritorialStatus is stamped on every province (D-212).
let l1 = snap.layer1.as_ref().unwrap();
assert!(
l1.drainage_basins
.iter()
.all(|b| b.territorial_status == TerritorialStatus::CommissionControlled),
"every basin inherits the body's TerritorialStatus"
);
}
// The placements propagate into the hot-cache BodyWorldState.
assert_eq!(
snap.into_body_world_state().placements.len(),
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
);
}
}