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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user