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"
);
}
}