feat(simulation): wire D-199 economic read-set into generation context (#954)
Layer 2 clean half. GenerateSkeleton now builds a CityGenerationContext from a body's real economic read-set instead of stub defaults. - New CityContextReader (server/src/atlas/city_context_reader.rs): reads the 6-field D-199 set (economic_role, prosperity_baseline, population, dominant_faction, founding_age_years, settlement_class) via one JOIN; prosperity_baseline derived per D-197. - GenWorkItem::GenerateSkeleton carries the resolved context; run_work_item builds the real context instead of DistrictWorldState::default(). - Left at defaults (deferred, documented): dominant_bulk_class / dominant_production_ubiquity (#982, design-blocked), trait_selection (#1005), morphology_zone + Layer-3 fields (attractor placement / D-204 / D-214). 16 new tests; cargo check/clippy --all-targets -D warnings clean; 1279 lib tests pass; fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
/// 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<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl CityContextReader {
|
||||
/// Open a read-only connection to `systems_db`.
|
||||
pub fn open(systems_db: &Path) -> Result<Self, CityContextReadError> {
|
||||
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<CityEconomicReadSet, CityContextReadError> {
|
||||
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<String>, // acn.economic_role
|
||||
i64, // acn.population
|
||||
Option<String>, // acn.settlement_class
|
||||
Option<i64>, // b.founding_age_years
|
||||
Option<String>, // 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<CityGenerationContext, CityContextReadError> {
|
||||
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<SettlementClass, CityContextReadError> {
|
||||
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<i64>,
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CityGenerationContext>,
|
||||
/// 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user