//! 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, mut responses: ResMut, mut cache: ResMut, mut window_cache: ResMut, queue: Res, resolver: Option>, city_reader: Option>, body_params_reader: Option>, rng: Option>, time: Option>, ) { 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, mut responses: ResMut, path: Option>, ) { 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, mut responses: ResMut, city_reader: Option>, ) { 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, mut responses: ResMut, city_reader: Option>, ) { 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, mut responses: ResMut, browse_reader: Option>, ) { 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, mut responses: ResMut, mut global_cache: ResMut, mut canvas_cache: ResMut, body_state_cache: Res, queue: Res, resolver: Option>, body_params_reader: Option>, rng: Option>, time: Option>, ) { 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, mut cache: ResMut, mut window_cache: ResMut, mut global_tier_cache: ResMut, mut step_canvas_cache: ResMut, city_reader: Option>, trait_catalog: Option>, body_params_reader: Option>, rng: Option>, time: Option>, ) { 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> = 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, Vec) = 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, /// 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 = 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, ) -> 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 { // 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 for dedup-with-max-quality. let mut octant_quality: BTreeMap = 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 = (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::().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::().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::(); 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::().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::(); 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::().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::(); 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::(); 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::().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::(); 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::().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 { 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 = 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::>() ); } #[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"); } }