feat(simulation): atlas proxy layers + data-delivery endpoints (T-960, T-949)

T-960: RoadGraphLayer (L2 edges/junctions, MaintenanceAuthority, rail flag, named routes) + SettlementLayer (name/position/size-class/is_capital/is_port from T-955 placements) as new Option fields on AtlasLayerResponse — T-1046 precedent; layer_proxy doc comment corrected (cascade runs through RoadGraph, client up_to ignored). T-949: new StarMapRequest/StarMapResponse (raw JSON passthrough of the star_map_data.json bake) + CityNamesRequest/CityNamesResponse (atlas_city_names via CityContextReader) with defense-in-depth Sol exclusion (GJ-0 system id OR settlement_wave='origin', D-236). Inbound demux extended with mandatory boolean discriminator fields (serde ignores unknown fields — optional-shape sniffing would be ambiguous); AtlasLayerRequest unchanged. SimBridge trait +2 methods, implemented on TcpBridge + LocalBridge. CityPlacement/CityRecord gained name/population/is_capital (BodyWorldState cache-hit path stays DB-free). ~30 new unit tests incl. demux disambiguation + Sol branches; gen_fixtures extended for road/settlement samples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:45:25 +02:00
co-authored by Claude Fable 5
parent 6bda9f1697
commit 37881acba0
15 changed files with 1654 additions and 23 deletions
+425 -5
View File
@@ -14,13 +14,15 @@
use serde::{Deserialize, Serialize};
use crate::atlas::body_params_reader::BodyParamsReader;
use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick};
use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick};
use crate::atlas::cascade::CascadeLayer;
use crate::atlas::city_context_reader::CityContextReader;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer1::Layer1Output;
use crate::atlas::road_graph::RoadNodeKind;
use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError};
use crate::seed::SeedChain;
use crate::simulation::generator::{AttractorType, MaintenanceAuthority};
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
/// (the loader prefers the chunk; this is only the floor).
@@ -29,9 +31,10 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// A client request for a body's generation layers (D-225).
///
/// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item`
/// currently runs the cascade through `CascadeLayer::Settlement` unconditionally,
/// ignoring this field. Wiring per-request depth (and the partial caching it
/// implies) is deferred to #1021.
/// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::RoadGraph`
/// (the terminal layer, T-1038) unconditionally on every request, ignoring this
/// field. Wiring per-request depth (and the partial caching it implies) is
/// deferred to #1021.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerRequest {
pub body_id: String,
@@ -67,7 +70,8 @@ pub struct DistrictGridLayer {
}
/// A layer response: the computed `Layer1Output` + the coarse district grid
/// (D-225, T-1046), or a non-ready status.
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
/// a non-ready status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerResponse {
pub body_id: String,
@@ -76,6 +80,15 @@ pub struct AtlasLayerResponse {
/// The coarse district/morphology grid for the Atlas overlay (T-1046).
/// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise.
pub district_grid: Option<DistrictGridLayer>,
/// The inter-settlement road/rail graph overlay (T-960 §1, T-1038).
/// `Some` on a cache hit once the RoadGraph layer has run; `None` otherwise
/// (including a body with zero placed settlements — an empty graph has no
/// nodes to draw, so it collapses to `None` the same way `district_grid`
/// does for an unrun layer).
pub road_graph: Option<RoadGraphLayer>,
/// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit
/// once the Settlement layer has placed at least one city; `None` otherwise.
pub settlements: Option<SettlementLayer>,
}
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
@@ -111,6 +124,173 @@ pub fn build_district_grid(
})
}
// ---------------------------------------------------------------------------
// RoadGraphLayer (T-960 §1, T-1038)
// ---------------------------------------------------------------------------
/// One node in the [`RoadGraphLayer`] overlay — a settlement junction or a
/// waypoint. Trimmed from the internal [`crate::atlas::road_graph::RoadNode`]:
/// `degree` and `parent_edge` are internal bookkeeping a planetary-map overlay
/// doesn't need (degree is trivially re-derivable client-side by counting
/// edges per node index if a renderer wants junction highlighting).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphNode {
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
/// space as `Layer1Output` attractors/rivers and `SettlementLayer` positions.
pub position: (u16, u16),
pub kind: RoadNodeKind,
/// The settlement's `city_id` (cross-references `SettlementLayer`), or
/// `None` for a waypoint.
pub city_id: Option<u64>,
}
/// One edge in the [`RoadGraphLayer`] overlay — a routed road or rail segment.
/// Trimmed from [`crate::atlas::road_graph::RoadEdge`]: `length_cells` is an
/// internal A* routing-grid measure with no meaning outside that grid's scale
/// (the polyline `path` is what an overlay actually draws).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphEdge {
/// `nodes` indices of the endpoint settlements (`from < to`).
pub from: usize,
pub to: usize,
/// Routed polyline in working-heightmap-grid coordinates `(row, col)`.
pub path: Vec<(u16, u16)>,
pub maintenance: MaintenanceAuthority,
/// `true` if this edge is a railroad; `false` is a road.
pub is_rail: bool,
/// Joined `systems.db` named-route id, if any (empty pool today — D-223).
pub named_route_id: Option<String>,
}
/// The inter-settlement road/rail graph, trimmed for the Atlas planetary-map
/// overlay (T-960 §1, D-211, T-1038). See [`RoadGraphNode`]/[`RoadGraphEdge`]
/// for what was dropped from the internal `RoadGraph`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphLayer {
pub nodes: Vec<RoadGraphNode>,
pub edges: Vec<RoadGraphEdge>,
}
/// Build the [`RoadGraphLayer`] from a body's cached state (T-960 §1).
/// Returns `None` when the RoadGraph layer has not run, which coincides
/// exactly with "no settlements placed" (`build_road_graph` returns an empty
/// graph for zero placements, and every placement yields at least one node).
pub fn build_road_graph_layer(state: &BodyWorldState) -> Option<RoadGraphLayer> {
if state.road_graph.nodes.is_empty() {
return None;
}
let nodes = state
.road_graph
.nodes
.iter()
.map(|n| RoadGraphNode {
position: n.position,
kind: n.kind,
city_id: n.city_id,
})
.collect();
let edges = state
.road_graph
.edges
.iter()
.map(|e| RoadGraphEdge {
from: e.from,
to: e.to,
path: e.path.clone(),
maintenance: e.maintenance,
is_rail: e.is_rail,
named_route_id: e.named_route_id.clone(),
})
.collect();
Some(RoadGraphLayer { nodes, edges })
}
// ---------------------------------------------------------------------------
// SettlementLayer (T-960 §2, #955)
// ---------------------------------------------------------------------------
/// Coarse settlement size class for the Atlas overlay (T-960 §2), derived from
/// raw population using the same Tier A/B population cutoffs the D-211
/// placement pipeline already uses (`attractor_matching::match_cities`):
/// Tier A (≥ 1,000,000 or `NameLocked`) settlements are `Major`, Tier B
/// (50,000–999,999) are `Standard`, and everything else (Tier C / synthetic
/// overflow) is `Minor`. A display bucket, not new simulation truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettlementSizeClass {
Major,
Standard,
Minor,
}
impl SettlementSizeClass {
/// Bucket a raw population using the D-211 Tier A/B cutoffs.
pub fn from_population(population: i64) -> Self {
if population >= 1_000_000 {
SettlementSizeClass::Major
} else if population >= 50_000 {
SettlementSizeClass::Standard
} else {
SettlementSizeClass::Minor
}
}
}
/// One placed settlement in the [`SettlementLayer`] overlay (T-960 §2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettlementEntry {
pub city_id: u64,
pub name: String,
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
/// space as `Layer1Output` attractors/rivers (T-960 §2: match the
/// coordinate convention layer1 features already use so the client
/// transforms identically).
pub position: (u16, u16),
pub size_class: SettlementSizeClass,
/// Authored `atlas_city_names.kind == 'capital'` (not population-derived).
pub is_capital: bool,
/// Cheap derived flag: `true` if the settlement's anchoring attractor is
/// water-adjacent (`CoastalAccess` / `RiverMouth` / `LakeShore`).
pub is_port: bool,
}
/// The settlement-placement overlay for one body (T-960 §2, #955, D-211).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettlementLayer {
pub settlements: Vec<SettlementEntry>,
}
/// `true` for the water-adjacent attractor types a settlement counts as a
/// "port" for cheaply (T-960 §2). No "foothold" flag: unlike `is_port`, there
/// is no existing concept in the placement data this could derive from
/// without inventing new business logic — left out (see the T-960 report).
fn is_port_attractor(at: AttractorType) -> bool {
matches!(
at,
AttractorType::CoastalAccess | AttractorType::RiverMouth | AttractorType::LakeShore
)
}
/// Build the [`SettlementLayer`] from a body's cached state (T-960 §2).
/// Returns `None` when the Settlement layer has not placed any city yet.
pub fn build_settlement_layer(state: &BodyWorldState) -> Option<SettlementLayer> {
if state.placements.is_empty() {
return None;
}
let settlements = state
.placements
.iter()
.map(|p| SettlementEntry {
city_id: p.city_id,
name: p.name.clone(),
position: p.position,
size_class: SettlementSizeClass::from_population(p.population),
is_capital: p.is_capital,
is_port: is_port_attractor(p.attractor_type),
})
.collect();
Some(SettlementLayer { settlements })
}
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
/// `world_seed` derives the body's `SeedChain` for the enqueued analysis.
///
@@ -154,11 +334,15 @@ pub fn handle_atlas_request(
district_basin_dirs: std::collections::BTreeMap::new(),
};
let district_grid = build_district_grid(state);
let road_graph = build_road_graph_layer(state);
let settlements = build_settlement_layer(state);
return AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Ready,
layer1: Some(layer1),
district_grid,
road_graph,
settlements,
};
}
@@ -229,6 +413,8 @@ pub fn handle_atlas_request(
status: AtlasLayerStatus::Pending,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
}
}
// Unknown / no terrain → re-requesting won't help.
@@ -238,12 +424,16 @@ pub fn handle_atlas_request(
status: AtlasLayerStatus::NotFound,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
Err(e) => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Error(e.to_string()),
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
}
}
@@ -317,6 +507,236 @@ mod tests {
assert!(build_district_grid(&state).is_none());
}
/// A blank `BodyWorldState` for tests that only care about one field —
/// callers overwrite `placements`/`road_graph`/etc. as needed.
fn blank_state(body_id: &str) -> BodyWorldState {
BodyWorldState {
body_id: body_id.into(),
heightmap: vec![],
heightmap_width: 16,
heightmap_height: 8,
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
districts: std::collections::BTreeMap::new(),
last_accessed: 0,
}
}
/// T-960 §1: `build_road_graph_layer` trims the internal `RoadGraph` (drops
/// `degree`/`parent_edge`/`length_cells`) while keeping everything a
/// planetary-map overlay needs (positions, kind, polyline, maintenance,
/// rail flag, named-route id).
#[test]
fn road_graph_layer_built_from_cached_state() {
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
use crate::simulation::generator::MaintenanceAuthority;
let mut state = blank_state("GJ1c");
state.road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(1),
position: (10, 20),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
},
RoadNode {
city_id: None,
position: (15, 25),
kind: RoadNodeKind::Waypoint,
degree: 0,
parent_edge: Some(0),
},
],
edges: vec![RoadEdge {
from: 0,
to: 1,
path: vec![(10, 20), (15, 25)],
length_cells: 20, // internal routing-grid measure — dropped
maintenance: MaintenanceAuthority::Trade,
named_route_id: Some("split/hwy-1".into()),
is_rail: true,
}],
};
let layer = build_road_graph_layer(&state).expect("populated road_graph → Some");
assert_eq!(layer.nodes.len(), 2);
assert_eq!(layer.nodes[0].position, (10, 20));
assert_eq!(layer.nodes[0].kind, RoadNodeKind::Settlement);
assert_eq!(layer.nodes[0].city_id, Some(1));
assert_eq!(layer.nodes[1].kind, RoadNodeKind::Waypoint);
assert_eq!(layer.nodes[1].city_id, None);
assert_eq!(layer.edges.len(), 1);
assert_eq!(layer.edges[0].path, vec![(10, 20), (15, 25)]);
assert_eq!(layer.edges[0].maintenance, MaintenanceAuthority::Trade);
assert!(layer.edges[0].is_rail);
assert_eq!(
layer.edges[0].named_route_id.as_deref(),
Some("split/hwy-1")
);
// Layer hasn't run (or zero settlements) → None, mirroring district_grid.
let unrun = blank_state("GJ1c");
assert!(build_road_graph_layer(&unrun).is_none());
}
/// T-960 §2: `build_settlement_layer` derives `size_class` from population
/// using the D-211 Tier A/B cutoffs, threads `is_capital` straight through,
/// and derives `is_port` cheaply from the anchoring attractor type.
#[test]
fn settlement_layer_built_from_cached_placements() {
use crate::atlas::attractor_matching::CityPlacement;
use crate::simulation::generator::{
ArrangementPattern, FoundingOrientation, PoliticalArchetype,
};
let mk = |city_id: u64,
name: &str,
pos: (u16, u16),
population: i64,
is_capital: bool,
attractor_type: AttractorType| CityPlacement {
city_id,
name: name.to_string(),
position: pos,
attractor_type,
score: 1000,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population,
is_capital,
};
let mut state = blank_state("GJ1c");
state.placements = vec![
mk(
1,
"Port Aldren",
(12, 58),
2_000_000,
true,
AttractorType::CoastalAccess,
),
mk(
2,
"Millbrook",
(30, 40),
200_000,
false,
AttractorType::ValleyFloor,
),
mk(
3,
"Farmstead Rell",
(50, 60),
8_000,
false,
AttractorType::PlainCenter,
),
];
let layer = build_settlement_layer(&state).expect("populated placements → Some");
assert_eq!(layer.settlements.len(), 3);
let capital = layer.settlements.iter().find(|s| s.city_id == 1).unwrap();
assert_eq!(capital.name, "Port Aldren");
assert_eq!(capital.position, (12, 58));
assert_eq!(capital.size_class, SettlementSizeClass::Major);
assert!(capital.is_capital);
assert!(capital.is_port, "CoastalAccess must read as a port");
let mid = layer.settlements.iter().find(|s| s.city_id == 2).unwrap();
assert_eq!(mid.size_class, SettlementSizeClass::Standard);
assert!(!mid.is_capital);
assert!(!mid.is_port, "ValleyFloor is not a port attractor");
let small = layer.settlements.iter().find(|s| s.city_id == 3).unwrap();
assert_eq!(small.size_class, SettlementSizeClass::Minor);
assert!(!small.is_port);
// No placements → None.
let unrun = blank_state("GJ1c");
assert!(build_settlement_layer(&unrun).is_none());
}
/// T-960: the new layers survive a MessagePack round trip inside
/// `AtlasLayerResponse` — the same wire path the bridge uses
/// (`rmp_serde::to_vec_named` / `from_slice`, matching `layer1`/
/// `district_grid`'s existing serialization).
#[test]
fn atlas_layer_response_with_new_layers_round_trips_msgpack() {
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
use crate::simulation::generator::{
ArrangementPattern, FoundingOrientation, MaintenanceAuthority, PoliticalArchetype,
};
let mut state = blank_state("GJ1c");
state.placements = vec![CityPlacement {
city_id: 1,
name: "Port Aldren".into(),
position: (12, 58),
attractor_type: AttractorType::CoastalAccess,
score: 1000,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population: 2_000_000,
is_capital: true,
}];
state.road_graph = RoadGraph {
nodes: vec![RoadNode {
city_id: Some(1),
position: (12, 58),
kind: RoadNodeKind::Settlement,
degree: 0,
parent_edge: None,
}],
edges: vec![RoadEdge {
from: 0,
to: 0,
path: vec![(12, 58)],
length_cells: 0,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
}],
};
let resp = AtlasLayerResponse {
body_id: "GJ1c".into(),
status: AtlasLayerStatus::Ready,
layer1: None,
district_grid: None,
road_graph: build_road_graph_layer(&state),
settlements: build_settlement_layer(&state),
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.body_id, "GJ1c");
let rg = decoded.road_graph.expect("road_graph survives round trip");
assert_eq!(rg.nodes[0].position, (12, 58));
assert_eq!(rg.edges[0].maintenance, MaintenanceAuthority::Administrative);
let settlements = decoded.settlements.expect("settlements survives round trip");
assert_eq!(settlements.settlements[0].name, "Port Aldren");
assert_eq!(
settlements.settlements[0].size_class,
SettlementSizeClass::Major
);
assert!(settlements.settlements[0].is_capital);
assert!(settlements.settlements[0].is_port);
}
fn req(body_id: &str) -> AtlasLayerRequest {
AtlasLayerRequest {
body_id: body_id.to_string(),