//! 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, /// 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, } // --------------------------------------------------------------------------- // Reader // --------------------------------------------------------------------------- /// Reads the D-199 economic read-set for a city from `systems.db` at /// generation-dispatch time. Holds a read-only SQLite connection. /// /// Analogous to [`crate::atlas::source_resolver::BodySourceResolver`] — both /// read from `systems.db` read-only and are used at dispatch time to pre-resolve /// the inputs a Rayon work item needs before it is submitted. pub struct CityContextReader { conn: Arc>, } impl CityContextReader { /// Open a read-only connection to `systems_db`. pub fn open(systems_db: &Path) -> Result { let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| CityContextReadError::Db(e.to_string()))?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) } /// Read the D-199 6-field economic read-set for `city_id`. /// /// Joins `atlas_city_names` → `bodies` → `system_factions` to gather all /// required fields. Returns `CityContextReadError::MissingField` if any /// required field is NULL. `dominant_faction` is allowed to be NULL (the /// system may have no recorded faction data); it is carried as `Option`. pub fn read_set( &self, city_id: u64, world_seed: u64, ) -> Result { let conn = self .conn .lock() .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; // ─── Query ──────────────────────────────────────────────────────────── // atlas_city_names carries economic_role, population, settlement_class. // bodies carries founding_age_years (via the city's body_id). // system_factions carries dominant_faction (via bodies.system_id). // 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, // acn.economic_role i64, // acn.population Option, // acn.settlement_class Option, // b.founding_age_years Option, // sf.dominant_faction Option, // 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 { 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, 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>(2)?, row.get::<_, i64>(3)?, row.get::<_, Option>(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, 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>(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 { 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>(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, 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, 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 { 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` 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, 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" ); } }