Files
settled-reach/server/src/atlas/plugin.rs
T
jpmschweitzerandClaude Fable 5 98991284bf feat(simulation): road-graph hub refinement — scaled-cap hubs, co-location collapse, minor-attach edge split, RailHeadFacing (T-1076)
is_standalone_hq threaded through CityRecord/CityPlacement via corporations LEFT JOIN (both readers); hubs = top-cap by population among non-HQ settlements (HUB_SPACING_DIAG_PX=64, HUB_CAP_MIN=6); exact-name collapse keeping lowest city_id with loud warn; nearest-point-on-polyline snap (SNAP_MAX_PX=8) with Junction edge-split preserving from<to via norm_edge, else A*-spur to nearest hub; FoundingOrientation::RailHeadFacing{bearing_degrees} assigned at degree>=3 junctions (octant bearing toward dominant incident edge), consumed by skeleton_gen railhead_edge() through the D-234b flush-frontage machinery; believability reader now honours baked settlement_class (stale hardcode since T-1075). 11 new targeted tests; existing suites green.

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

1945 lines
80 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 crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
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::{DistrictPos, DistrictProfile};
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
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::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, CityNamesRequestBuffer, CityNamesResponseBuffer,
StarMapRequestBuffer, StarMapResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, 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))
.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),
);
}
}
/// 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`.
fn serve_atlas_requests(
mut requests: ResMut<AtlasRequestBuffer>,
mut responses: ResMut<AtlasResponseBuffer>,
mut cache: ResMut<BodyWorldStateCache>,
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();
for req in pending {
let resp = match resolver.as_ref() {
Some(r) => handle_atlas_request(
&req,
&mut cache,
&queue,
&r.0,
reader,
params_reader,
world_seed,
tick,
),
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,
},
};
responses.0.push(resp);
}
}
/// Drain inbound star-map requests and serve each through the proxy (T-949a).
/// A thin read-the-file-fresh proxy — see `atlas_data_proxy` module doc for
/// why there's no caching. Absent `StarMapDataPath` (not wired at startup,
/// e.g. unit tests) reports an error per request rather than panicking.
fn serve_star_map_requests(
mut requests: ResMut<StarMapRequestBuffer>,
mut responses: ResMut<StarMapResponseBuffer>,
path: Option<Res<StarMapDataPath>>,
) {
if requests.0.is_empty() {
return;
}
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
let resp = match path.as_ref() {
Some(p) => handle_star_map_request(&req, &p.0),
None => crate::atlas::atlas_data_proxy::StarMapResponse {
status: crate::atlas::atlas_data_proxy::StarMapStatus::Error(
"star map data path unavailable".to_string(),
),
data: None,
},
};
responses.0.push(resp);
}
}
/// Drain inbound city-names requests and serve each through the proxy
/// (T-949b): D-236 Sol check, then the names-only `atlas_city_names` read.
fn serve_city_names_requests(
mut requests: ResMut<CityNamesRequestBuffer>,
mut responses: ResMut<CityNamesResponseBuffer>,
city_reader: Option<Res<CityContextReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = city_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
responses.0.push(handle_city_names_request(&req, reader));
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
/// channel drain + cache insert, never the ~45 ms cascade itself.
fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
rng: Option<Res<SimRng>>,
) {
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();
// ── 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.districts,
&state.road_graph,
&vocab,
),
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. TODO(#957): the stub GenerateSkeleton
// returns a default skeleton with quarter_id=0; real gen (#957) will
// populate it from CityGenerationContext.
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)"
);
}
}
}
}
/// 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` — from the `DistrictProfile` covering this placement's
/// heightmap-grid pixel, via `state.districts` (D-239 §6, T-1039). Falls back
/// to `AlluvialPlain` when the district grid is empty (unit tests, early cascade).
/// - `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.
///
/// `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`.
fn build_skeleton_work_item(
body_id: &str,
world_seed: u64,
placement: &CityPlacement,
read_set: CityEconomicReadSet,
districts: &BTreeMap<DistrictPos, DistrictProfile>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
) -> 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;
// ── T-1039 / D-239 §6: morphology_zone from covering DistrictProfile ───────
// Convert the placement's working-grid pixel position to a DistrictPos using
// the canonical scale constant — no hardcoded magic numbers here.
let district_pos = scale::heightmap_pixel_to_district(placement.position);
context.morphology_zone = match districts.get(&district_pos) {
Some(d) => d.morphology_zone,
None => {
// An empty grid is the expected params-missing / early-cascade case
// (debug); a miss against a *populated* grid means the pixel→DistrictPos
// conversion is off — a real bug worth a warning, not a silent wrong
// topology.
if districts.is_empty() {
tracing::debug!(
city_id = placement.city_id,
?district_pos,
"morphology_zone fallback to AlluvialPlain: district grid not built for this body"
);
} else {
tracing::warn!(
city_id = placement.city_id,
?district_pos,
"morphology_zone fallback to AlluvialPlain: pos not in populated district grid — check pixel→district convention"
);
}
MorphologyZone::AlluvialPlain
}
};
// ── 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 chain = SeedChain::for_body(world_seed, body_id)
.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(),
}
}
/// 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::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.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
},
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![AtlasLayerRequest {
body_id: "GJ1c".to_string(),
up_to: CascadeLayer::Topography,
}]));
world.insert_resource(AtlasResponseBuffer::default());
world.insert_resource(BodyWorldStateCache::new(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].body_id, "GJ1c");
// No resolver wired → Error status (exercises the drain + push path).
assert!(matches!(responses.0[0].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![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
world.insert_resource(StarMapDataPath(path.clone()));
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].status, StarMapStatus::Ready);
assert!(world.resource::<StarMapRequestBuffer>().0.is_empty());
let _ = std::fs::remove_file(&path);
}
/// Without `StarMapDataPath` wired (e.g. a stripped-down test world), the
/// serve system reports `Error` per request rather than panicking.
#[test]
fn serve_star_map_without_path_resource_is_error() {
use crate::atlas::atlas_data_proxy::{StarMapRequest, StarMapStatus};
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
// No StarMapDataPath resource.
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert!(matches!(responses.0[0].status, StarMapStatus::Error(_)));
}
/// T-949b: without `CityContextReaderResource` wired, the serve system
/// reports `Error` per request (mirrors the atlas-request "no resolver"
/// convention) rather than panicking.
#[test]
fn serve_city_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{CityNamesRequest, CityNamesStatus};
let mut world = World::new();
world.insert_resource(CityNamesRequestBuffer(vec![CityNamesRequest {
city_names: true,
body_id: "GJ1c".to_string(),
}]));
world.insert_resource(CityNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_city_names_requests);
sched.run(&mut world);
let responses = world.resource::<CityNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].body_id, "GJ1c");
assert!(matches!(responses.0[0].status, CityNamesStatus::Error(_)));
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
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,
}
}
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),
&BTreeMap::new(),
&road_graph,
&vocab,
) 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(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
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(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) 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: political_archetype + morphology_zone threading ───────────────
/// Verify that `build_skeleton_work_item` threads the placement's
/// `political_archetype` (replacing the `Commission` stub) and looks up
/// `morphology_zone` from the district grid.
#[test]
fn threads_political_archetype_and_morphology_zone() {
use crate::atlas::district_profile::{
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
};
use crate::atlas::scale::BasinDirection;
use crate::simulation::generator::MorphologyZone;
// A Corporate archetype placement in a Fjord district.
let placement = sample_placement_with_archetype(
42,
FoundingOrientation::Coastal { facing_degrees: 90 },
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
// CityPlacement.position = (10, 20) → district_pos = (col/8, row/8) = (20/8, 10/8) = (2, 1)
let district_pos = scale::heightmap_pixel_to_district(placement.position);
assert_eq!(district_pos, (2, 1));
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
districts.insert(
district_pos,
DistrictProfile {
morphology_zone: MorphologyZone::Fjord,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: GlaciationGrade::Moderate,
precipitation_class: PrecipitationClass::Temperate,
slope_q: 60,
elev_q: 50,
ocean_fraction_q: 10,
river_threshold: 200,
temperature_c: Some(8.0),
moisture_q: 55,
vegetation_class: VegetationClass::Scrub,
basin_direction: BasinDirection::North,
},
);
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
"TestBody",
1,
&placement,
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
) 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 must come from the DistrictProfile.
assert_eq!(
context.morphology_zone,
MorphologyZone::Fjord,
"morphology_zone must be looked up from DistrictProfile (T-1039)"
);
}
/// When no district grid is available (empty districts map), morphology_zone
/// falls back to AlluvialPlain (the safe mesh-topology default).
#[test]
fn morphology_zone_fallback_when_district_missing() {
use crate::simulation::generator::MorphologyZone;
let placement = sample_placement(1, FoundingOrientation::Cardinal);
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
"BodyX",
0,
&placement,
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton")
};
assert_eq!(
context.morphology_zone,
MorphologyZone::AlluvialPlain,
"should fall back to AlluvialPlain when district grid empty"
);
}
// ── 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.
#[test]
fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() {
use crate::atlas::district_profile::{
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
};
use crate::atlas::scale::BasinDirection;
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
use crate::simulation::generator::AccessKind;
// CorpTerritory → Corporate archetype; CoastalAccess attractor.
let placement = sample_placement_with_archetype(
99,
FoundingOrientation::Coastal {
facing_degrees: 270,
},
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
let district_pos = scale::heightmap_pixel_to_district(placement.position);
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
districts.insert(
district_pos,
DistrictProfile {
morphology_zone: MorphologyZone::Fjord,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: GlaciationGrade::Moderate,
precipitation_class: PrecipitationClass::SemiArid,
slope_q: 70,
elev_q: 30,
ocean_fraction_q: 20,
river_threshold: 200,
temperature_c: Some(5.0),
moisture_q: 35,
vegetation_class: VegetationClass::Barren,
basin_direction: BasinDirection::North,
},
);
let GenWorkItem::GenerateSkeleton {
context,
economic_role,
population,
founding_age_years,
chain,
quarter_id,
..
} = build_skeleton_work_item(
"FjordBody",
7,
&placement,
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
};
// 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(),
&BTreeMap::new(),
&road_graph,
&empty_vocab(),
)
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(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
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");
}
}