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:
+152
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user