//! 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, mut responses: ResMut, mut 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(); 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, mut responses: ResMut, path: Option>, ) { 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, 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 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, mut cache: ResMut, city_reader: Option>, trait_catalog: Option>, rng: 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(); // ── 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.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, /// 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` — 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, 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 { // 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::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.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 }, 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![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::(); 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::().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::(); assert_eq!(responses.0.len(), 1); assert_eq!(responses.0[0].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![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].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::(); 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::().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 = 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), &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 = 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 = 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"); } }