feat(simulation): wire L3->L4 GenerateSkeleton dispatch (T-1022)

The Layer-4 quarter-skeleton geometry (D-234: morphology-correct streets +
the D-234b waterfront rule) was fully implemented but dormant — no
production call site dispatched GenerateSkeleton from a settlement
placement, so founding_orientation stayed pinned to the
context_from_read_set Cardinal stub.

Expand the BodyAnalyzed arm of drain_generation_completions to, before
caching the body, submit one GenerateSkeleton per CityPlacement: build the
D-199 context from the read-set, override founding_orientation with the
attractor-matched value carried on the placement (D-213), and derive a
canonical namespace-isolated quarter_id from (world_seed, body, city)
(D-194/D-230) instead of the city_id*10 placeholder. Dispatch logic is
extracted into the pure build_skeleton_work_item helper for DB-free testing.

Reader + world seed are optional system params (mirrors serve_atlas_requests);
absent either, dispatch is skipped and the body is still cached. Empty
placements and per-placement read_set errors are handled (warn + skip).
Station/multi-source dispatch (Q-109) is out of scope — BodyAnalyzed only
fires on the planetary AnalyzeBody path.

Tests: orientation-override + canonical-quarter_id unit test and a
determinism/city-scoping test on the helper. Full lib suite green (1327).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:30:51 +02:00
co-authored by Claude Opus 4.8
parent 5dfea07bff
commit a69f4cf3ec
+174 -3
View File
@@ -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));
}
}