diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 08a44174c..90b363bdc 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -11,12 +11,16 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; -use crate::atlas::city_context_reader::CityContextReaderResource; -use crate::atlas::gen_queue::{GenCompletion, GenerationQueue}; +use crate::atlas::city_context_reader::{ + context_from_read_set, CityContextReaderResource, CityEconomicReadSet, +}; +use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus}; use crate::atlas::source_resolver::BodySourceResolverResource; use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer}; +use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::tick_phases::TickPhase; @@ -78,10 +82,44 @@ fn serve_atlas_requests( fn drain_generation_completions( queue: Res, mut cache: ResMut, + city_reader: Option>, + rng: Option>, ) { for completion in queue.drain_completions() { match completion { - GenCompletion::BodyAnalyzed { state, .. } => cache.insert(state), + 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(); + for placement in &state.placements { + let read_set = match reader.read_set(placement.city_id, world_seed) { + Ok(rs) => rs, + Err(e) => { + tracing::warn!( + city_id = placement.city_id, + body_id = %body_id, + error = %e, + "L3→L4 dispatch: read_set failed — skipping placement" + ); + continue; + } + }; + queue.submit( + build_skeleton_work_item(&body_id, world_seed, placement, read_set), + GenPriority::Low, + ); + } + } + cache.insert(state); + } GenCompletion::Failed { item, reason } => { tracing::warn!(?item, %reason, "background generation work item failed"); } @@ -118,11 +156,57 @@ fn drain_generation_completions( } } +/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement +/// (T-1022, D-234). Builds the D-199 context from the read-set (mirroring +/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context), +/// which is just `read_set` + `context_from_read_set`), then overrides the +/// `Cardinal` stub with the attractor-matched +/// [`FoundingOrientation`](crate::simulation::generator::FoundingOrientation) carried +/// on the placement (D-213), and derives the canonical, namespace-isolated +/// `quarter_id` from `(world_seed, body, city)` (D-194/D-230) — replacing the +/// `city_id * 10` placeholder. 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, +) -> 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; + + let mut context = context_from_read_set(placement.city_id, read_set); + context.founding_orientation = placement.founding_orientation.clone(); + + // 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's own field. + 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, + } +} + #[cfg(test)] mod tests { use super::*; use crate::atlas::gen_queue::{GenPriority, GenWorkItem}; use crate::seed::SeedChain; + use crate::simulation::generator::FoundingOrientation; use bevy_ecs::schedule::Schedule; use std::time::Duration; @@ -212,4 +296,91 @@ mod tests { // The request buffer was drained. 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, + } + } + + fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement { + use crate::simulation::generator::{ArrangementPattern, AttractorType, PoliticalArchetype}; + CityPlacement { + city_id, + position: (10, 20), + attractor_type: AttractorType::CoastalAccess, + score: 100, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: orientation, + } + } + + #[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()) + 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()) + else { + unreachable!() + }; + quarter_id + }; + // Same inputs → same id; different city → different id. + assert_eq!(qid(3), qid(3)); + assert_ne!(qid(3), qid(4)); + } }