One commit for two tickets whose changes share the bridge/plugin plumbing files. T-1169 connects the three dormant feature-name pieces: atlas_feature_names populated at regen (17,891 rows — 15,190 mountain, 2,701 river — via populate_atlas_feature_names mirroring the city-names importer; systems.db regenerated, stamp fresh), attach_feature_names wired into the cascade's Topography block with name pools threaded DB-free through AnalyzeBody (D-225 pattern) and assignments stored on Layer1Output/BodyWorldState for future consumers, and a FeatureNamesRequest/Response read proxy as the bridge's 7th tagged envelope (D-236 pattern, both SimBridge impls). Client label DRAW is deliberately NOT here — implementation proved both river and mountain labels need a wire-carried position (the pool is position-free; course polylines aren't correlated with the named attractors by construction) — deferred to T-1195's single design pass. cascade_layer1 golden re-pinned (additive feature_names field). T-1159 retires the legacy u32 granularity field fully shadowed by window_granularity_v2: AtlasLayerRequest.window_granularity, DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and resolve_window_granularity are gone server-side; client encode paths and the caller-less atlas_window_cache legacy key component dropped; msgpack fixtures regenerated; the T-1150 aliasing regression test now drives through the surviving enum field. The district_window carrier itself survives byte-compatible per D-255(c). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1547 lines
64 KiB
Rust
1547 lines
64 KiB
Rust
//! 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 (the D-232 phase-1 K-draw runs at L3→L4 dispatch
|
||
//! time, T-994 — see `atlas::plugin::build_skeleton_work_item` — this reader
|
||
//! has no body-wide coverage view)
|
||
//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked)
|
||
//! - `dominant_production_ubiquity` — Common default (#982 design-blocked)
|
||
//! - `body_district_type_mix` / `settlement_district_pos` / `district_dominant_by_type`
|
||
//! — T-994 additions, all require body-wide aggregation this per-city reader
|
||
//! doesn't have; overridden at L3→L4 dispatch time
|
||
//!
|
||
//! **Field read, not deferred (T-994 addition):** `geographic_sector` — from
|
||
//! `star_systems.geographic_sector` (via `bodies.system_id`). A single
|
||
//! per-system value, unlike the four fields above.
|
||
//!
|
||
//! **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::collections::BTreeMap;
|
||
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,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared SQL fragments
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The D-242 standalone-corp-HQ LEFT JOIN (T-1076 §1; single source of truth,
|
||
/// PR #178 T2). Marks an `atlas_city_names` row (aliased `acn`) as a Standalone
|
||
/// corp-HQ company town when a `corporations` row (aliased `c`) matches on
|
||
/// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and
|
||
/// `proper_name = name` — the exact shape
|
||
/// `populate_standalone_hq_settlements` (economy_import/corporations.py) emits
|
||
/// settlement rows with. Composed into a query whose FROM clause aliases
|
||
/// `atlas_city_names AS acn`; the flag is read as `c.corp_id IS NOT NULL`.
|
||
/// Used by [`CityContextReader::read_body_settlements`] and the believability
|
||
/// harness's `read_cities` — extend both if the join shape ever changes.
|
||
pub(crate) const STANDALONE_HQ_JOIN_SQL: &str = "LEFT JOIN corporations AS c
|
||
ON c.hq_placement = 'Standalone'
|
||
AND c.headquarters_body = acn.body_id
|
||
AND c.proper_name = acn.name";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 (0–10_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,
|
||
/// Not a D-199 field — T-994 (D-232) addition. This city's system corridor
|
||
/// (`star_systems.geographic_sector`). `None` if unset. A soft weight on the
|
||
/// trait-template draw only, never a gate.
|
||
pub geographic_sector: Option<String>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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).
|
||
// star_systems carries geographic_sector (via bodies.system_id, T-994).
|
||
//
|
||
// 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).
|
||
// geographic_sector is nullable (some systems have no recorded corridor).
|
||
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
|
||
Option<String>, // ss.geographic_sector
|
||
)> = conn.query_row(
|
||
"SELECT
|
||
acn.economic_role,
|
||
acn.population,
|
||
acn.settlement_class,
|
||
b.founding_age_years,
|
||
sf.dominant_faction,
|
||
ss.geographic_sector
|
||
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
|
||
LEFT JOIN star_systems AS ss ON ss.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)?,
|
||
row.get(5)?,
|
||
))
|
||
},
|
||
);
|
||
|
||
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,
|
||
geographic_sector,
|
||
) = 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,
|
||
geographic_sector,
|
||
})
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// `is_standalone_hq` (T-1076 §1): the [`STANDALONE_HQ_JOIN_SQL`] LEFT JOIN
|
||
/// marks the rows that are D-242 Standalone corp-HQ company towns. The
|
||
/// road-graph hub rule demotes these to minor nodes regardless of
|
||
/// population. The believability harness's own settlement read
|
||
/// (`believability::read_cities`) composes the same shared constant —
|
||
/// single source of truth for the join (PR #178 T2).
|
||
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 sql = format!(
|
||
"SELECT acn.id, acn.name, acn.economic_role, acn.population,
|
||
acn.settlement_class, COALESCE(acn.kind, 'city'),
|
||
c.corp_id IS NOT NULL
|
||
FROM atlas_city_names AS acn
|
||
{STANDALONE_HQ_JOIN_SQL}
|
||
WHERE acn.body_id = ?1
|
||
ORDER BY acn.id"
|
||
);
|
||
let mut stmt = conn
|
||
.prepare(&sql)
|
||
.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)?,
|
||
row.get::<_, String>(5)?,
|
||
row.get::<_, bool>(6)?,
|
||
))
|
||
})
|
||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||
|
||
let mut out = Vec::new();
|
||
for r in rows {
|
||
let (id, name, role, population, sclass, kind, is_standalone_hq) =
|
||
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()),
|
||
is_capital: kind == "capital",
|
||
is_standalone_hq,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Read the body's system `dominant_faction` (D-237) for Layer-3
|
||
/// TerritorialStatus + spatial-character derivation (#956). Joins
|
||
/// `bodies` → `system_factions` via `system_id`. Returns `None` when the
|
||
/// body is unknown or its system has no recorded faction (both → the
|
||
/// `FrontierUnclaimed` default downstream). Best-effort: only a DB/mutex
|
||
/// error fails.
|
||
pub fn read_body_dominant_faction(
|
||
&self,
|
||
body_id: &str,
|
||
) -> Result<Option<String>, CityContextReadError> {
|
||
let conn = self
|
||
.conn
|
||
.lock()
|
||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||
let result = conn.query_row(
|
||
"SELECT sf.dominant_faction
|
||
FROM bodies AS b
|
||
LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id
|
||
WHERE b.body_id = ?1",
|
||
[body_id],
|
||
|row| row.get::<_, Option<String>>(0),
|
||
);
|
||
match result {
|
||
Ok(faction) => Ok(faction),
|
||
// Body not present → no faction (treated as frontier downstream).
|
||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||
Err(e) => Err(CityContextReadError::Db(e.to_string())),
|
||
}
|
||
}
|
||
|
||
/// D-236 Sol-exclusion gate: `true` if `body_id`'s system is Sol.
|
||
///
|
||
/// Sol (system `GJ-0`) is permanently out of the generation cascade — the
|
||
/// two signals D-236 names as equivalent gate flags are checked directly:
|
||
/// `bodies.system_id = 'GJ-0'` *or* the joined
|
||
/// `system_history.settlement_wave = 'origin'`. Either one alone is
|
||
/// sufficient (defence in depth; in practice they always agree — `'origin'`
|
||
/// is a one-off wave value only ever assigned to GJ-0).
|
||
///
|
||
/// An unknown `body_id` is **not** treated as Sol (`Ok(false)`) — that's a
|
||
/// distinct "no such body" outcome the caller's own not-found handling
|
||
/// covers (mirrors [`read_body_dominant_faction`](Self::read_body_dominant_faction)'s
|
||
/// convention). Only a DB/mutex error fails.
|
||
pub fn is_sol_body(&self, body_id: &str) -> Result<bool, CityContextReadError> {
|
||
let conn = self
|
||
.conn
|
||
.lock()
|
||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||
let result = conn.query_row(
|
||
"SELECT b.system_id, sh.settlement_wave
|
||
FROM bodies AS b
|
||
LEFT JOIN system_history AS sh ON sh.system_id = b.system_id
|
||
WHERE b.body_id = ?1",
|
||
[body_id],
|
||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||
);
|
||
match result {
|
||
Ok((system_id, settlement_wave)) => {
|
||
Ok(system_id == "GJ-0" || settlement_wave.as_deref() == Some("origin"))
|
||
}
|
||
// Body not present → not (specifically) Sol; the caller's own
|
||
// not-found handling applies to the "unknown body" case.
|
||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
|
||
Err(e) => Err(CityContextReadError::Db(e.to_string())),
|
||
}
|
||
}
|
||
|
||
/// Read every authored settlement **name** on `body_id` from
|
||
/// `atlas_city_names` (T-949 — replaces the client's names-only
|
||
/// `markers.json` read for non-Sol bodies, D-223/D-236). Unlike
|
||
/// [`read_body_settlements`](Self::read_body_settlements) this returns only
|
||
/// the id/name/capital-flag triple — no economic/placement fields — and is
|
||
/// available immediately (it doesn't require the generation cascade to have
|
||
/// placed anything). Ordered by `id`. An unknown body yields an empty list
|
||
/// (matches `read_body_settlements`'s convention); only a DB/mutex error
|
||
/// fails.
|
||
pub fn read_body_city_names(
|
||
&self,
|
||
body_id: &str,
|
||
) -> Result<Vec<CityNameRow>, CityContextReadError> {
|
||
let conn = self
|
||
.conn
|
||
.lock()
|
||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT id, name, COALESCE(kind, 'city')
|
||
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::<_, String>(2)?,
|
||
))
|
||
})
|
||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||
|
||
let mut out = Vec::new();
|
||
for r in rows {
|
||
let (id, name, kind) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||
out.push(CityNameRow {
|
||
city_id: id as u64,
|
||
name,
|
||
is_capital: kind == "capital",
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Read every reserved geographic feature **name** on `body_id` from
|
||
/// `atlas_feature_names` (T-1169 — mirrors [`read_body_city_names`]
|
||
/// exactly, D-236 pattern, over `atlas_feature_names` instead of
|
||
/// `atlas_city_names`). Returns the id/name/feature_type triple — this is
|
||
/// the raw reserved-name POOL, not a position assignment (positions come
|
||
/// from `layer1::attach_feature_names` at cascade generation time, not
|
||
/// from this reader). Ordered by `id`. An unknown body yields an empty
|
||
/// list (matches `read_body_city_names`'s convention); only a DB/mutex
|
||
/// error fails. **No Sol check here** — unlike city names, this is a raw
|
||
/// pool read with no per-body caller-facing status enum; the proxy
|
||
/// handler (`atlas_data_proxy::handle_feature_names_request`) applies the
|
||
/// same D-236 Sol exclusion `handle_city_names_request` does, BEFORE
|
||
/// calling this method, so Sol bodies never reach this query in practice.
|
||
pub fn read_body_feature_names(
|
||
&self,
|
||
body_id: &str,
|
||
) -> Result<Vec<FeatureNameRow>, CityContextReadError> {
|
||
let conn = self
|
||
.conn
|
||
.lock()
|
||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT id, name, feature_type
|
||
FROM atlas_feature_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::<_, String>(2)?,
|
||
))
|
||
})
|
||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||
|
||
let mut out = Vec::new();
|
||
for r in rows {
|
||
let (id, name, feature_type) =
|
||
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||
out.push(FeatureNameRow {
|
||
feature_id: id as u64,
|
||
name,
|
||
feature_type,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
|
||
/// One row of the T-949 names-only read (see
|
||
/// [`CityContextReader::read_body_city_names`]).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct CityNameRow {
|
||
pub city_id: u64,
|
||
pub name: String,
|
||
pub is_capital: bool,
|
||
}
|
||
|
||
/// One row of the T-1169 names-only read (see
|
||
/// [`CityContextReader::read_body_feature_names`]).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct FeatureNameRow {
|
||
pub feature_id: u64,
|
||
pub name: String,
|
||
/// `atlas_feature_names.feature_type` — `"river"` | `"mountain"` (the two
|
||
/// pools `import_economics`/`atlas.py::populate_atlas_feature_names`
|
||
/// currently populates, T-1169 scope). Carried as a raw string, not an
|
||
/// enum — mirrors `CityNameRow.is_capital`'s discipline of staying a thin
|
||
/// passthrough of the DB row, no server-side vocabulary gate here.
|
||
pub feature_type: String,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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: the D-232 phase-1 K-draw runs at L3→L4 dispatch time
|
||
// (build_skeleton_work_item, T-994) — this reader has no body-wide view
|
||
// (coverage aggregate, catalog reader) so it is left empty here and
|
||
// overridden by the caller, same pattern as morphology_zone/political_archetype.
|
||
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,
|
||
|
||
// ── T-994 additions ────────────────────────────────────────────────
|
||
// geographic_sector: the one D-199-style read-set field this ticket adds
|
||
// (star_systems.geographic_sector via the city's system_id) — real value,
|
||
// not a stub.
|
||
geographic_sector: rs.geographic_sector,
|
||
// body_district_type_mix / settlement_district_pos / district_dominant_by_type:
|
||
// all three require body-wide aggregation (every settlement's district
|
||
// mix, the settlement's world position) that this per-city reader doesn't
|
||
// have. Overridden at L3→L4 dispatch time (build_skeleton_work_item).
|
||
body_district_type_mix: Vec::new(),
|
||
settlement_district_pos: (0, 0),
|
||
district_dominant_by_type: BTreeMap::new(),
|
||
// ── T-1003 additions — same dispatch-time override story as above:
|
||
// driver rates need the road graph, pools need the resolved catalog.
|
||
swerve_rates_bps: (0, 0),
|
||
swerve_foreign_pool: Vec::new(),
|
||
swerve_heritage_pool: Vec::new(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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, geographic_sector TEXT);
|
||
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));
|
||
// geographic_sector: absent in this fixture (no UPDATE below) -> None.
|
||
assert_eq!(rs.geographic_sector, None);
|
||
}
|
||
|
||
// ─── geographic_sector (T-994) ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn read_set_threads_geographic_sector_from_star_systems() {
|
||
let (db, city_id) = make_test_db(
|
||
"GJ8d",
|
||
"GJ-8",
|
||
"financial",
|
||
1_000_000,
|
||
Some("NameLocked"),
|
||
Some(100),
|
||
None,
|
||
);
|
||
let conn = Connection::open(&db).expect("reopen");
|
||
conn.execute(
|
||
"UPDATE star_systems SET geographic_sector = 'east_reach' WHERE system_id = 'GJ-8'",
|
||
[],
|
||
)
|
||
.expect("set sector");
|
||
drop(conn);
|
||
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
let rs = reader.read_set(city_id as u64, 42).expect("read set");
|
||
assert_eq!(rs.geographic_sector.as_deref(), Some("east_reach"));
|
||
|
||
let ctx = context_from_read_set(city_id as u64, rs);
|
||
assert_eq!(ctx.geographic_sector.as_deref(), Some("east_reach"));
|
||
}
|
||
|
||
#[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 ≈5000–6000 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). Includes
|
||
/// an EMPTY `corporations` table — `read_body_settlements`' T-1076
|
||
/// standalone-HQ LEFT JOIN references it, so the fixture schema must carry
|
||
/// it (empty ⇒ every row reads `is_standalone_hq = false`). Use
|
||
/// `add_standalone_corp` to mark one.
|
||
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
|
||
);
|
||
CREATE TABLE corporations (
|
||
corp_id TEXT PRIMARY KEY,
|
||
proper_name TEXT NOT NULL,
|
||
hq_placement TEXT,
|
||
headquarters_body 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
|
||
}
|
||
|
||
/// Register `name` on `body_id` as a D-242 Standalone corp HQ in the
|
||
/// fixture's `corporations` table (the shape the T-1076 join matches).
|
||
fn add_standalone_corp(db: &PathBuf, corp_id: &str, name: &str, body_id: &str) {
|
||
let conn = Connection::open(db).expect("reopen");
|
||
conn.execute(
|
||
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
|
||
VALUES (?1, ?2, 'Standalone', ?3)",
|
||
rusqlite::params![corp_id, name, body_id],
|
||
)
|
||
.expect("insert corp");
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
// ─── D-242 corp-HQ settlement model (T-1074/T-1075/T-1076) ───────────────
|
||
//
|
||
// A Standalone-HQ settlement (economy_import/corporations.py:
|
||
// populate_standalone_hq_settlements) is inserted into atlas_city_names as
|
||
// an ORDINARY row — same schema, same fields, no corp linkage marker on
|
||
// the row itself (the corp<->settlement relationship lives on
|
||
// corporations.headquarters_body). The T-1074 invariant test below
|
||
// predicted that any future special-casing "will have to be threaded
|
||
// through explicitly" — T-1076 §1 is exactly that threading: an explicit
|
||
// LEFT JOIN against corporations now derives `is_standalone_hq`, consumed
|
||
// ONLY by the road-graph hub rule. The D-211 placement fields stay
|
||
// identical to an ordinary city's (the equal-terms attractor invariant in
|
||
// attractor_matching.rs still holds).
|
||
|
||
#[test]
|
||
fn read_body_settlements_treats_standalone_hq_row_identically_to_pooled_city() {
|
||
// "Gate Corporation" here stands in for a Phase-B-inserted
|
||
// Standalone-HQ row (name = corp proper_name, economic_role from
|
||
// corp_hq_placement.toml's standalone_economic_role, population from
|
||
// the T-1075 Zipf bake, settlement_class from the same bake/override
|
||
// path as any other city) — identical in D-211 placement shape to the
|
||
// ordinary pooled cities "Tributarium"/"Ruhr" it sits alongside.
|
||
// (Without a corporations row registering it, the T-1076 join also
|
||
// leaves is_standalone_hq = false — the flag comes only from the
|
||
// corporations side, never from the city row.)
|
||
let db = make_settlements_db(&[
|
||
(
|
||
"Tributarium",
|
||
"manufacturing",
|
||
2_727_273_224,
|
||
Some("PopulationBudget"),
|
||
),
|
||
(
|
||
"Ruhr",
|
||
"manufacturing",
|
||
1_363_636_611,
|
||
Some("PopulationBudget"),
|
||
),
|
||
(
|
||
"Gate Corporation",
|
||
"manufacturing",
|
||
909_090_165,
|
||
Some("NameLocked"),
|
||
),
|
||
]);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
let cities = reader.read_body_settlements("PlanetX").expect("read");
|
||
assert_eq!(cities.len(), 3, "all three rows read back, HQ or not");
|
||
|
||
let hq = cities
|
||
.iter()
|
||
.find(|c| c.name == "Gate Corporation")
|
||
.expect("Standalone-HQ row must be present");
|
||
// Every CityRecord field the D-211 pipeline reads is populated exactly
|
||
// like an ordinary city's.
|
||
assert_eq!(hq.economic_role, "manufacturing");
|
||
assert_eq!(hq.population, 909_090_165);
|
||
assert_eq!(hq.settlement_class, SettlementClass::NameLocked);
|
||
assert!(!hq.is_capital, "Standalone HQ is not a capital by default");
|
||
assert!(
|
||
!hq.is_standalone_hq,
|
||
"no corporations row registers this name — the flag must stay false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn read_body_settlements_flags_standalone_hq_via_corporations_join() {
|
||
// T-1076 §1: the corporations LEFT JOIN marks exactly the rows whose
|
||
// (headquarters_body, proper_name) matches a Standalone corp — the
|
||
// shape populate_standalone_hq_settlements emits.
|
||
let db = make_settlements_db(&[
|
||
(
|
||
"Tributarium",
|
||
"manufacturing",
|
||
2_000_000,
|
||
Some("PopulationBudget"),
|
||
),
|
||
(
|
||
"Gate Corporation",
|
||
"manufacturing",
|
||
909_090_165,
|
||
Some("PopulationBudget"),
|
||
),
|
||
]);
|
||
add_standalone_corp(&db, "gate-corporation", "Gate Corporation", "PlanetX");
|
||
// A Standalone corp on a DIFFERENT body with the same proper_name must
|
||
// NOT mark PlanetX's row (the join keys on headquarters_body too).
|
||
add_standalone_corp(&db, "other-corp", "Tributarium", "PlanetY");
|
||
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
let cities = reader.read_body_settlements("PlanetX").expect("read");
|
||
let hq = cities
|
||
.iter()
|
||
.find(|c| c.name == "Gate Corporation")
|
||
.unwrap();
|
||
let pool = cities.iter().find(|c| c.name == "Tributarium").unwrap();
|
||
assert!(
|
||
hq.is_standalone_hq,
|
||
"registered Standalone corp row is flagged"
|
||
);
|
||
assert!(
|
||
!pool.is_standalone_hq,
|
||
"same-name corp on another body must not leak the flag across bodies"
|
||
);
|
||
}
|
||
|
||
// ─── read_body_city_names (T-949) ────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn read_body_city_names_returns_id_name_capital() {
|
||
let db = make_settlements_db(&[
|
||
("Capital", "financial", 2_000_000, Some("NameLocked")),
|
||
("Outpost", "extraction", 5_000, None),
|
||
]);
|
||
let conn = Connection::open(&db).expect("reopen");
|
||
conn.execute(
|
||
"UPDATE atlas_city_names SET kind = 'capital' WHERE name = 'Capital'",
|
||
[],
|
||
)
|
||
.expect("set capital");
|
||
drop(conn);
|
||
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
let names = reader.read_body_city_names("PlanetX").expect("read");
|
||
assert_eq!(names.len(), 2);
|
||
// Ordered by id == insertion order.
|
||
assert_eq!(names[0].name, "Capital");
|
||
assert!(names[0].is_capital);
|
||
assert_eq!(names[1].name, "Outpost");
|
||
assert!(
|
||
!names[1].is_capital,
|
||
"the default 'city' kind must not read as capital"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn read_body_city_names_unknown_body_is_empty() {
|
||
let db = make_settlements_db(&[("Solo", "residential", 10_000, None)]);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(
|
||
reader
|
||
.read_body_city_names("Ghost")
|
||
.expect("read")
|
||
.is_empty(),
|
||
"unknown body yields no names, matching read_body_settlements' convention"
|
||
);
|
||
}
|
||
|
||
// ─── read_body_feature_names (T-1169) ────────────────────────────────────
|
||
|
||
/// Minimal db for `read_body_feature_names`: just `atlas_feature_names`.
|
||
fn make_features_db(rows: &[(&str, &str)]) -> PathBuf {
|
||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let path = std::env::temp_dir().join(format!("sr_ctxfeat_{}_{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_feature_names (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
body_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
feature_type TEXT NOT NULL
|
||
);",
|
||
)
|
||
.expect("create table");
|
||
for (name, feature_type) in rows {
|
||
conn.execute(
|
||
"INSERT INTO atlas_feature_names (body_id, name, feature_type)
|
||
VALUES ('PlanetX', ?1, ?2)",
|
||
rusqlite::params![name, feature_type],
|
||
)
|
||
.expect("insert feature");
|
||
}
|
||
drop(conn);
|
||
path
|
||
}
|
||
|
||
#[test]
|
||
fn read_body_feature_names_returns_id_name_type() {
|
||
let db = make_features_db(&[("Wiesenbach", "mountain"), ("Kaltfluss", "river")]);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
let names = reader.read_body_feature_names("PlanetX").expect("read");
|
||
assert_eq!(names.len(), 2);
|
||
// Ordered by id == insertion order.
|
||
assert_eq!(names[0].name, "Wiesenbach");
|
||
assert_eq!(names[0].feature_type, "mountain");
|
||
assert_eq!(names[1].name, "Kaltfluss");
|
||
assert_eq!(names[1].feature_type, "river");
|
||
}
|
||
|
||
#[test]
|
||
fn read_body_feature_names_unknown_body_is_empty() {
|
||
let db = make_features_db(&[("Solo Peak", "mountain")]);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(
|
||
reader
|
||
.read_body_feature_names("Ghost")
|
||
.expect("read")
|
||
.is_empty(),
|
||
"unknown body yields no names, matching read_body_city_names' convention"
|
||
);
|
||
}
|
||
|
||
// ─── is_sol_body (T-949, D-236) ──────────────────────────────────────────
|
||
|
||
/// Minimal db for `is_sol_body`: one `bodies` row + an optional
|
||
/// `system_history` row carrying `settlement_wave`.
|
||
fn make_sol_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
|
||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let path = std::env::temp_dir().join(format!("sr_ctxsol_{}_{n}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&path);
|
||
let conn = Connection::open(&path).expect("create db");
|
||
conn.execute_batch(
|
||
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL);
|
||
CREATE TABLE system_history (
|
||
system_id TEXT PRIMARY KEY,
|
||
settlement_wave TEXT
|
||
);",
|
||
)
|
||
.expect("create tables");
|
||
conn.execute(
|
||
"INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)",
|
||
rusqlite::params![body_id, system_id],
|
||
)
|
||
.expect("insert body");
|
||
if let Some(wave) = settlement_wave {
|
||
conn.execute(
|
||
"INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)",
|
||
rusqlite::params![system_id, wave],
|
||
)
|
||
.expect("insert system_history");
|
||
}
|
||
drop(conn);
|
||
path
|
||
}
|
||
|
||
#[test]
|
||
fn is_sol_body_true_for_gj0_system_id() {
|
||
let db = make_sol_db("Earth", "GJ-0", None);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(reader.is_sol_body("Earth").expect("query"));
|
||
}
|
||
|
||
#[test]
|
||
fn is_sol_body_true_for_origin_settlement_wave() {
|
||
// D-236 names `system_id = 'GJ-0'` and `settlement_wave = 'origin'` as
|
||
// equivalent gate signals — a body whose system carries the 'origin'
|
||
// wave (even under a hypothetically different system_id) must also be
|
||
// excluded, not just a literal "GJ-0" string match.
|
||
let db = make_sol_db("Weirdbody", "GJ-999", Some("origin"));
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(reader.is_sol_body("Weirdbody").expect("query"));
|
||
}
|
||
|
||
#[test]
|
||
fn is_sol_body_false_for_ordinary_body() {
|
||
let db = make_sol_db("GJ1c", "GJ-1", Some("first_wave"));
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(!reader.is_sol_body("GJ1c").expect("query"));
|
||
}
|
||
|
||
#[test]
|
||
fn is_sol_body_false_for_unknown_body() {
|
||
let db = make_sol_db("GJ1c", "GJ-1", None);
|
||
let reader = CityContextReader::open(&db).expect("open");
|
||
assert!(
|
||
!reader.is_sol_body("ghost").expect("query"),
|
||
"an unknown body is not (specifically) Sol-excluded"
|
||
);
|
||
}
|
||
}
|