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
+161
View File
@@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OpenFlags};
use thiserror::Error;
use crate::atlas::attractor_matching::CityRecord;
use crate::bps::log10_floor;
use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain};
use crate::simulation::generator::{
@@ -225,6 +226,63 @@ impl CityContextReader {
let rs = self.read_set(city_id, world_seed)?;
Ok(context_from_read_set(city_id, rs))
}
/// Read every settlement on `body_id` as [`CityRecord`]s for Layer-3
/// attractor placement (#955, D-211). Ordered by `id` for deterministic
/// input.
///
/// `settlement_class` is NULL at this stage (placement is what *derives* it,
/// D-196), so a NULL defaults to `PopulationBudget` — NOT `NameLocked` —
/// leaving `match_cities` to tier by population (the largest become the
/// Tier-A capitals). A non-NULL value is parsed as authored. `economic_role`
/// NULL falls back to `residential` (the neutral role).
pub fn read_body_settlements(
&self,
body_id: &str,
) -> Result<Vec<CityRecord>, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, name, economic_role, population, settlement_class
FROM atlas_city_names
WHERE body_id = ?1
ORDER BY id",
)
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let rows = stmt
.query_map([body_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, Option<String>>(4)?,
))
})
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (id, name, role, population, sclass) =
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let city_id = id as u64;
let settlement_class = match sclass.as_deref() {
Some(s) => parse_settlement_class(Some(s), city_id)?,
None => SettlementClass::PopulationBudget,
};
out.push(CityRecord {
city_id,
name,
settlement_class,
population,
economic_role: role.unwrap_or_else(|| "residential".to_string()),
});
}
Ok(out)
}
}
// ---------------------------------------------------------------------------
@@ -393,6 +451,16 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener
}
}
// ---------------------------------------------------------------------------
// Bevy resource wrapper
// ---------------------------------------------------------------------------
/// Bevy `Resource` wrapper — `Res<CityContextReaderResource>` in systems. Mirrors
/// [`crate::atlas::source_resolver::BodySourceResolverResource`]; the atlas proxy
/// uses it to read a body's settlements on a cache miss (#955).
#[derive(bevy_ecs::prelude::Resource)]
pub struct CityContextReaderResource(pub CityContextReader);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -753,4 +821,97 @@ mod tests {
"prosperity_baseline_bps must be deterministic for same inputs"
);
}
// ─── read_body_settlements (#955) ────────────────────────────────────────
/// Build a db with several settlements on one body, returning its path. Some
/// rows have a NULL `settlement_class` (the pre-placement state).
fn make_settlements_db(rows: &[(&str, &str, i64, Option<&str>)]) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_ctxst_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE atlas_city_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'city',
economic_role TEXT,
population INTEGER NOT NULL,
settlement_class TEXT
);",
)
.expect("create table");
for (name, role, pop, sclass) in rows {
conn.execute(
"INSERT INTO atlas_city_names (body_id, name, economic_role, population, settlement_class)
VALUES ('PlanetX', ?1, ?2, ?3, ?4)",
rusqlite::params![name, role, pop, sclass],
)
.expect("insert settlement");
}
drop(conn);
path
}
#[test]
fn read_body_settlements_defaults_null_class_to_population_budget() {
// NULL settlement_class is the pre-placement state. It must default to
// PopulationBudget, NOT NameLocked — NameLocked would force every
// settlement Tier-A in match_cities and collapse population tiering.
let db = make_settlements_db(&[("Capital", "financial", 2_000_000, None)]);
let reader = CityContextReader::open(&db).expect("open");
let cities = reader.read_body_settlements("PlanetX").expect("read");
assert_eq!(cities.len(), 1);
assert_eq!(
cities[0].settlement_class,
SettlementClass::PopulationBudget
);
assert_eq!(cities[0].population, 2_000_000);
assert_eq!(cities[0].economic_role, "financial");
}
#[test]
fn read_body_settlements_orders_by_id_and_parses_explicit_class() {
let db = make_settlements_db(&[
("Alpha", "agricultural", 50_000, Some("OrganicGrowth")),
("Beta", "manufacturing", 800_000, None),
("Gamma", "research", 300_000, Some("NameLocked")),
]);
let reader = CityContextReader::open(&db).expect("open");
let cities = reader.read_body_settlements("PlanetX").expect("read");
// Ordered by autoincrement id == insertion order.
let names: Vec<&str> = cities.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["Alpha", "Beta", "Gamma"]);
assert_eq!(cities[0].settlement_class, SettlementClass::OrganicGrowth);
assert_eq!(
cities[1].settlement_class,
SettlementClass::PopulationBudget
);
assert_eq!(cities[2].settlement_class, SettlementClass::NameLocked);
}
#[test]
fn read_body_settlements_falls_back_role_and_handles_empty() {
// NULL economic_role → "residential"; an unknown body → empty vec.
let db = make_settlements_db(&[("Lone", "", 10_000, None)]);
let conn = Connection::open(&db).expect("reopen");
conn.execute(
"UPDATE atlas_city_names SET economic_role = NULL WHERE name = 'Lone'",
[],
)
.expect("null role");
drop(conn);
let reader = CityContextReader::open(&db).expect("open");
let cities = reader.read_body_settlements("PlanetX").expect("read");
assert_eq!(cities[0].economic_role, "residential");
assert!(
reader
.read_body_settlements("Ghost")
.expect("read")
.is_empty(),
"unknown body yields no settlements"
);
}
}
+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![],
}
}
+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);
}
}
+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,
);
+17
View File
@@ -198,6 +198,23 @@ fn main() {
),
}
// Settlement reader for Layer-3 placement (#955): reads a body's settlements
// from systems.db on a cache miss so the cascade work item stays DB-free.
match settled_reach_server::atlas::city_context_reader::CityContextReader::open(
&systems_db_path,
) {
Ok(reader) => {
tracing::info!("City context reader opened: {:?}", systems_db_path);
app.insert_resource(
settled_reach_server::atlas::city_context_reader::CityContextReaderResource(reader),
);
}
Err(e) => tracing::warn!(
"City context reader unavailable ({}). Settlements will not be placed.",
e
),
}
// Initialize SQLite settings store (#627).
// Path: alongside save files in the server's working directory.
let settings_path = std::path::PathBuf::from("settings.db");