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
+26 -3
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick};
use crate::atlas::cascade::CascadeLayer;
use crate::atlas::city_context_reader::CityContextReader;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer1::Layer1Output;
use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError};
@@ -55,11 +56,16 @@ pub struct AtlasLayerResponse {
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
/// `world_seed` derives the body's `SeedChain` for the enqueued analysis.
///
/// `city_reader` supplies the body's settlements for Layer-3 placement (#955),
/// read on a cache miss. `None` (or a read failure) places no cities — the
/// cascade still runs Layer 1; the body just gets no settlement placements.
pub fn handle_atlas_request(
req: &AtlasLayerRequest,
cache: &mut BodyWorldStateCache,
queue: &GenerationQueue,
resolver: &BodySourceResolver,
city_reader: Option<&CityContextReader>,
world_seed: u64,
current_tick: SimTick,
) -> AtlasLayerResponse {
@@ -85,12 +91,29 @@ pub fn handle_atlas_request(
// Miss — resolve the source heightmap and enqueue background analysis.
match resolver.resolve(&req.body_id) {
Ok(heightmap_path) => {
// Pre-resolve this body's settlements so the Rayon work item stays
// DB-free (#955, D-225). A read failure is non-fatal: log and place
// no cities (Layer 1 still runs).
let cities = match city_reader {
Some(reader) => reader
.read_body_settlements(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"settlement read failed; placing no cities"
);
Vec::new()
}),
None => Vec::new(),
};
queue.submit(
GenWorkItem::AnalyzeBody {
body_id: req.body_id.clone(),
heightmap_path,
sea_level: DEFAULT_SEA_LEVEL,
body_seed: SeedChain::for_body(world_seed, &req.body_id),
cities,
},
GenPriority::Immediate,
);
@@ -207,7 +230,7 @@ mod tests {
let (_db, resolver) = empty_resolver();
let queue = GenerationQueue::with_threads(1);
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, 42, 1);
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1);
assert_eq!(resp.status, AtlasLayerStatus::Ready);
assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c");
}
@@ -218,7 +241,7 @@ mod tests {
let (_db, resolver) = resolver_with_body("GJ1c");
let queue = GenerationQueue::with_threads(1);
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, 42, 1);
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1);
assert_eq!(resp.status, AtlasLayerStatus::Pending);
assert!(resp.layer1.is_none());
@@ -239,7 +262,7 @@ mod tests {
let (_db, resolver) = empty_resolver();
let queue = GenerationQueue::with_threads(1);
let resp = handle_atlas_request(&req("ghost"), &mut cache, &queue, &resolver, 42, 1);
let resp = handle_atlas_request(&req("ghost"), &mut cache, &queue, &resolver, None, 42, 1);
assert_eq!(resp.status, AtlasLayerStatus::NotFound);
}
}