diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index e2f8d4904..bc10b1b2a 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -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, 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>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Option>(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` 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" + ); + } } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 6d7baefa3..a3a4c20df 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -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, }, /// 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![], } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 8cd30127f..ce2308445 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -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); } } diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index a2fbe6d72..fabef8554 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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, queue: Res, resolver: Option>, + city_reader: Option>, rng: Option>, time: Option>, ) { @@ -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, ); diff --git a/server/src/main.rs b/server/src/main.rs index f7e3af1a1..6d89a9def 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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");