Files
settled-reach/server/src/atlas/city_context_reader.rs
T
jpmschweitzerandClaude Opus 4.8 9d8ff8b341 fix(simulation): fall back on unrecognized settlement_class (#955)
read_body_settlements propagated a parse error for any non-NULL but
unrecognized settlement_class, which the atlas proxy swallowed and then
enqueued the body with zero cities — one malformed row silently dropped
placement for the entire body. Treat an unknown variant like NULL: fall
back to PopulationBudget with a warning. parse_settlement_class stays
strict for the D-199 read_set path, which must abort on bad fields.

Found in PR #149 review (Hoshe H1). Adds a test covering the fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:33:24 +02:00

957 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_bps` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 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 — integer basis points, D-010):**
//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)`
//! where (all values are integer basis points; 10_000 bps = 1.0):
//! - `role_base_bps` — per-role lookup (10 values; D-197 table)
//! - `pop_bonus_bps` — `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200
//! - `terrain_bonus_bps` — 0 (Layer-1 topography not yet available here)
//! - `noise_bps` — ±500 symmetric uniform via `SeedChain` and integer modulo (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::atlas::attractor_matching::CityRecord;
use crate::bps::log10_floor;
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`.
/// Integer basis points (010_000; 10_000 = 1.0). D-010 integer-only.
pub prosperity_baseline_bps: u32,
/// 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_bps from role base + pop bonus + noise.
// All arithmetic is integer (basis points, 10_000 = 1.0) — D-010 compliant.
let prosperity_baseline_bps =
prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed);
Ok(CityEconomicReadSet {
economic_role,
prosperity_baseline_bps,
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))
}
/// 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 recognized non-NULL value is parsed as authored; an
/// *unrecognized* value also falls back to `PopulationBudget` (with a warning)
/// rather than erroring — one bad row must not zero out the whole body's
/// placements. `economic_role` NULL falls back to `residential` (the neutral
/// role).
///
/// Unlike [`read_set`](Self::read_set) (the strict D-199 path that aborts on
/// any malformed field), this method is best-effort: it never fails on row
/// content, only on a DB/connection error.
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).unwrap_or_else(|_| {
tracing::warn!(
city_id,
settlement_class = s,
"unrecognized settlement_class; defaulting to PopulationBudget"
);
SettlementClass::PopulationBudget
}),
None => SettlementClass::PopulationBudget,
};
out.push(CityRecord {
city_id,
name,
settlement_class,
population,
economic_role: role.unwrap_or_else(|| "residential".to_string()),
});
}
Ok(out)
}
}
// ---------------------------------------------------------------------------
// prosperity_baseline_bps derivation (D-197, partial — integer basis points)
// ---------------------------------------------------------------------------
/// Per-role base prosperity in basis points (D-197 table).
///
/// 10_000 bps = 1.0. Values match the previous f32 table scaled by ×10_000:
/// 0.55 → 5500, 0.70 → 7000, etc.
fn role_base_bps(role: &str) -> u32 {
match role {
"manufacturing" => 5_500,
"financial" => 7_000,
"agricultural" => 5_000,
"extraction" => 4_500,
"service_mixed" => 6_000,
"institutional" => 6_500,
"transit_hub" => 6_000,
"research" => 6_500,
"military" => 5_500,
"residential" => 5_000,
// Unknown role → mid-point fallback; logged by the caller if needed.
_ => 5_500,
}
}
/// Population log-scale bonus in basis points (D-197).
///
/// `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200.
///
/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010).
/// The `+1` inside the log10 ensures the result is ≥ 0 for any positive pop.
fn pop_bonus_bps(population: i64) -> u32 {
if population <= 0 {
return 0;
}
// ratio = pop / 1_000_000 + 1. Integer division (floors toward zero) matches
// floor(pop / 1_000_000.0) for positive values, then +1 gives ≥ 1 for log.
let ratio = (population as u64) / 1_000_000 + 1;
let mag = log10_floor(ratio);
// 400 bps per order of magnitude, cap at 1200 (3 orders).
(400 * mag).min(1_200)
}
/// ±500 bps symmetric uniform noise seeded deterministically from (world_seed, city_id).
///
/// Uses `SeedChain` to derive a per-city noise stream, then maps via integer
/// modulo to [-500, +500] — no floats, fixes the always-negative bug that
/// arose from the old code dividing a 31-bit RNG value by u32::MAX (D-010).
///
/// **SeedChain derivation:** seeds root → Layer3Settlement directly using the
/// city_id hash as the domain id. This is safe because city_id is globally
/// unique (it is the `atlas_city_names.id` primary key); no body_id is
/// available at read time.
fn prosperity_noise_bps(city_id: u64, world_seed: u64) -> i32 {
// Domain: Layer3Settlement (prosperity is a settlement-level property).
// city_id is globally unique, so root → Layer3Settlement is unambiguous.
let seed = SeedChain::root(world_seed)
.derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string()))
.seed();
let mut rng = AtlasRng::new(splitmix64(seed));
// Integer modulo over 1001 values (0..=1000), shifted to [-500, +500].
// AtlasRng::next_u32() returns a 31-bit value — modulo is uniform here
// because 1001 divides cleanly into the 31-bit range (2^31 / 1001 ≈ 2.1M,
// so bias is negligible, but correctness doesn't depend on that — we only
// need ±500 symmetric range, not strict uniformity).
(rng.next_u32() % 1_001) as i32 - 500
}
/// D-197 formula in integer basis points (terrain gradient left at 0 — requires Layer-1 data).
///
/// `clamp(role_base_bps + pop_bonus_bps + terrain_bonus_bps + noise_bps, 1000, 9500)`
///
/// All arithmetic is integer — no f32/f64 in the determinism path (D-010).
pub fn prosperity_baseline_from_read_set(
economic_role: &str,
population: i64,
city_id: u64,
world_seed: u64,
) -> u32 {
let base = role_base_bps(economic_role) as i32;
let pb = pop_bonus_bps(population) as i32;
// terrain_bonus_bps = 0: Layer-1 attractor output not available at this tier.
let noise = prosperity_noise_bps(city_id, world_seed);
let raw = base + pb + noise;
raw.clamp(1_000, 9_500) as u32
}
// ---------------------------------------------------------------------------
// 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_bps is D-199 field 2 (derived, D-197).
prosperity_baseline_bps: rs.prosperity_baseline_bps,
// ── 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,
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
#[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)
}
// ─── role_base_bps ───────────────────────────────────────────────────────
#[test]
fn role_base_financial_is_7000() {
assert_eq!(role_base_bps("financial"), 7_000);
}
#[test]
fn role_base_unknown_falls_back_to_5500() {
assert_eq!(role_base_bps("deep_space_weird_role"), 5_500);
}
// ─── pop_bonus_bps ───────────────────────────────────────────────────────
#[test]
fn pop_bonus_zero_population() {
assert_eq!(pop_bonus_bps(0), 0);
}
#[test]
fn pop_bonus_one_million() {
// pop=1_000_000 → ratio = 1_000_000 / 1_000_000 + 1 = 2 → log10_floor(2) = 0 → 0 bps
assert_eq!(pop_bonus_bps(1_000_000), 0);
}
#[test]
fn pop_bonus_ten_million() {
// pop=10_000_000 → ratio = 10 + 1 = 11 → log10_floor(11) = 1 → 400 bps
assert_eq!(pop_bonus_bps(10_000_000), 400);
}
#[test]
fn pop_bonus_hundred_million() {
// pop=100_000_000 → ratio = 100 + 1 = 101 → log10_floor(101) = 2 → 800 bps
assert_eq!(pop_bonus_bps(100_000_000), 800);
}
#[test]
fn pop_bonus_capped_at_1200() {
// Huge population → bonus capped at 1200.
assert_eq!(pop_bonus_bps(i64::MAX), 1_200);
}
// ─── prosperity_noise_bps ────────────────────────────────────────────────
#[test]
fn prosperity_noise_is_deterministic() {
let a = prosperity_noise_bps(42, 1234);
let b = prosperity_noise_bps(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_bps(city_id, 99);
assert!(
(-500..=500).contains(&n),
"noise {n} out of [-500, 500] for city_id={city_id}"
);
}
}
#[test]
fn prosperity_noise_positive_is_achievable() {
// Regression: old code divided a 31-bit RNG value by u32::MAX,
// making the result always < 0.5 and therefore always negative
// after the shift. Verify that at least one of a set of city_ids
// produces positive noise, confirming the bug is fixed.
let positives = (0u64..100)
.map(|id| prosperity_noise_bps(id, 12345))
.filter(|&n| n > 0)
.count();
assert!(
positives > 0,
"expected at least one positive noise value across 100 city_ids (was always-negative before fix)"
);
}
// ─── prosperity_baseline_from_read_set ───────────────────────────────────
#[test]
fn prosperity_baseline_bps_clamped_low() {
// extraction (4500) + zero pop + worst noise (-500) = 4000 → in [1000, 9500]
let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0);
assert!((1_000..=9_500).contains(&p));
}
#[test]
fn prosperity_baseline_bps_extraction_range() {
// extraction base 4500 ± 500 noise → expect [4000, 5000]
let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0);
assert!(
(4_000..=5_000).contains(&p),
"extraction/0 pop should be ~4500 ± 500, got {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_bps (non-stub: derived from real role+pop)
// financial base 7000 bps + pop_bonus(5M) bps ± 500 noise
let expected_base_bps = 7_000 + pop_bonus_bps(5_000_000);
let diff = (rs.prosperity_baseline_bps as i32 - expected_base_bps as i32).abs();
assert!(
diff <= 500 + 1,
"prosperity_baseline_bps {} not near expected base {} ± noise",
rs.prosperity_baseline_bps,
expected_base_bps
);
assert!((1_000..=9_500).contains(&rs.prosperity_baseline_bps));
// 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_bps_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");
// manufacturing base is 5500 bps; pop=2M → pop_bonus=0; noise in ±500.
// Result should be ≈50006000 bps.
assert!(
(1_000..=9_500).contains(&ctx.prosperity_baseline_bps),
"prosperity_baseline_bps must be in [1000, 9500], got {}",
ctx.prosperity_baseline_bps
);
assert!(
(4_900..=6_100).contains(&ctx.prosperity_baseline_bps),
"manufacturing/2M should produce ~5500 ± 600 bps, got {}",
ctx.prosperity_baseline_bps
);
}
#[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_bps_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_bps, rs2.prosperity_baseline_bps,
"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"
);
}
#[test]
fn read_body_settlements_unknown_class_falls_back_without_dropping_body() {
// A single row with an unrecognized settlement_class must NOT fail the
// whole read (which would enqueue the body with zero cities). It falls
// back to PopulationBudget and the other settlements are unaffected.
let db = make_settlements_db(&[
("Good", "financial", 500_000, Some("NameLocked")),
(
"Weird",
"manufacturing",
200_000,
Some("TotallyBogusVariant"),
),
]);
let reader = CityContextReader::open(&db).expect("open");
let cities = reader.read_body_settlements("PlanetX").expect("read");
assert_eq!(cities.len(), 2, "one bad row must not drop the whole body");
assert_eq!(cities[0].settlement_class, SettlementClass::NameLocked);
assert_eq!(
cities[1].settlement_class,
SettlementClass::PopulationBudget,
"unrecognized class defaults to PopulationBudget"
);
}
}