diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 3bd08dde4..f29c3f45b 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -99,3 +99,4 @@ Edge cases: empty state.placements -> loop submits nothing, returns normally (no Verified: Layer-4 geometry (D-234a/b) fully implemented in server/src/atlas/skeleton_gen.rs (derive_corridors reads morphology_zone, derive_access_points reads road_entry_directions, waterfront rule ~108-113 reads founding_orientation). Only the dispatch is dormant.', NULL, '2026-06-07 10:42:27', '2026-06-07 10:42:27', '2026-06-07 10:42:27', NULL, 'aa1911b38c699f46e21fed37aacde669', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1022', 'status', 'backlog', 'in_progress', NULL, '2026-06-07 10:44:24', '2026-06-07 10:44:24', '2026-06-07 10:44:24', NULL, 'bae44ce724f650962ff7d1d27aafd2bc', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1022', 'status', 'in_progress', 'review', NULL, '2026-06-07 11:33:25', '2026-06-07 11:33:25', '2026-06-07 11:33:25', NULL, '96b9df26d2ea52af819b5e0fd85c9bf7', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index a21b82cf1..8928d7598 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -281,3 +281,23 @@ DECISION for implementer (no open Q, one-liner): quarter_id has no canonical der Edge cases: empty state.placements -> loop submits nothing, returns normally (no special case). Q-109 (station/multi-source dispatch) non-blocking — BodyAnalyzed only fires on the planetary AnalyzeBody path; stations have no AnalyzeBody enqueue site yet. Verified: Layer-4 geometry (D-234a/b) fully implemented in server/src/atlas/skeleton_gen.rs (derive_corridors reads morphology_zone, derive_access_points reads road_entry_directions, waterfront rule ~108-113 reads founding_orientation). Only the dispatch is dormant.', 'in_progress', 'high', NULL, 'server', 'D-234', '2026-06-07 08:10:17', '2026-06-07 10:44:24', NULL, '31b663a2d5dbb8ad823ff3d54f8a4f1f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1022', 'story', 'T-750', 'Layer-3 -> Layer-4 GenerateSkeleton dispatch (founding_orientation wiring)', 'Wire Layer-3 settlement placement to Layer-4 quarter generation so the already-implemented Layer-4 geometry runs in production with real per-settlement orientation. Today there is NO production call site that dispatches GenerateSkeleton from a placement: city_context_reader.rs:484 stubs founding_orientation=Cardinal, so the implemented D-234b waterfront rule (and morphology-correct streets) stay dormant. + +Scope: on GenCompletion::BodyAnalyzed, iterate state.placements and submit one GenerateSkeleton work item per placed settlement, building CityGenerationContext from each CityPlacement (context_from_read_set, city_context_reader.rs:~462) and overriding founding_orientation with the attractor-matched CityPlacement.founding_orientation (already computed in attractor_matching.rs:~535, carried on CityPlacement:~63). + +Call sites: plugin.rs BodyAnalyzed handler (~84), gen_queue.rs GenerateSkeleton arm, city_context_reader.rs (stub at 484), attractor_matching.rs. Q-109 (multi-source/station dispatch) is adjacent but non-blocking for the planetary path. Surfaced by Si vetting (2026-06-07): T-976/989/990/991 geometry is done+tested but dormant without this dispatch. + +--- +Refinement (Si, 2026-06-07) — READY: + +Wiring point: drain_generation_completions in server/src/atlas/plugin.rs:78. The BodyAnalyzed arm (line 84) currently calls cache.insert(state) and discards placements. Expand it: after cache insert, iterate state.placements (Vec, body_world_state.rs:85) and submit one GenWorkItem::GenerateSkeleton per placement. System signature grows by Option> + Option> (world_seed), mirroring serve_atlas_requests; both already imported. + +Per placement: ctx = reader.build_context(placement.city_id, world_seed) (city_context_reader.rs:221) -> read_set -> context_from_read_set -> CityGenerationContext. Override the stub: ctx.founding_orientation = placement.founding_orientation.clone() (the one field stubbed Cardinal at city_context_reader.rs:484; value computed by founding_orientation() at attractor_matching.rs:535, carried on CityPlacement:63 from attractor.water_bearing + territorial_status). + +GenerateSkeleton other fields: economic_role/population/founding_age_years from reader.read_set() CityEconomicReadSet (call read_set directly to capture them; carried separately per gen_queue.rs:93-108). body_id = state.body_id.clone(). chain = SeedChain::for_body(world_seed,&body_id).derive(SeedDomain::Layer4Quarter, placement.city_id) (Layer4Quarter at seed.rs:92; mirrors Layer3Settlement use at city_context_reader.rs:389). + +DECISION for implementer (no open Q, one-liner): quarter_id has no canonical derivation yet (test placeholder city_id*10). Use SeedChain::for_body(world_seed,&body_id).derive(SeedDomain::Layer4Quarter, placement.city_id).seed() — deterministic, namespace-isolated; add a comment referencing D-194/D-230. + +Edge cases: empty state.placements -> loop submits nothing, returns normally (no special case). Q-109 (station/multi-source dispatch) non-blocking — BodyAnalyzed only fires on the planetary AnalyzeBody path; stations have no AnalyzeBody enqueue site yet. + +Verified: Layer-4 geometry (D-234a/b) fully implemented in server/src/atlas/skeleton_gen.rs (derive_corridors reads morphology_zone, derive_access_points reads road_entry_directions, waterfront rule ~108-113 reads founding_orientation). Only the dispatch is dormant.', 'review', 'high', NULL, 'server', 'D-234', '2026-06-07 08:10:17', '2026-06-07 11:33:25', NULL, '073bbf10c9acad1a04547af6aea8f632', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); 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)); + } }