feat(simulation): run Layer-3 settlement placement in the runtime cascade (#955)

Wire the existing D-211 attractor-matching engine into the live
generation cascade so settlements are placed in-game, not just in tests.

- CityContextReader::read_body_settlements reads a body's settlements
  from atlas_city_names (ordered by id for determinism). NULL
  settlement_class defaults to PopulationBudget, not NameLocked: the
  class is NULL until placement runs, and NameLocked would force every
  settlement Tier-A in match_cities and collapse population tiering
  (D-211). NULL economic_role falls back to residential.
- The AnalyzeBody work item carries the body's Vec<CityRecord>, and
  run_work_item now runs up_to Settlement (was Topography). A body with
  no settlements yields empty placements at negligible cost.
- The atlas layer proxy reads settlements on a cache miss and pins them
  onto the work item, keeping the Rayon task DB-free (D-225). A read
  failure is non-fatal: log and place no cities (Layer 1 still runs).
  Threaded through a new CityContextReaderResource Bevy resource opened
  in main.rs, mirroring BodySourceResolverResource.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:14:18 +02:00
co-authored by Claude Opus 4.8
parent f748b017cb
commit e2787fbfb1
5 changed files with 227 additions and 8 deletions
+7 -1
View File
@@ -12,6 +12,7 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
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::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
use crate::atlas::source_resolver::BodySourceResolverResource;
@@ -44,6 +45,7 @@ fn serve_atlas_requests(
mut cache: ResMut<BodyWorldStateCache>,
queue: Res<GenerationQueue>,
resolver: Option<Res<BodySourceResolverResource>>,
city_reader: Option<Res<CityContextReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
@@ -52,10 +54,13 @@ fn serve_atlas_requests(
}
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 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, world_seed, tick),
Some(r) => {
handle_atlas_request(&req, &mut cache, &queue, &r.0, reader, world_seed, tick)
}
None => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
@@ -155,6 +160,7 @@ mod tests {
heightmap_path: test_heightmap_path(),
sea_level: 0.3,
body_seed: SeedChain::for_body(42, "PlanetX"),
cities: vec![],
},
GenPriority::Immediate,
);