Files
settled-reach/server/src/atlas/city_context_reader.rs
T
jpmschweitzerandClaude Fable 5 8da9670e0f feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)
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>
2026-07-25 16:10:27 +02:00

1547 lines
64 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 (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"
);
}
}