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
+16 -4
View File
@@ -27,6 +27,7 @@ use std::sync::{Arc, Mutex};
use bevy_ecs::prelude::Resource;
use crossbeam_channel::{Receiver, Sender};
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::body_world_state::BodyWorldState;
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
@@ -67,6 +68,10 @@ pub enum GenWorkItem {
heightmap_path: PathBuf,
sea_level: f32,
body_seed: SeedChain,
/// The body's settlements (from `atlas_city_names`), pre-resolved at
/// dispatch time so the cascade stays DB-free. Fed to Layer-3 placement
/// (#955); empty if the body has no settlements (cascade stops at Layer 1).
cities: Vec<CityRecord>,
},
/// Generate a Phase 1 DistrictSkeleton for this city.
///
@@ -347,6 +352,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
heightmap_path,
sea_level,
body_seed,
cities,
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
Ok(hm) => {
// Layer 1 runs at the GRID_W×GRID_H working resolution (D-202):
@@ -356,10 +362,15 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
} else {
hm
};
// Cities (&[]) get supplied once the Layer-3 settlement read is
// wired through the work item (#955 follow-on); Topography needs none.
let snapshot =
run_cascade_from_heightmap(*body_seed, working, &[], CascadeLayer::Topography);
// Run through Layer 3 (settlement placement, #955): the enqueuer
// pre-resolved this body's settlements onto `cities`. A body with
// no settlements yields empty placements at negligible cost.
let snapshot = run_cascade_from_heightmap(
*body_seed,
working,
cities,
CascadeLayer::Settlement,
);
GenCompletion::BodyAnalyzed {
body_id: body_id.clone(),
state: snapshot.into_body_world_state(),
@@ -451,6 +462,7 @@ mod tests {
heightmap_path: test_heightmap_path(),
sea_level: 0.3,
body_seed: SeedChain::for_body(42, body_id),
cities: vec![],
}
}