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>
2342 lines
97 KiB
Rust
2342 lines
97 KiB
Rust
//! 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");
|
||
}
|
||
}
|