Merge pull request 'feat(simulation): wire L3->L4 GenerateSkeleton dispatch (T-1022)' (#157) from layer4-skeleton-dispatch into main
This commit was merged in pull request #157.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<CityPlacement>, body_world_state.rs:85) and submit one GenWorkItem::GenerateSkeleton per placement. System signature grows by Option<Res<CityContextReaderResource>> + Option<Res<SimRng>> (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);
|
||||
|
||||
+174
-3
@@ -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<GenerationQueue>,
|
||||
mut cache: ResMut<BodyWorldStateCache>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
rng: Option<Res<SimRng>>,
|
||||
) {
|
||||
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::<AtlasRequestBuffer>().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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user