Files
settled-reach/server/src/atlas/plugin.rs
T
jpmschweitzerandClaude Fable 5 8da9670e0f feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)
One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).

T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:10:27 +02:00

2342 lines
97 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 tier plugin (#968, D-206) — wires the background generation queue
//! and the per-body world-state cache into the running app.
//!
//! Registers [`GenerationQueue`] and [`BodyWorldStateCache`] as resources and
//! adds a `PreInput` system that drains completed work each tick and inserts the
//! computed [`BodyWorldState`](crate::atlas::body_world_state::BodyWorldState)
//! into the cache. The queue's *submitter* is the atlas layer-stream proxy
//! (#969, D-225); this plugin closes the submit→Rayon→cascade→drain→cache loop.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_feature_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};
use crate::atlas::browse_proxy::handle_browse_request;
use crate::atlas::browse_reader::BrowseReaderResource;
use crate::atlas::city_context_reader::{
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
};
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::district_profile::{self, BodyParams, DistrictPos};
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer_proxy::{
handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache,
DISTRICT_WINDOW_CACHE_CAPACITY,
};
use crate::atlas::road_graph::{RoadGraph, RoadNode};
use crate::atlas::scale;
use crate::atlas::skeleton_gen::derive_complexity;
use crate::atlas::source_resolver::BodySourceResolverResource;
use crate::atlas::step_canvas::{
serve_step_canvas_request, GlobalTierCache, StepCanvasCache, StepCanvasResponse,
StepCanvasStatus, STEP_CANVAS_CACHE_CAPACITY,
};
use crate::atlas::trait_catalog_reader::{
ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate,
};
use crate::atlas::trait_draw::{
complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type,
VocabularyDrawInputs,
};
use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, FeatureNamesRequestBuffer,
FeatureNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, ProductionUbiquity, WorldTier,
};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::tick_phases::TickPhase;
/// Wires the D-206 background generation tier into the app (#968).
pub struct GenerationPlugin;
impl Plugin for GenerationPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(GenerationQueue::new())
.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY))
.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY))
.insert_resource(GlobalTierCache::new())
.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY))
.add_systems(
Update,
drain_generation_completions.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),
)
.add_systems(
Update,
serve_feature_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
serve_step_canvas_requests.in_set(TickPhase::PreInput),
);
}
}
/// Drain inbound atlas layer requests and serve each through the proxy (#969,
/// D-225): cache hit → Ready, miss → resolve + enqueue + Pending. Responses are
/// buffered for the bridge to flush in `PostSnapshot`.
///
/// `window_cache` serves the optional district-window query (D-226 T-1124
/// amendment, T-1137) — see `handle_atlas_request`/`serve_district_window`.
/// Unlike the rest of `handle_atlas_request`, the window path IS
/// connection-aware (its coalescing key), so `conn_id` — already threaded
/// through this loop for response routing (D-254 §2) — is passed one level
/// further in for that one purpose only.
fn serve_atlas_requests(
mut requests: ResMut<AtlasRequestBuffer>,
mut responses: ResMut<AtlasResponseBuffer>,
mut cache: ResMut<BodyWorldStateCache>,
mut window_cache: ResMut<DistrictWindowCache>,
queue: Res<GenerationQueue>,
resolver: Option<Res<BodySourceResolverResource>>,
city_reader: Option<Res<CityContextReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
if requests.0.is_empty() {
return;
}
let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0);
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
let reader = city_reader.as_ref().map(|r| &r.0);
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
// D-254 §2: 1:1, in-order request->response — the connection id rides
// alongside the request untouched by handle_atlas_request (which has no
// notion of connections) and is re-attached to the response so the
// bridge's send_atlas_responses routes it back to only that connection.
for (conn_id, req) in pending {
let resp = match resolver.as_ref() {
Some(r) => handle_atlas_request(
&req,
&mut cache,
&mut window_cache,
&queue,
&r.0,
reader,
params_reader,
world_seed,
tick,
conn_id,
),
None => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
district_window: None,
quarter_footprints: None,
},
};
responses.0.push((conn_id, 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 (conn_id, 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((conn_id, 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 (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_city_names_request(&req, reader)));
}
}
/// Drain inbound feature-names requests and serve each through the proxy
/// (T-1169): D-236 Sol check, then the names-only `atlas_feature_names` read.
/// Mirrors [`serve_city_names_requests`] exactly.
fn serve_feature_names_requests(
mut requests: ResMut<FeatureNamesRequestBuffer>,
mut responses: ResMut<FeatureNamesResponseBuffer>,
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 (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_feature_names_request(&req, reader)));
}
}
/// Drain inbound data-browser requests and serve each through the proxy
/// (D-254 §4, T-1131): one of the six v1 registry-tier entity kinds, dispatched
/// to `BrowseReader` by `(kind, query)`.
///
/// `pub` (unlike its atlas/star-map/city-names siblings, which stay private)
/// so `server/tests/bridge_tcp.rs`'s browse integration tests can drive the
/// TRUE full pipeline (demux -> receive_bridge_inputs -> BrowseRequestBuffer
/// -> serve_browse_requests -> BrowseResponseBuffer -> send_browse_responses)
/// end-to-end via `RunSystemOnce`, rather than bypassing this system the way
/// `reader_receives_tagged_star_map_response` bypasses `serve_star_map_requests`
/// (see that test's own doc comment) because it has no way to call it.
pub fn serve_browse_requests(
mut requests: ResMut<BrowseRequestBuffer>,
mut responses: ResMut<BrowseResponseBuffer>,
browse_reader: Option<Res<BrowseReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = browse_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_browse_request(&req, reader)));
}
}
/// Drain inbound step-canvas requests and serve each through the proxy
/// (T-1181, D-255(c)/(d)): Global rung → the always-keep `GlobalTierCache`;
/// every fixed rung → the dual-axis-evicted `StepCanvasCache`. Cache hit →
/// Ready, miss → resolve + enqueue (`GenWorkItem::DeriveStepCanvas`) +
/// Pending — same D-225 poll/cache/enqueue model `serve_atlas_requests`
/// already uses for `district_window`.
fn serve_step_canvas_requests(
mut requests: ResMut<StepCanvasRequestBuffer>,
mut responses: ResMut<StepCanvasResponseBuffer>,
mut global_cache: ResMut<GlobalTierCache>,
mut canvas_cache: ResMut<StepCanvasCache>,
body_state_cache: Res<BodyWorldStateCache>,
queue: Res<GenerationQueue>,
resolver: Option<Res<BodySourceResolverResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
if requests.0.is_empty() {
return;
}
let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0);
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
// Read-only lookup (peek, no LRU bump — this proxy is not the
// canonical "this body was visited" signal, serve_atlas_requests'
// own cache.get already owns that) for settlement_id coverage
// (step_canvas::serve_step_canvas_request's doc). Empty when the
// body isn't cached yet or has no placements — settlement_id then
// reads all-zero on the derived canvas, not an error.
let placements: &[crate::atlas::attractor_matching::CityPlacement] = body_state_cache
.peek(&req.body_id)
.map(|s| s.placements.as_slice())
.unwrap_or(&[]);
let resp = match resolver.as_ref() {
Some(r) => serve_step_canvas_request(
&req,
global_cache.as_mut(),
canvas_cache.as_mut(),
&queue,
&r.0,
params_reader,
placements,
world_seed,
tick,
conn_id,
),
None => StepCanvasResponse {
body_id: req.body_id.clone(),
rung: req.rung,
center: req.center,
// Nothing was derived — echo (0, 0) rather than the raw
// wire extent, matching serve_step_canvas_request's own
// Global-rung convention for "no meaningful extent to
// report" (PR #201 review, Hoshe finding 1).
extent: (0, 0),
min_wl_m: req.min_wl_m,
status: StepCanvasStatus::Error("no body source resolver".to_string()),
canvas: None,
},
};
responses.0.push((conn_id, resp));
}
}
/// 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
/// channel drain + cache insert, never the ~45 ms cascade itself.
fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
mut window_cache: ResMut<DistrictWindowCache>,
mut global_tier_cache: ResMut<GlobalTierCache>,
mut step_canvas_cache: ResMut<StepCanvasCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
for completion in queue.drain_completions() {
match completion {
GenCompletion::BodyAnalyzed { state, .. } => {
// L3→L4 dispatch (T-1022, D-234): without a production call site the
// Layer-4 quarter skeleton never runs — morphology-correct streets and
// the D-234b waterfront rule stay dormant and `founding_orientation` is
// stuck at the `context_from_read_set` Cardinal stub. Submit one
// GenerateSkeleton per placed settlement, threading the attractor-matched
// orientation (D-213). Needs the city-context reader + world seed; absent
// either (e.g. unit tests), skip dispatch and just cache the body.
if let (Some(city_reader), Some(rng)) = (city_reader.as_ref(), rng.as_ref()) {
let reader = &city_reader.0;
let world_seed = rng.seed();
let body_id = state.body_id.clone();
// D-256(d): body physical params, re-read here (same pattern
// as `serve_atlas_requests`'s `params_reader` — a cheap DB
// row read on the main thread, mirroring the `AnalyzeBody`
// dispatch-time precedent at T-1023's original call site) so
// `run_work_item`'s exact-position morphology_zone derive
// has the settlement's body radius. `None` on a read
// failure or absent reader — the exact-position resolution
// then skips and `context.morphology_zone` stays at its
// `AlluvialPlain` stub (same fallback as an empty district
// grid pre-D-256).
let dispatch_body_params: Option<Box<BodyParams>> = match body_params_reader
.as_ref()
{
Some(reader) => reader
.0
.read_body_params(&body_id)
.map(Box::new)
.map(Some)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %body_id,
error = %e,
"L3→L4 dispatch: body_params read failed — morphology_zone stays AlluvialPlain"
);
None
}),
None => None,
};
// D-256(d): shared per-body heightmap for `run_work_item`'s
// `TerrainAnalysisCache::get_or_derive` — built once from
// data already in memory (no disk re-read), `Arc`'d so every
// settlement dispatched below clones a pointer.
let dispatch_heightmap =
std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap {
body_id: body_id.clone(),
width: state.heightmap_width,
height: state.heightmap_height,
data: state.heightmap.clone(),
sea_level: state.sea_level,
});
// ── T-994 (D-232): body-level aggregation for the phase-1
// trait-vocabulary K-draw ───────────────────────────────────
// Read each placement's D-199 read-set once — reused both to
// build the body-wide coverage aggregate here and to build
// that placement's own skeleton work item below, so this
// dispatch pass makes exactly one `read_set` DB round trip per
// settlement (same as before this ticket). The aggregation
// math itself is the pure `aggregate_body_dispatch_inputs`
// (unit-tested directly, PR #173 review H1).
let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new();
for placement in &state.placements {
match reader.read_set(placement.city_id, world_seed) {
Ok(rs) => resolved.push((placement, rs)),
Err(e) => {
tracing::warn!(
city_id = placement.city_id,
body_id = %body_id,
error = %e,
"L3→L4 dispatch: read_set failed — skipping placement"
);
}
}
}
let BodyDispatchAggregates {
body_district_type_mix,
max_prosperity_bps,
max_k,
} = aggregate_body_dispatch_inputs(&resolved, world_seed, &body_id);
// Phase-1 K-draw (D-232): computed once, shared by every
// settlement on this body — the closed-vocabulary invariant.
// An absent reader (tests) degrades to an empty catalog:
// draw/pools/eligible all no-op identically.
let (catalog, bias): (Vec<TraitTemplate>, Vec<TraitBias>) =
match trait_catalog.as_ref() {
Some(tc) => (
tc.0.read_catalog().unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait catalog read failed — empty vocabulary");
Vec::new()
}),
tc.0.read_body_bias(&body_id).unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait bias read failed — no bias applied");
Vec::new()
}),
),
None => (Vec::new(), Vec::new()),
};
// D-235 exterior-grammar content (T-988), read alongside the
// D-232 catalog above — same L3→L4 dispatch-time rationale
// (`assign_block_tags` stays DB-free downstream, T-987/D-230).
// `templates` reuses the already-fetched `catalog` (itself
// OnceLock-cached inside the reader) rather than re-querying.
let exterior_catalog: ExteriorCatalog = match trait_catalog.as_ref() {
Some(tc) => ExteriorCatalog {
templates: catalog.clone(),
zone_bias: tc.0.read_zone_bias().unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "zone bias read failed — uniform draw everywhere");
BTreeMap::new()
}),
color_bands: tc.0.read_color_register_bands().unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "color register bands read failed — neutral color everywhere");
BTreeMap::new()
}),
},
None => ExteriorCatalog::default(),
};
let body_sector: Option<&str> = resolved
.first()
.and_then(|(_, rs)| rs.geographic_sector.as_deref());
// dominant_bulk_class/dominant_production_ubiquity are
// #982 design-blocked stubs (always NonPhysical/Common) —
// see CityGenerationContext's field docs.
let inputs = VocabularyDrawInputs {
k: max_k,
dominant_bulk_class: &BulkClass::NonPhysical,
dominant_production_ubiquity: &ProductionUbiquity::Common,
max_prosperity_bps,
geographic_sector: body_sector,
coverage_district_types: &body_district_type_mix,
};
// Hard-gate-eligible pool — shared by the K-draw, the T-1003
// swerve pools, and the phase-2 necessity escape hatch (the
// swerve is cultural-only; the D-233 gates always hold).
let eligible = hard_gate_eligible(&catalog, &inputs);
let trait_selection = draw_body_vocabulary(
&catalog,
&bias,
&inputs,
SeedChain::for_body(world_seed, &body_id),
);
let swerve_pools = build_swerve_pools(&eligible, &trait_selection, body_sector);
let vocab = BodyVocabularyContext {
trait_selection: &trait_selection,
body_district_type_mix: &body_district_type_mix,
catalog: &catalog,
eligible: &eligible,
swerve_pools: &swerve_pools,
exterior_catalog: &exterior_catalog,
};
for (placement, read_set) in resolved {
queue.submit(
build_skeleton_work_item(
&body_id,
world_seed,
placement,
read_set,
state.heightmap_width,
state.heightmap_height,
dispatch_body_params.as_deref(),
&state.road_graph,
&vocab,
Arc::clone(&dispatch_heightmap),
),
GenPriority::Low,
);
}
}
cache.insert(*state);
}
GenCompletion::Failed { item, reason } => {
tracing::warn!(?item, %reason, "background generation work item failed");
}
// Insert district world state into the matching body's cache entry (D-230).
GenCompletion::SkeletonGenerated {
city_id,
body_id,
state,
} => {
if !body_id.is_empty() {
if let Some(body_state) = cache.peek_mut(&body_id) {
// Key by state.skeleton.quarter_id (D-194/D-230): a city has many
// quarters, each with its own QuarterId. `city_id` is only the
// dispatch key used in the work item — the canonical insert key is
// the quarter's own stable id.
let _ = city_id; // used as dispatch key only; quarter_id is the map key
body_state
.quarters
.insert(state.skeleton.quarter_id, *state);
} else {
tracing::warn!(
city_id,
body_id,
"SkeletonGenerated: body not in cache — district state dropped"
);
}
}
// body_id empty = stub result from GenerateSkeleton stub; silently ignore.
}
GenCompletion::ChunkFilled { filled } => {
// The shell is derived (D-230, T-987). There is no consumer on the
// main thread yet: in-world rendering of generated tiles is Phase 5
// (gated by T-962), and the on-demand *dispatch* trigger — enqueueing
// FillChunk as the player's load radius enters a chunk — lives in the
// Phase-5 streaming path, which must not be built on the legacy
// `chunk_streaming.rs` rendering code before then (CLAUDE.md cascade
// rule). FillChunk is re-derivable on demand (D-227), so dropping the
// result here costs nothing structural; we only trace it for now.
tracing::trace!(
quarter_id = filled.quarter_id,
chunk = ?filled.chunk_in_quarter(),
voxels = filled.voxel_count(),
"FillChunk derived (no Phase-5 consumer yet)"
);
}
GenCompletion::WindowDerived { body_id, layer } => {
// D-226 T-1124 amendment, T-1137: cache the completed window —
// NOT pushed into any in-flight response (this drain has no
// notion of which connection(s) are waiting). The requester's
// NEXT poll (the existing D-225 re-request loop) hits
// `handle_atlas_request`'s window branch, which finds this
// entry via `DistrictWindowCache::get` and serves it.
window_cache.insert(
(
body_id,
layer.center,
layer.n,
layer.granularity_v2,
layer.min_wl_m,
),
*layer,
);
}
GenCompletion::StepCanvasDerived {
body_id,
rung,
center,
extent,
min_wl_m,
canvas,
} => {
// T-1181, D-255(d): cache the completed canvas — NOT pushed
// into any in-flight response (same re-poll-and-hit-cache
// model WindowDerived above uses). Global (rung 0) goes to
// the always-keep GlobalTierCache; every fixed rung goes to
// the dual-axis-evicted StepCanvasCache.
if rung.is_global() {
global_tier_cache.as_mut().insert(body_id, *canvas);
} else {
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
step_canvas_cache.as_mut().insert(
(body_id, rung, center, extent, min_wl_m),
*canvas,
tick,
);
}
}
}
}
}
/// Body-level aggregates feeding the phase-1 K-draw (T-994), computed over the
/// successfully-resolved placements of one body.
struct BodyDispatchAggregates {
/// Every `DistrictType` any settlement on the body will produce, deduped
/// and deterministically ordered (BTreeSet iteration, D-010).
body_district_type_mix: Vec<DistrictType>,
/// MAX prosperity across settlements — the vocabulary gate is
/// coverage-aware (see `VocabularyDrawInputs::max_prosperity_bps`).
max_prosperity_bps: u32,
/// MAX `complexity_k` across settlements (see the `trait_draw`
/// module-level note on body-vs-settlement K).
max_k: usize,
}
/// The pure aggregation math behind the L3→L4 dispatch (T-994) — split out of
/// `drain_generation_completions` so it unit-tests without a DB, queue, or
/// Bevy world (PR #173 review H1).
fn aggregate_body_dispatch_inputs(
resolved: &[(&CityPlacement, CityEconomicReadSet)],
world_seed: u64,
body_id: &str,
) -> BodyDispatchAggregates {
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> = Default::default();
let mut max_prosperity_bps: u32 = 0;
let mut max_k: usize = 0;
for (placement, read_set) in resolved {
max_prosperity_bps = max_prosperity_bps.max(read_set.prosperity_baseline_bps);
// Re-derives the identical DistrictType mix `generate_quarter_skeleton`
// computes later for this same placement (same chain, same inputs) —
// cheap (a 16-draw seeded LCG) and gives the aggregate the *actual*
// district-type mix rather than a proxy.
let mix_chain = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::Layer4Quarter, placement.city_id);
let mix = compute_district_mix(
read_set.population,
&read_set.economic_role,
&placement.political_archetype,
16,
mix_chain,
);
body_district_type_mix.extend(mix.districts);
// world_tier has no real derivation yet (city_context_reader's #TBD
// stub, always Waypoint) — tracked here via population tier alone so a
// future world_tier derivation slots into this MAX-K aggregate without
// revisiting this loop.
let tier = derive_complexity(
&WorldTier::Waypoint,
population_tier(read_set.population),
read_set.population,
);
max_k = max_k.max(complexity_k(&tier));
}
BodyDispatchAggregates {
body_district_type_mix: body_district_type_mix.into_iter().collect(),
max_prosperity_bps,
max_k,
}
}
/// Body-level D-232 trait-vocabulary draw outputs, threaded into
/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep
/// that function's argument count under the clippy `too_many_arguments`
/// threshold — see its doc comment.
struct BodyVocabularyContext<'a> {
/// Phase-1 K-draw result (D-232), computed once per body by the caller —
/// identical for every settlement on the body (the closed-vocabulary
/// invariant). Empty when no `TraitCatalogReaderResource` is wired (tests)
/// or the body's K is 0 (`ComplexityTier::Empty` everywhere on the body).
trait_selection: &'a [String],
/// Every `DistrictType` present anywhere on the body (T-994 coverage
/// aggregate) — threaded onto `CityGenerationContext.body_district_type_mix`
/// verbatim (design point 4: visible on the context, not just consumed
/// internally by the draw).
body_district_type_mix: &'a [DistrictType],
/// The full trait-template catalog, needed to resolve phase 2
/// (`district_dominant_by_type`) for each settlement's District cell —
/// `zone_affinity` lives on the catalog row, not on `trait_selection`'s tags.
catalog: &'a [TraitTemplate],
/// Hard-gate-eligible subset of `catalog` (T-1003) — the phase-2 necessity
/// escape hatch reaches this pool when the vocabulary can't serve a district
/// type (the swerve is cultural-only; D-233 gates always hold).
eligible: &'a [&'a TraitTemplate],
/// Body-level T-1003 swerve candidate pools (foreign-import /
/// heritage-callback), cloned onto each settlement's context — the
/// per-building wildcard draws from these at `assign_block_tags` time.
swerve_pools: &'a SwervePools,
/// D-235 exterior-grammar content (T-988): the catalog's `visual_bundle`s
/// plus the two sibling content tables, read once per body and cloned
/// verbatim onto every settlement's `GenerateSkeleton` work item — the
/// per-building `BuildingExteriorTag` draw happens at `assign_block_tags`
/// time (`atlas::trait_exterior`), never inside `FillChunk`.
exterior_catalog: &'a ExteriorCatalog,
}
/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's
/// centrality (high) / isolation (low) driver input. 0 when the city has no
/// node or no edges (matching `road_entry_directions_for_city`'s fallback).
fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
let Some(idx) = road_graph
.nodes
.iter()
.position(|n: &RoadNode| n.city_id == Some(city_id))
else {
return 0;
};
road_graph
.edges
.iter()
.filter(|e| e.from == idx || e.to == idx)
.count() as u32
}
/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement
/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set
/// (mirroring
/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context)),
/// then overrides:
///
/// - `founding_orientation` — from the attractor-matched placement (D-213).
/// - `political_archetype` — from the attractor-matched placement (D-214, T-1039),
/// replacing the `Commission` stub in `context_from_read_set`.
/// - `morphology_zone` — **NOT resolved here** (D-256(d), T-1174). It stays at
/// `context_from_read_set`'s `AlluvialPlain` stub through this function; the
/// work item instead carries `settlement_world_m`/`body_params`/`body_seed`/
/// `heightmap` so `run_work_item` can resolve it via an exact-position
/// `derive_at_metres` call during execution, where `TerrainAnalysis` is
/// reachable (`BodyWorldState` drops it, D-203/T-1048) — a survey cell's
/// centre (the pre-D-256 lookup key) can be hundreds of km from a
/// settlement near the cell's edge.
/// - `road_entry_directions` — derived from `state.road_graph`: for each road edge
/// incident on this city, the compass octant (0=N…7=NW) of the bearing from the
/// city toward the far endpoint, de-duplicated per octant and ordered by descending
/// road quality so the highest-prestige entry is first (T-1043, D-215 AdminFacing
/// rule). Empty when `road_graph` has no edges for this city.
///
/// `arrangement_pattern` is **re-derived** at L4 from `(political_archetype,
/// economic_role)` via the same pure function used at L3 (T-1039 OPTION (b) —
/// locked, no `CityGenerationContext` field added). Re-derivation is provably
/// identical to the L3 value (pure total function, no RNG). The re-derivation call
/// itself lives in the consumer (`generate_quarter_skeleton`), not in this function
/// — `build_skeleton_work_item` only threads the inputs it needs.
///
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
/// city)` — not the `city_id * 10` placeholder.
///
/// `heightmap_width`/`heightmap_height` are the body's working-grid dims
/// (`BodyWorldState.heightmap_width`/`heightmap_height`) — used to convert the
/// placement's pixel position to world metres via `pixel_to_world_m` (D-256(b)'s
/// bridge function), for both `settlement_world_m` and the true `DistrictPos`
/// used by `settlement_district_pos`/`pick_district_dominant_by_type` (D-256(a):
/// `DistrictPos` canonically means the true D-243 grid — the pre-D-256
/// `heightmap_pixel_to_district` conversion actually returned a survey-raster
/// position mislabeled as a district).
///
/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994):
/// `trait_selection` (phase 1, computed once per body by the caller) and the
/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this
/// specific settlement's District cell. Bundled into one struct to keep this
/// function's argument count under the clippy `too_many_arguments` threshold.
///
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
#[allow(clippy::too_many_arguments)]
fn build_skeleton_work_item(
body_id: &str,
world_seed: u64,
placement: &CityPlacement,
read_set: CityEconomicReadSet,
heightmap_width: u32,
heightmap_height: u32,
body_params: Option<&BodyParams>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
heightmap: Arc<crate::atlas::heightmap::BodyHeightmap>,
) -> GenWorkItem {
// The D-199 raw fields ride alongside the context (generate_quarter_skeleton
// takes them separately), so capture them before context_from_read_set consumes
// the read-set.
let economic_role = read_set.economic_role.clone();
let population = read_set.population;
let founding_age_years = read_set.founding_age_years;
// T-1003 driver input (cosmopolitanism) — captured here for the same reason.
let faction_mixed = read_set.dominant_faction.as_deref() == Some("mixed");
let mut context = context_from_read_set(placement.city_id, read_set);
// ── T-1022 / D-213: founding orientation from attractor-matched placement ──
context.founding_orientation = placement.founding_orientation.clone();
// ── T-1039 / D-214: political_archetype from placement (real value) ────────
// Replaces the `Commission` stub that `context_from_read_set` leaves.
context.political_archetype = placement.political_archetype;
// ── D-256(d): settlement world metres + true DistrictPos ────────────────────
// `pixel_to_world_m` is the SAME bridge function the survey raster uses
// (D-256(b)) — converting the placement's working-grid pixel (row, col) to
// world metres, then floor-dividing by DISTRICT_M for the true D-243 cell.
// `morphology_zone` itself is NOT resolved here — see this function's doc
// and `run_work_item`'s `GenerateSkeleton` arm (D-256(d)); it stays at the
// `context_from_read_set` `AlluvialPlain` stub through this function.
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
placement.position.1 as f64,
placement.position.0 as f64,
heightmap_width as usize,
heightmap_height as usize,
body_params.and_then(|p| p.body_radius_km),
);
let district_pos: DistrictPos = (
(world_x_m / scale::DISTRICT_M as f64).floor() as i32,
(world_y_m / scale::DISTRICT_M as f64).floor() as i32,
);
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
// computed once per body by the caller (drain_generation_completions) — the
// closed-vocabulary invariant requires every settlement on the body to carry
// the identical `trait_selection`, so this function only threads it through,
// never re-derives it. Phase 2 (district_dominant_by_type) IS settlement-
// specific (keyed by this settlement's own District cell) and is resolved
// here, at dispatch time — not inside the GenerateSkeleton Rayon task, and
// never inside FillChunk (T-987 keeps fill pure/cache-free).
context.trait_selection = vocab.trait_selection.to_vec();
context.body_district_type_mix = vocab.body_district_type_mix.to_vec();
context.settlement_district_pos = district_pos;
context.district_dominant_by_type = pick_district_dominant_by_type(
vocab.catalog,
vocab.eligible,
vocab.trait_selection,
SeedChain::for_body(world_seed, body_id),
district_pos,
);
// ── T-1003 / D-232: deviation/swerve driver rates + candidate pools ─────────
// Pools are body-level (same eligible catalog + vocabulary everywhere on the
// body); the driver RATES are per-settlement — centrality/isolation from the
// road graph, cosmopolitanism from the faction read, conservatism from
// founding age. `context.world_tier` rides the reader's Waypoint stub today
// (same caveat as the K aggregation) — Epicenter/Passage multipliers activate
// once a real derivation lands.
let drivers = SwerveDrivers {
world_tier: &context.world_tier,
faction_mixed,
road_degree: road_degree_for_city(placement.city_id, road_graph),
founding_age_years,
};
let rates = compute_swerve_rates(&drivers);
context.swerve_rates_bps = (rates.foreign_bps, rates.heritage_bps);
context.swerve_foreign_pool = vocab.swerve_pools.foreign.clone();
context.swerve_heritage_pool = vocab.swerve_pools.heritage.clone();
// ── T-1043: road_entry_directions from road_graph ───────────────────────────
// Find this city's settlement node index in the road graph (O(n) scan on a
// small slice — settlement counts are single-digit to low hundreds per body).
context.road_entry_directions =
road_entry_directions_for_city(placement.city_id, placement.position, road_graph);
// ── Canonical quarter id (D-194/D-230) ────────────────────────────────────
// Deterministic + namespace-isolated per (world_seed, body, city).
// SeedChain is Copy, so `chain.seed()` leaves `chain` usable for the work item.
let body_seed = SeedChain::for_body(world_seed, body_id);
let chain = body_seed.derive(SeedDomain::Layer4Quarter, placement.city_id);
let quarter_id = chain.seed();
GenWorkItem::GenerateSkeleton {
city_id: placement.city_id,
body_id: body_id.to_string(),
context: Box::new(context),
quarter_id,
chain,
economic_role,
population,
founding_age_years,
exterior_catalog: vocab.exterior_catalog.clone(),
settlement_world_m: (world_x_m, world_y_m),
body_params: body_params.cloned().map(Box::new),
body_seed,
heightmap,
}
}
/// Derive road entry octants (0=N…7=NW) for one city from the road graph.
///
/// For each road edge incident on `city_id`, computes the compass octant of the
/// bearing from the city toward the far endpoint. Results are:
/// - **De-duplicated** per octant (a BTreeSet accumulates unique octants).
/// - **Ordered by descending road quality** so the highest-prestige entry comes
/// first (the AdminFacing consumer selects the first entry as its prestige gate
/// per D-215).
///
/// Returns an empty `Vec` when the city has no road connections — the caller's
/// `derive_access_points` will fall back to a central `BlockJunction`.
///
/// Pure function (no side effects, deterministic output for fixed inputs).
fn road_entry_directions_for_city(
city_id: u64,
city_pos: (u16, u16),
road_graph: &RoadGraph,
) -> Vec<u8> {
// Find the settlement node index for this city.
let city_node_idx = road_graph
.nodes
.iter()
.position(|n: &RoadNode| n.city_id == Some(city_id));
let Some(city_idx) = city_node_idx else {
return Vec::new();
};
// Collect (octant, quality_rank) for each incident edge; BTreeSet dedups per
// octant keeping the highest-quality rank for each (deterministic iteration).
// BTreeMap<octant, rank> for dedup-with-max-quality.
let mut octant_quality: BTreeMap<u8, u8> = BTreeMap::new();
for edge in &road_graph.edges {
let is_from = edge.from == city_idx;
let is_to = edge.to == city_idx;
if !is_from && !is_to {
continue;
}
// Far endpoint position — the direction from city toward the far end.
let far_pos = if is_from {
road_graph.nodes[edge.to].position
} else {
road_graph.nodes[edge.from].position
};
let octant = bearing_octant(city_pos, far_pos);
let rank = maintenance_authority_rank(edge.maintenance);
octant_quality
.entry(octant)
.and_modify(|r| *r = (*r).max(rank))
.or_insert(rank);
}
if octant_quality.is_empty() {
return Vec::new();
}
// Collect (rank, octant) into a Vec, sort descending by rank then ascending
// by octant (tie-break) for a fully deterministic, prestige-first order.
let mut ranked: Vec<(u8, u8)> = octant_quality
.iter()
.map(|(&oct, &rank)| (rank, oct))
.collect();
ranked.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
ranked.into_iter().map(|(_, oct)| oct).collect()
}
/// Compass octant (0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW) of the bearing
/// from `from` toward `to` in working-heightmap-grid coordinates `(row, col)`.
///
/// Working-grid rows increase **southward** (row 0 = top = north), so:
/// - Δrow < 0 → northward, Δrow > 0 → southward
/// - Δcol < 0 → westward, Δcol > 0 → eastward
///
/// Integer arithmetic only (D-010). Returns 0 (North) for a zero-vector.
fn bearing_octant(from: (u16, u16), to: (u16, u16)) -> u8 {
let dr = to.0 as i32 - from.0 as i32; // +south / -north
let dc = to.1 as i32 - from.1 as i32; // +east / -west
if dr == 0 && dc == 0 {
return 0;
}
// 8-sector classification by the dominant axis + sign of the minor axis.
// We double the components to avoid a division and keep integer math.
// |dc| > |dr|*2 → pure E/W; |dr| > |dc|*2 → pure N/S; else diagonal.
let adr = dr.unsigned_abs() as i64;
let adc = dc.unsigned_abs() as i64;
// Octant ordering matches skeleton_gen.rs (D-234): 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW.
if adc > adr * 2 {
// Dominant East or West
if dc > 0 {
2
} else {
6
}
} else if adr > adc * 2 {
// Dominant North or South (row increases southward)
if dr > 0 {
4
} else {
0
}
} else if dr <= 0 && dc > 0 {
1 // NE
} else if dr > 0 && dc > 0 {
3 // SE
} else if dr > 0 && dc <= 0 {
5 // SW
} else {
7 // NW (dr <= 0 && dc < 0)
}
}
/// Prestige rank for a `MaintenanceAuthority` (0 = lowest, 4 = highest).
///
/// Used to order `road_entry_directions` so the AdminFacing consumer (D-215)
/// picks the highest-quality entry as its prestige gate without re-inspecting
/// edge metadata.
///
/// Administrative > Corporate > Trade > Communal > Abandoned.
fn maintenance_authority_rank(m: MaintenanceAuthority) -> u8 {
match m {
MaintenanceAuthority::Administrative => 4,
MaintenanceAuthority::Corporate => 3,
MaintenanceAuthority::Trade => 2,
MaintenanceAuthority::Communal => 1,
MaintenanceAuthority::Abandoned => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem};
use crate::atlas::road_graph::{RoadEdge, RoadNode, RoadNodeKind};
use crate::bridge::ConnectionId;
use crate::seed::SeedChain;
use crate::simulation::generator::{
ArrangementPattern, AttractorType, FoundingOrientation, MaintenanceAuthority,
PoliticalArchetype,
};
use bevy_ecs::schedule::Schedule;
use std::time::Duration;
/// Tiny 16-bit grayscale heightmap PNG at a unique temp path, so the real
/// cascade can run without a committed fixture.
fn test_heightmap_path() -> std::path::PathBuf {
use std::io::BufWriter;
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_genplugin_{}_{n}.png", std::process::id()));
let file = std::fs::File::create(&path).expect("create test heightmap");
let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16);
enc.set_color(png::ColorType::Grayscale);
enc.set_depth(png::BitDepth::Sixteen);
let mut w = enc.write_header().expect("png header");
let data: Vec<u8> = (0..32u32 * 16)
.flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes())
.collect();
w.write_image_data(&data).expect("png data");
path
}
#[test]
fn drain_system_populates_cache() {
// The full loop: submit → Rayon cascade → completion → drain → cache.
let mut world = World::new();
world.insert_resource(GenerationQueue::new());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY));
world.insert_resource(GlobalTierCache::new());
world.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY));
world.resource::<GenerationQueue>().submit(
GenWorkItem::AnalyzeBody {
body_id: "PlanetX".to_string(),
heightmap_path: test_heightmap_path(),
sea_level: 0.3,
body_seed: SeedChain::for_body(42, "PlanetX"),
cities: vec![],
dominant_faction: None,
body_params: None, // T-1023: no DB params in this unit test
river_names: vec![],
mountain_names: vec![],
},
GenPriority::Immediate,
);
let mut sched = Schedule::default();
sched.add_systems(drain_generation_completions);
// Rayon runs the cascade asynchronously; the drain runs each schedule pass.
let mut found = false;
for _ in 0..100 {
sched.run(&mut world);
if world.resource::<BodyWorldStateCache>().contains("PlanetX") {
found = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
found,
"drain system should insert the analyzed body into the cache"
);
}
#[test]
fn serve_drains_requests_into_responses() {
use crate::atlas::cascade::CascadeLayer;
use crate::atlas::layer_proxy::AtlasLayerRequest;
let mut world = World::new();
world.insert_resource(AtlasRequestBuffer(vec![(
ConnectionId(0),
AtlasLayerRequest {
body_id: "GJ1c".to_string(),
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
},
)]));
world.insert_resource(AtlasResponseBuffer::default());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY));
world.insert_resource(GenerationQueue::with_threads(1));
// No resolver / SimRng / SimulationTime — all optional in the system.
let mut sched = Schedule::default();
sched.add_systems(serve_atlas_requests);
sched.run(&mut world);
let responses = world.resource::<AtlasResponseBuffer>();
assert_eq!(responses.0.len(), 1, "request should produce one response");
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
// No resolver wired → Error status (exercises the drain + push path).
assert!(matches!(
responses.0[0].1.status,
AtlasLayerStatus::Error(_)
));
// The request buffer was drained.
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![(
ConnectionId(0),
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].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.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![(
ConnectionId(0),
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].1.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![(
ConnectionId(0),
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].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
assert!(matches!(responses.0[0].1.status, CityNamesStatus::Error(_)));
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
#[test]
fn serve_feature_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{FeatureNamesRequest, FeatureNamesStatus};
let mut world = World::new();
world.insert_resource(FeatureNamesRequestBuffer(vec![(
ConnectionId(0),
FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".to_string(),
},
)]));
world.insert_resource(FeatureNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_feature_names_requests);
sched.run(&mut world);
let responses = world.resource::<FeatureNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
assert!(matches!(
responses.0[0].1.status,
FeatureNamesStatus::Error(_)
));
assert!(world.resource::<FeatureNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
economic_role: "service_mixed".to_string(),
prosperity_baseline_bps: 6_000,
population: 500_000,
dominant_faction: None,
founding_age_years: 200,
settlement_class: SettlementClass::PopulationBudget,
geographic_sector: None,
}
}
/// Empty D-232 draw context (T-994/T-1003) — no catalog reader wired,
/// matching the production behaviour when `TraitCatalogReaderResource` is
/// absent.
fn empty_vocab() -> BodyVocabularyContext<'static> {
static EMPTY_POOLS: SwervePools = SwervePools {
foreign: Vec::new(),
heritage: Vec::new(),
};
// `ExteriorCatalog::default()` isn't a const fn (derived `Default`),
// so a `static` binding isn't available the way it is for
// `EMPTY_POOLS` above — leak a tiny one-off value instead (test-only,
// matches this function's existing 'static-returning contract).
let exterior_catalog: &'static ExteriorCatalog =
Box::leak(Box::new(ExteriorCatalog::default()));
BodyVocabularyContext {
trait_selection: &[],
body_district_type_mix: &[],
catalog: &[],
eligible: &[],
swerve_pools: &EMPTY_POOLS,
exterior_catalog,
}
}
/// D-256(d) test fixture: a minimal 64×32 working-grid heightmap
/// (matching `district_profile::tests::test_hm`'s shape) wrapped in the
/// `Arc` `build_skeleton_work_item`/`GenWorkItem::GenerateSkeleton` carry
/// for the exact-position `morphology_zone` resolution. `body_params:
/// None` (the common case in these queue-mechanics-focused tests) skips
/// that resolution entirely, so the heightmap content is inert — flat
/// data is enough to satisfy the type.
fn sample_heightmap() -> Arc<crate::atlas::heightmap::BodyHeightmap> {
Arc::new(crate::atlas::heightmap::BodyHeightmap {
body_id: "test".into(),
width: 64,
height: 32,
data: vec![0.5; 64 * 32],
sea_level: 0.3,
})
}
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,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
is_standalone_hq: false,
}
}
fn sample_placement_with_archetype(
city_id: u64,
orientation: FoundingOrientation,
archetype: PoliticalArchetype,
arrangement: ArrangementPattern,
) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: (10, 20),
attractor_type: AttractorType::CoastalAccess,
score: 100,
synthetic: false,
political_archetype: archetype,
arrangement_pattern: arrangement,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
is_standalone_hq: false,
}
}
// ── T-994 body-level aggregation + threading (PR #173 review H1) ─────────
fn read_set_with(
prosperity_bps: u32,
population: i64,
founding_age: u32,
) -> CityEconomicReadSet {
CityEconomicReadSet {
prosperity_baseline_bps: prosperity_bps,
population,
founding_age_years: founding_age,
..sample_read_set()
}
}
#[test]
fn aggregate_body_dispatch_inputs_unions_mixes_and_takes_maxes() {
let p1 = sample_placement(1, FoundingOrientation::Cardinal);
let p2 = sample_placement(2, FoundingOrientation::Cardinal);
// City 1: ghost-stub population (< 5K on Waypoint → ComplexityTier::Empty, K=0).
// City 2: normal city (Waypoint → Minimal, K=1).
let rs1 = read_set_with(6_000, 3_000, 200);
let rs2 = read_set_with(8_500, 500_000, 200);
let resolved = vec![(&p1, rs1.clone()), (&p2, rs2.clone())];
let agg = aggregate_body_dispatch_inputs(&resolved, 42, "BodyAgg");
assert_eq!(
agg.max_prosperity_bps, 8_500,
"MAX prosperity across settlements"
);
assert_eq!(agg.max_k, 1, "MAX complexity K across settlements (0 vs 1)");
// The coverage mix must be exactly the union of each placement's own
// deterministic district mix (same chains generate_quarter_skeleton uses).
let mut expected: std::collections::BTreeSet<DistrictType> = Default::default();
for (p, rs) in [(&p1, &rs1), (&p2, &rs2)] {
let chain =
SeedChain::for_body(42, "BodyAgg").derive(SeedDomain::Layer4Quarter, p.city_id);
expected.extend(
compute_district_mix(
rs.population,
&rs.economic_role,
&p.political_archetype,
16,
chain,
)
.districts,
);
}
assert!(!expected.is_empty());
assert_eq!(
agg.body_district_type_mix,
expected.into_iter().collect::<Vec<_>>()
);
}
#[test]
fn dispatched_contexts_share_vocabulary_but_carry_per_settlement_swerve_rates() {
use crate::atlas::trait_catalog_reader::TraitTemplate;
use std::collections::BTreeMap;
fn tmpl(tag: &str) -> TraitTemplate {
TraitTemplate {
tag: tag.to_string(),
corridor_pool: "baseline".to_string(),
geographic_sector: None,
bulk_class_gate: Vec::new(),
production_ubiquity_gate: Vec::new(),
min_prosperity_bps: 0,
base_weight: 10_000,
weight_mods: BTreeMap::new(),
zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(),
visual_bundle: Default::default(),
}
}
let catalog = vec![tmpl("temp_a"), tmpl("temp_b")];
let eligible: Vec<&TraitTemplate> = catalog.iter().collect();
let trait_selection = vec!["temp_a".to_string(), "temp_b".to_string()];
let mix = vec![DistrictType::MixedUse];
let pools = SwervePools {
foreign: vec![("foreign_x".to_string(), 10_000)],
heritage: vec![("herit_y".to_string(), 10_000)],
};
let exterior_catalog = ExteriorCatalog::default();
let vocab = BodyVocabularyContext {
trait_selection: &trait_selection,
body_district_type_mix: &mix,
catalog: &catalog,
eligible: &eligible,
swerve_pools: &pools,
exterior_catalog: &exterior_catalog,
};
// City 1 sits in the road graph with degree 2; city 2 has no node (degree 0).
let road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(1),
position: (10, 20),
kind: RoadNodeKind::Settlement,
degree: 2,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(90),
position: (10, 4),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(91),
position: (26, 20),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
],
edges: vec![
RoadEdge {
from: 0,
to: 1,
path: vec![(10, 20), (10, 4)],
length_cells: 16,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
},
RoadEdge {
from: 0,
to: 2,
path: vec![(10, 20), (26, 20)],
length_cells: 16,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
},
],
};
let build = |city_id: u64, founding_age: u32| {
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
"BodyThread",
42,
&sample_placement(city_id, FoundingOrientation::Cardinal),
read_set_with(6_000, 500_000, founding_age),
64,
32,
None,
&road_graph,
&vocab,
sample_heightmap(),
) else {
panic!("expected GenerateSkeleton");
};
context
};
let ctx1 = build(1, 50); // connected (degree 2), young settlement
let ctx2 = build(2, 400); // off-graph (degree 0), old settlement
// The body-level draw outputs are identical on both settlements — the
// closed-vocabulary invariant threaded through dispatch.
assert_eq!(ctx1.trait_selection, ctx2.trait_selection);
assert_eq!(ctx1.trait_selection, trait_selection);
assert_eq!(ctx1.body_district_type_mix, ctx2.body_district_type_mix);
assert_eq!(ctx1.swerve_foreign_pool, ctx2.swerve_foreign_pool);
assert_eq!(ctx1.swerve_heritage_pool, ctx2.swerve_heritage_pool);
assert_eq!(ctx1.swerve_foreign_pool, pools.foreign);
// The swerve RATES are per-settlement (T-1003 drivers): city 1 gets the
// road-degree centrality bump on foreign (100 → 120) and no isolation
// multiplier on heritage (Waypoint remote ×1.5 only → 150); city 2 is
// isolated (×2.0) + remote (×1.5) + old (×1.5) → capped at 300.
assert_eq!(ctx1.swerve_rates_bps, (120, 150));
assert_eq!(ctx2.swerve_rates_bps, (100, 300));
}
#[test]
fn build_skeleton_work_item_threads_orientation_and_canonical_quarter_id() {
let placement = sample_placement(
7,
FoundingOrientation::Coastal {
facing_degrees: 270,
},
);
let GenWorkItem::GenerateSkeleton {
city_id,
body_id,
context,
quarter_id,
economic_role,
population,
founding_age_years,
..
} = build_skeleton_work_item(
"PlanetX",
42,
&placement,
sample_read_set(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
};
// The attractor-matched orientation replaces the context_from_read_set
// Cardinal stub — the whole point of T-1022.
assert_eq!(
context.founding_orientation,
FoundingOrientation::Coastal {
facing_degrees: 270
}
);
assert_eq!(city_id, 7);
assert_eq!(body_id, "PlanetX");
assert_eq!(economic_role, "service_mixed");
assert_eq!(population, 500_000);
assert_eq!(founding_age_years, 200);
// quarter_id is the canonical D-194/D-230 derivation, not the city_id*10 stub.
let expected = SeedChain::for_body(42, "PlanetX")
.derive(SeedDomain::Layer4Quarter, 7)
.seed();
assert_eq!(quarter_id, expected);
assert_ne!(quarter_id, 7 * 10, "must not be the old placeholder");
}
#[test]
fn quarter_id_is_deterministic_and_city_scoped() {
let qid = |city_id: u64| {
let placement = sample_placement(city_id, FoundingOrientation::Cardinal);
let GenWorkItem::GenerateSkeleton { quarter_id, .. } = build_skeleton_work_item(
"BodyA",
99,
&placement,
sample_read_set(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
) else {
unreachable!()
};
quarter_id
};
// Same inputs → same id; different city → different id.
assert_eq!(qid(3), qid(3));
assert_ne!(qid(3), qid(4));
}
// ── T-1039 / D-256(d): political_archetype + morphology_zone threading ────
/// Verify that `build_skeleton_work_item` threads the placement's
/// `political_archetype` (replacing the `Commission` stub). `morphology_zone`
/// is NOT resolved at this dispatch-time function any more (D-256(d)) — it
/// stays at the `context_from_read_set` `AlluvialPlain` stub here; see
/// `run_work_item_resolves_morphology_zone_at_exact_settlement_position`
/// below for the execution-time resolution this ticket moved it to.
#[test]
fn threads_political_archetype() {
use crate::simulation::generator::MorphologyZone;
let placement = sample_placement_with_archetype(
42,
FoundingOrientation::Coastal { facing_degrees: 90 },
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
"TestBody",
1,
&placement,
sample_read_set(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
) else {
panic!("expected GenerateSkeleton");
};
// Political archetype must come from the placement, not context_from_read_set's stub.
assert_eq!(
context.political_archetype,
PoliticalArchetype::Corporate,
"political_archetype must be threaded from placement (T-1039)"
);
// morphology_zone is untouched by build_skeleton_work_item post-D-256(d).
assert_eq!(context.morphology_zone, MorphologyZone::AlluvialPlain);
}
/// D-256(d): `resolve_settlement_morphology_zone` (the function
/// `run_work_item`'s `GenerateSkeleton` arm calls) returns `None` when no
/// `body_params` is supplied (no DB row for this body — the same
/// condition the pre-D-256 "empty district grid" fallback covered), so
/// the caller leaves `context.morphology_zone` at its `AlluvialPlain`
/// stub.
#[test]
fn resolve_settlement_morphology_zone_none_when_body_params_absent() {
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
use std::sync::Mutex;
let heightmap = sample_heightmap();
let cache = Arc::new(Mutex::new(
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
));
let zone = resolve_settlement_morphology_zone(
&cache,
"BodyX",
None,
(0.0, 0.0),
SeedChain::for_body(0, "BodyX"),
&heightmap,
);
assert_eq!(
zone, None,
"no body_params → no resolution, caller keeps the stub"
);
}
/// D-256(d): with real `body_params`, `resolve_settlement_morphology_zone`
/// resolves at the settlement's EXACT world position via `derive_at_metres`
/// — not from a survey-cell lookup. Ground truth: an independent
/// `derive_at_metres` call at the same position must agree bit-for-bit.
#[test]
fn resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position() {
use crate::atlas::district_profile::{self, BodyParams, ClimateConstants};
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
use std::sync::Mutex;
let heightmap = sample_heightmap();
let body_params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
};
let placement = sample_placement(1, FoundingOrientation::Cardinal);
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
placement.position.1 as f64,
placement.position.0 as f64,
heightmap.width as usize,
heightmap.height as usize,
body_params.body_radius_km,
);
let body_seed = SeedChain::for_body(0, "TestBody");
let cache = Arc::new(Mutex::new(
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
));
let zone = resolve_settlement_morphology_zone(
&cache,
"TestBody",
Some(&body_params),
(world_x_m, world_y_m),
body_seed,
&heightmap,
);
// Ground truth: derive_at_metres at the SAME settlement world metres,
// via the terrain cache's own re-derive path so the TerrainAnalysis is
// byte-identical to what the resolver used — INCLUDING which hydrology
// moisture ceiling gets solved with. `resolve_settlement_morphology_zone`
// threads `Some(&body_params)` into `get_or_derive` (T-1184), which
// derives the body's real moisture ceiling rather than falling back to
// `run_layer1`'s body-agnostic default; this ground truth must use the
// SAME `run_layer1_with_moisture` path (not bare `run_layer1`) or the
// two `TerrainAnalysis`es solve hydrology at different moisture inputs
// — moot for this fixture's LAKE EXTENT (moisture-independent, see
// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`),
// but the two paths must agree by construction, not by coincidence of
// this specific body having no moisture-sensitive basin near the
// sampled position.
let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture(
&heightmap,
district_profile::derive_moisture_ceiling_q(&body_params),
);
let expected = district_profile::derive_at_metres(
body_seed,
"TestBody",
&body_params,
&ta,
world_x_m,
world_y_m,
&ClimateConstants::default(),
0.0,
&[],
);
assert_eq!(
zone,
Some(expected.morphology_zone),
"resolve_settlement_morphology_zone must match derive_at_metres at the \
settlement's exact world position (D-256(d))"
);
}
// ── T-1039: arrangement_pattern parity drift-tripwire ─────────────────────
/// Verifies that the L4 re-derivation of `arrangement_pattern` via
/// `attractor_matching::arrangement_pattern(&archetype, &role)` is always
/// identical to the L3 value stored on `CityPlacement.arrangement_pattern` for
/// a representative set of (archetype, economic_role) pairs.
///
/// This is the required hardening check (T-1039 OPTION (b)): if anyone
/// changes one derivation path without the other this test will catch the drift.
#[test]
fn arrangement_pattern_l4_rederivation_matches_l3_stored_value() {
use crate::atlas::attractor_matching::arrangement_pattern;
// Representative pairs: archetype + economic_role → expected pattern.
// These are the canonical D-214/D-215 pairs exercising all branches.
let cases: &[(PoliticalArchetype, &str, ArrangementPattern)] = &[
// Commission/Academic → RadialCore
(
PoliticalArchetype::Commission,
"institutional",
ArrangementPattern::RadialCore,
),
(
PoliticalArchetype::Academic,
"research",
ArrangementPattern::RadialCore,
),
// Corporate → CampusGrid
(
PoliticalArchetype::Corporate,
"manufacturing",
ArrangementPattern::CampusGrid,
),
(
PoliticalArchetype::Corporate,
"financial",
ArrangementPattern::CampusGrid,
),
// Pioneer/Industrial → RibbonDevelopment
(
PoliticalArchetype::Pioneer,
"agricultural",
ArrangementPattern::RibbonDevelopment,
),
(
PoliticalArchetype::Industrial,
"extraction",
ArrangementPattern::RibbonDevelopment,
),
// Military → FortifiedPerimeter
(
PoliticalArchetype::Military,
"military",
ArrangementPattern::FortifiedPerimeter,
),
// transit_hub is a cross-archetype override → HubAndSpoke
(
PoliticalArchetype::Commission,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
(
PoliticalArchetype::Corporate,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
// transit_hub must override EVERY archetype (the guard fires before the
// archetype match) — cover the rest so an accidental
// archetype-conditionalization of the override can't slip through.
(
PoliticalArchetype::Pioneer,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
(
PoliticalArchetype::Industrial,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
(
PoliticalArchetype::Military,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
(
PoliticalArchetype::Academic,
"transit_hub",
ArrangementPattern::HubAndSpoke,
),
];
for (archetype, role, expected_pattern) in cases {
// L4 re-derivation (the path used in build_skeleton_work_item).
let rederived = arrangement_pattern(archetype, role);
// Build a CityPlacement carrying the L3-computed value to simulate
// 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,
synthetic: false,
political_archetype: *archetype,
arrangement_pattern: *expected_pattern,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
is_standalone_hq: false,
};
assert_eq!(
rederived, l3_placement.arrangement_pattern,
"L4 re-derivation != L3 stored value for ({:?}, {role})",
archetype
);
assert_eq!(
rederived, *expected_pattern,
"arrangement_pattern({:?}, {role}) should be {:?}",
archetype, expected_pattern
);
}
}
/// Acceptance test for T-1039: a Corporate coastal placement in a Fjord district
/// dispatches a work item whose context uses Ribbon topology (Fjord) and
/// Corporate (CampusGrid) layout — not mesh+Commission.
///
/// D-256(d): `morphology_zone` is no longer resolved by
/// `build_skeleton_work_item` (that now happens at execution time via
/// `resolve_settlement_morphology_zone`, tested directly elsewhere against
/// real terrain — see
/// `resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position`).
/// This test's actual subject is `generate_quarter_skeleton`'s downstream
/// Fjord+Corporate behavior, so it overrides `context.morphology_zone`
/// directly on the built context — exactly what `run_work_item` does in
/// production once the exact-position resolution completes.
#[test]
fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() {
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
use crate::simulation::generator::{AccessKind, MorphologyZone};
// CorpTerritory → Corporate archetype; CoastalAccess attractor.
let placement = sample_placement_with_archetype(
99,
FoundingOrientation::Coastal {
facing_degrees: 270,
},
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
let GenWorkItem::GenerateSkeleton {
context,
economic_role,
population,
founding_age_years,
chain,
quarter_id,
..
} = build_skeleton_work_item(
"FjordBody",
7,
&placement,
sample_read_set(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
};
let mut context = context;
context.morphology_zone = MorphologyZone::Fjord;
// Verify the context is correctly wired before skeleton generation.
assert_eq!(context.political_archetype, PoliticalArchetype::Corporate);
assert_eq!(context.morphology_zone, MorphologyZone::Fjord);
// Run skeleton generation and verify:
// - DistrictLayoutMode is not the Commission path
// - street_topology(Fjord) → Ribbon (verified via absence of mesh-only outputs)
let skeleton = generate_quarter_skeleton(
&context,
population,
&economic_role,
quarter_id,
founding_age_years,
chain,
);
// A Corporate context with no road entries → BlockJunction fallback,
// but the layout mode must NOT be the Commission/Commission-grid variant.
// The skeleton's access_points are generated; at least one must exist.
assert!(
!skeleton.access_points.is_empty(),
"skeleton must have at least one access point"
);
// Corporate + Fjord should NOT produce only RadialCore topology access points.
// (Ribbon topology and CampusGrid layout are tested structurally here.)
// With no road entries, BlockJunction fires — but layout mode is Corporate.
let has_junction = skeleton
.access_points
.iter()
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
assert!(
has_junction,
"isolated Corporate+Fjord settlement should have BlockJunction fallback"
);
}
// ── T-1043: road_entry_directions from RoadGraph ───────────────────────────
/// Build a minimal RoadGraph with two nodes and one edge, then verify that
/// `road_entry_directions_for_city` returns the correct entry octant.
#[test]
fn road_entry_directions_single_east_road() {
// City at (row=10, col=10), road goes east to (row=10, col=50).
// Expected octant: 2 (East) — dc=40, dr=0, dominant east.
let city_pos = (10u16, 10u16);
let far_pos = (10u16, 50u16);
let road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(1),
position: city_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(2),
position: far_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
],
edges: vec![RoadEdge {
from: 0,
to: 1,
path: vec![city_pos, far_pos],
length_cells: 4,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
}],
};
let octants = road_entry_directions_for_city(1, city_pos, &road_graph);
assert_eq!(octants, vec![2u8], "east road should yield octant 2 (E)");
}
/// A settlement with two road connections (north and south) should produce
/// both octants, ordered by quality (higher-prestige first).
#[test]
fn road_entry_directions_multi_road_prestige_order() {
// City at (20, 20). Road north to (0, 20) [Administrative]; road south to
// (40, 20) [Communal]. Expected: [0 (N, rank 4), 4 (S, rank 1)].
let city_pos = (20u16, 20u16);
let north_pos = (0u16, 20u16);
let south_pos = (40u16, 20u16);
let road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(10),
position: city_pos,
kind: RoadNodeKind::Settlement,
degree: 2,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(11),
position: north_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(12),
position: south_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
],
edges: vec![
RoadEdge {
from: 0,
to: 1,
path: vec![city_pos, north_pos],
length_cells: 2,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
},
RoadEdge {
from: 0,
to: 2,
path: vec![city_pos, south_pos],
length_cells: 2,
maintenance: MaintenanceAuthority::Communal,
named_route_id: None,
is_rail: false,
},
],
};
let octants = road_entry_directions_for_city(10, city_pos, &road_graph);
assert_eq!(
octants,
vec![0u8, 4u8],
"N (Administrative, rank 4) must precede S (Communal, rank 1)"
);
}
/// Two roads on the same octant are de-duplicated; only the higher-quality
/// road's rank is kept.
#[test]
fn road_entry_directions_deduplicates_same_octant() {
let city_pos = (10u16, 10u16);
// Two roads both going south (dr > 0, dc = 0 → octant 4).
let road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(1),
position: city_pos,
kind: RoadNodeKind::Settlement,
degree: 2,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(2),
position: (30u16, 10u16),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(3),
position: (50u16, 10u16),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
],
edges: vec![
RoadEdge {
from: 0,
to: 1,
path: vec![city_pos, (30, 10)],
length_cells: 2,
maintenance: MaintenanceAuthority::Trade,
named_route_id: None,
is_rail: false,
},
RoadEdge {
from: 0,
to: 2,
path: vec![city_pos, (50, 10)],
length_cells: 4,
maintenance: MaintenanceAuthority::Corporate,
named_route_id: None,
is_rail: false,
},
],
};
let octants = road_entry_directions_for_city(1, city_pos, &road_graph);
// Both go south (octant 4); de-duplication keeps one; higher rank (Corporate=3) wins.
assert_eq!(octants, vec![4u8], "same-octant roads must be deduplicated");
}
/// Acceptance test for T-1043: a settlement with a road connection produces
/// at least one QuarterEdge access node on the correct octant; a genuinely
/// isolated settlement falls back to BlockJunction only.
#[test]
fn dispatch_with_road_produces_quarter_edge_not_block_junction() {
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
use crate::simulation::generator::AccessKind;
// Road goes east from city at (10, 10) to (10, 50) → octant 2 (East).
let city_pos = (10u16, 10u16);
let far_pos = (10u16, 50u16);
let road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(5),
position: city_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
RoadNode {
city_id: Some(6),
position: far_pos,
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
is_hub: false,
},
],
edges: vec![RoadEdge {
from: 0,
to: 1,
path: vec![city_pos, far_pos],
length_cells: 4,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
}],
};
let placement = CityPlacement {
city_id: 5,
name: "City5".into(),
position: city_pos,
attractor_type: AttractorType::CoastalAccess,
score: 100,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
is_standalone_hq: false,
};
let GenWorkItem::GenerateSkeleton {
context,
economic_role,
population,
founding_age_years,
chain,
quarter_id,
..
} = build_skeleton_work_item(
"RoadBody",
42,
&placement,
sample_read_set(),
64,
32,
None,
&road_graph,
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
};
// Context should have octant 2 (East) in road_entry_directions.
assert_eq!(
context.road_entry_directions,
vec![2u8],
"east road must produce octant 2 in road_entry_directions"
);
// Generate the skeleton and verify QuarterEdge is produced (not just BlockJunction).
let skeleton = generate_quarter_skeleton(
&context,
population,
&economic_role,
quarter_id,
founding_age_years,
chain,
);
let has_quarter_edge = skeleton
.access_points
.iter()
.any(|p| matches!(p.kind, AccessKind::QuarterEdge { octant: 2 }));
assert!(
has_quarter_edge,
"settlement with east road must produce QuarterEdge(octant=2)"
);
let has_block_junction = skeleton
.access_points
.iter()
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
assert!(
!has_block_junction,
"settlement with road connections must NOT fall back to BlockJunction"
);
}
/// An isolated settlement (no road edges) must fall back to BlockJunction only.
#[test]
fn isolated_settlement_falls_back_to_block_junction() {
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
use crate::simulation::generator::AccessKind;
let placement = sample_placement(7, FoundingOrientation::Cardinal);
let GenWorkItem::GenerateSkeleton {
context,
economic_role,
population,
founding_age_years,
chain,
quarter_id,
..
} = build_skeleton_work_item(
"IsolatedBody",
42,
&placement,
sample_read_set(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
};
assert!(
context.road_entry_directions.is_empty(),
"isolated settlement must have no road_entry_directions"
);
let skeleton = generate_quarter_skeleton(
&context,
population,
&economic_role,
quarter_id,
founding_age_years,
chain,
);
let has_block_junction = skeleton
.access_points
.iter()
.any(|p| matches!(p.kind, AccessKind::BlockJunction));
assert!(
has_block_junction,
"isolated settlement must fall back to BlockJunction"
);
}
// ── bearing_octant unit tests ──────────────────────────────────────────────
#[test]
fn bearing_octant_cardinal_directions() {
// North: row decreases (dr < 0, dc = 0)
assert_eq!(bearing_octant((10, 10), (0, 10)), 0, "N");
// East: col increases (dr = 0, dc > 0)
assert_eq!(bearing_octant((10, 10), (10, 50)), 2, "E");
// South: row increases (dr > 0, dc = 0)
assert_eq!(bearing_octant((10, 10), (50, 10)), 4, "S");
// West: col decreases (dr = 0, dc < 0)
assert_eq!(bearing_octant((10, 10), (10, 0)), 6, "W");
}
#[test]
fn bearing_octant_diagonal_directions() {
// NE: dr < 0, dc > 0 (roughly equal magnitude)
assert_eq!(bearing_octant((10, 10), (5, 15)), 1, "NE");
// SE: dr > 0, dc > 0
assert_eq!(bearing_octant((10, 10), (15, 15)), 3, "SE");
// SW: dr > 0, dc < 0
assert_eq!(bearing_octant((10, 10), (15, 5)), 5, "SW");
// NW: dr < 0, dc < 0
assert_eq!(bearing_octant((10, 10), (5, 5)), 7, "NW");
}
}