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