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
+152 -2
View File
@@ -13,6 +13,9 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
use std::collections::BTreeMap;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
@@ -37,7 +40,10 @@ use crate::atlas::trait_draw::{
use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer,
StarMapRequestBuffer, StarMapResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
@@ -57,7 +63,12 @@ impl Plugin for GenerationPlugin {
Update,
drain_generation_completions.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput));
.add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput))
.add_systems(Update, serve_star_map_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
);
}
}
@@ -100,12 +111,58 @@ fn serve_atlas_requests(
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
};
responses.0.push(resp);
}
}
/// Drain inbound star-map requests and serve each through the proxy (T-949a).
/// A thin read-the-file-fresh proxy — see `atlas_data_proxy` module doc for
/// why there's no caching. Absent `StarMapDataPath` (not wired at startup,
/// e.g. unit tests) reports an error per request rather than panicking.
fn serve_star_map_requests(
mut requests: ResMut<StarMapRequestBuffer>,
mut responses: ResMut<StarMapResponseBuffer>,
path: Option<Res<StarMapDataPath>>,
) {
if requests.0.is_empty() {
return;
}
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
let resp = match path.as_ref() {
Some(p) => handle_star_map_request(&req, &p.0),
None => crate::atlas::atlas_data_proxy::StarMapResponse {
status: crate::atlas::atlas_data_proxy::StarMapStatus::Error(
"star map data path unavailable".to_string(),
),
data: None,
},
};
responses.0.push(resp);
}
}
/// Drain inbound city-names requests and serve each through the proxy
/// (T-949b): D-236 Sol check, then the names-only `atlas_city_names` read.
fn serve_city_names_requests(
mut requests: ResMut<CityNamesRequestBuffer>,
mut responses: ResMut<CityNamesResponseBuffer>,
city_reader: Option<Res<CityContextReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = city_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
responses.0.push(handle_city_names_request(&req, reader));
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
@@ -798,6 +855,87 @@ mod tests {
assert!(world.resource::<AtlasRequestBuffer>().0.is_empty());
}
/// T-949a: the star-map serve system reads the wired `StarMapDataPath`
/// through to a `Ready` response end-to-end.
#[test]
fn serve_star_map_drains_requests_into_responses() {
use crate::atlas::atlas_data_proxy::{StarMapDataPath, StarMapRequest, StarMapStatus};
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"sr_plugin_starmap_{}_{n}.json",
std::process::id()
));
std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap();
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
world.insert_resource(StarMapDataPath(path.clone()));
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].status, StarMapStatus::Ready);
assert!(world.resource::<StarMapRequestBuffer>().0.is_empty());
let _ = std::fs::remove_file(&path);
}
/// Without `StarMapDataPath` wired (e.g. a stripped-down test world), the
/// serve system reports `Error` per request rather than panicking.
#[test]
fn serve_star_map_without_path_resource_is_error() {
use crate::atlas::atlas_data_proxy::{StarMapRequest, StarMapStatus};
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
// No StarMapDataPath resource.
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert!(matches!(responses.0[0].status, StarMapStatus::Error(_)));
}
/// T-949b: without `CityContextReaderResource` wired, the serve system
/// reports `Error` per request (mirrors the atlas-request "no resolver"
/// convention) rather than panicking.
#[test]
fn serve_city_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{CityNamesRequest, CityNamesStatus};
let mut world = World::new();
world.insert_resource(CityNamesRequestBuffer(vec![CityNamesRequest {
city_names: true,
body_id: "GJ1c".to_string(),
}]));
world.insert_resource(CityNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_city_names_requests);
sched.run(&mut world);
let responses = world.resource::<CityNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].body_id, "GJ1c");
assert!(matches!(responses.0[0].status, CityNamesStatus::Error(_)));
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
@@ -838,6 +976,7 @@ mod tests {
fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: (10, 20),
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -845,6 +984,8 @@ mod tests {
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
}
}
@@ -856,6 +997,7 @@ mod tests {
) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: (10, 20),
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -863,6 +1005,8 @@ mod tests {
political_archetype: archetype,
arrangement_pattern: arrangement,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
}
}
@@ -1308,6 +1452,7 @@ mod tests {
// what attractor_matching::match_cities would have stored at L3.
let l3_placement = CityPlacement {
city_id: 1,
name: "City1".into(),
position: (0, 0),
attractor_type: AttractorType::PlainCenter,
score: 100,
@@ -1315,6 +1460,8 @@ mod tests {
political_archetype: *archetype,
arrangement_pattern: *expected_pattern,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
};
assert_eq!(
@@ -1633,6 +1780,7 @@ mod tests {
let placement = CityPlacement {
city_id: 5,
name: "City5".into(),
position: city_pos,
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -1640,6 +1788,8 @@ mod tests {
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
};
let GenWorkItem::GenerateSkeleton {