From ca9ab189e05c1864eaded7921d79edbba5ac4f3f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 2 May 2026 18:40:44 +0200 Subject: [PATCH] feat(simulation): generation pipeline Rust types + SystemNameIndex (#900, #912-#926) WorldTier enum fixed to Epicenter/Regional/Backwater/Passage/Waypoint (D-218). Full enum implementations for ComplexityTier, SettingType, SettlementClass, DistrictType, PoliticalArchetype, FoundingOrientation, TerritorialStatus, GeographicAttractor, AttractorType, and CompatibilityMatrix. SystemNameIndex with Aho-Corasick text scanning for background pre-generation queue integration (D-206). Co-Authored-By: Claude Opus 4.6 (1M context) --- server/Cargo.lock | 2 + server/Cargo.toml | 2 + server/src/lib.rs | 1 + server/src/simulation/generator.rs | 159 ++++++++++++++++- server/src/simulation/name_index.rs | 256 ++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 server/src/simulation/name_index.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 7caec66b0..4acd3fdf5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1296,9 +1296,11 @@ dependencies = [ name = "settled-reach-server" version = "0.1.37" dependencies = [ + "aho-corasick", "bevy_app", "bevy_ecs", "bevy_tasks", + "bytemuck", "clap", "crossbeam-channel", "econ-sim", diff --git a/server/Cargo.toml b/server/Cargo.toml index f6bb67319..1c6639fbe 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,7 +28,9 @@ crossbeam-channel = "0.5" sysinfo = "0.35" serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } +bytemuck = "1" toml = "0.8" +aho-corasick = "1" # Economics simulation — Leontief + tâtonnement + D-180 event port (#821) econ-sim = { path = "../tooling/econ-sim" } diff --git a/server/src/lib.rs b/server/src/lib.rs index e2706c9cd..23e7bb3e1 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,7 @@ // The Settled Reach - Simulation Server // Rust/bevy_ecs simulation server for D-010 client-server architecture +pub mod atlas; pub mod bookmark; pub mod bridge; pub mod cause_chain; diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 427c19038..819f0a54b 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -100,15 +100,19 @@ pub type PlacedObject = String; /// Network importance of a world in the galaxy. /// Determines simulation fidelity budget and NPC complexity ceiling. /// -/// Source: tyre-round4.md §2.1, workshop-outcomes.md +/// Source: D-218, workshop-outcomes.md #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum WorldTier { - /// Background system — minimal simulation, sparse NPCs. Pure environmental. - Peripheral, - /// Standard Settled Reach system — full simulation, complex social sites. - Connected, - /// Major hub — maximum fidelity, multi-faction politics, all triangle types. - Core, + /// Hub system. Full simulation, high faction pressure. + Epicenter, + /// Regional system. 1–4 districts, partial full-budget simulation. + Regional, + /// Small community. 1 district. Network-insignificant, NOT budget-capped. + Backwater, + /// Transit stop. Pass-through node. Moderate complexity ceiling. + Passage, + /// Not simulated until player approaches. Minimal complexity ceiling. + Waypoint, } /// Generator content budget for a district. @@ -288,6 +292,147 @@ pub enum EraCause { CulturalShift, } +// --------------------------------------------------------------------------- +// Settlement classification enums (D-196, D-212, D-213, D-214, D-215) +// --------------------------------------------------------------------------- + +/// How a settlement enters and exits active simulation. +/// Controls whether generation runs, and at what complexity level. +/// Source: D-196 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum SettlementClass { + /// Named in wiki; always active regardless of population threshold. + NameLocked, + /// Active if pop ≥ 50_000; ghost stub if pop < 5_000. + PopulationBudget, + /// Active only while the triggering economic condition holds. + EconomicTriggered, + /// Emergent settlement not in atlas at generation time; written during simulation. + OrganicGrowth, +} + +/// Dominant power structure of a settlement and its physical spatial expression. +/// Derived from TerritorialStatus + economic_role at generation time. +/// Source: D-214 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum PoliticalArchetype { + /// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement. + Commission, + /// Corp-dominated; commercial density, restricted campus blocks, restricted-perimeter adjacent. + Corporate, + /// Self-organized; organic growth, mixed use, ribbon arrangement. + Pioneer, + /// Garrison or fortification origin; defensible geometry, fortified-perimeter arrangement. + Military, + /// University or research origin; campus-quad structure, green space, radial-core arrangement. + Academic, + /// Factory-first; large-footprint industrial blocks, worker residential rings, ribbon arrangement. + Industrial, +} + +/// Primary spatial axis of a city's original street grid. +/// Derived from the matched attractor type (D-211). Controls district grid rotation. +/// Source: D-213 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum FoundingOrientation { + /// Street grid perpendicular to coastline. `facing_degrees`: compass bearing toward water (0–359). + Coastal { facing_degrees: u16 }, + /// Street grid parallel to founding river. `bearing_degrees`: river flow direction (0–359). + RiverAligned { bearing_degrees: u16 }, + /// Grid rotated to follow local contours (valley floor settlements). + TerrainFollowing, + /// Grid aligned to cardinal N/S/E/W (Commission-planned settlements on flat terrain). + Cardinal, + /// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359. + Free { bearing_degrees: u16 }, +} + +/// Territory control status for a province (drainage basin). Priority-ordered derivation. +/// Source: D-212 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TerritorialStatus { + /// Commission faction_influence ≥ 0.6 in this province. + CommissionControlled, + /// Single corporation faction_influence ≥ 0.5. + CorpTerritory, + /// Two or more factions each ≥ 0.3; no dominant faction. + ContestedZone, + /// No faction with influence ≥ 0.2. + FrontierUnclaimed, + /// Cultural corridor has indigenous autonomy flag. + IndigenousHeld, + /// Population density < 0.01 AND no faction ≥ 0.1. + Derelict, +} + +// --------------------------------------------------------------------------- +// Attractor types for settlement placement (D-195, D-209, D-211) +// --------------------------------------------------------------------------- + +/// The type of terrain feature that attracts settlement placement. +/// Source: D-195, D-209 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum AttractorType { + /// Where a river meets sea level or coastline. Historically high-value. + RiverMouth, + /// Proximity to coast without a river mouth. Port access. + CoastalAccess, + /// Where a river crosses a topographic saddle or confluence point. + RiverCrossing, + /// Local elevation minimum; flat, arable, sheltered. + ValleyFloor, + /// Saddle point between adjacent drainage basins; controls a mountain pass. + PassEntrance, + /// Adjacent to a lake polygon. + LakeShore, + /// Flat terrain away from all other attractors; fallback for plains settlements. + PlainCenter, +} + +/// A terrain feature at a specific map position that influences city placement scoring. +/// Source: D-195, D-209 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GeographicAttractor { + /// Pixel position in heightmap space [row, col]. + pub position: (u16, u16), + pub attractor_type: AttractorType, + /// Normalized strength 0.0–1.0. Derived from flow accumulation or habitability score. + pub strength: f32, +} + +/// Compatibility weights between economic roles and attractor types. +/// A 10×7 matrix (10 economic_role values × 7 AttractorType variants). +/// Each cell is a weight multiplier 0.0–3.0 applied during attractor-matching scoring. +/// Source: D-195 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CompatibilityMatrix { + /// Row order: manufacturing, financial, agricultural, extraction, service_mixed, + /// institutional, transit_hub, research, military, residential. + /// Column order: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor, + /// PassEntrance, LakeShore, PlainCenter. + pub weights: [[f32; 7]; 10], +} + +/// Data contract between build-time (systems.db) and the runtime-background +/// generation tier. Populated from atlas_city_names + bodies at generation +/// dispatch time. All 8 fields are required before a generation task may run. +/// Source: D-200, D-199 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CityGenerationContext { + /// Foreign key into atlas_city_names.id + pub city_id: u64, + pub political_archetype: PoliticalArchetype, + /// Starting economic health seed (0.0–1.0). Derived per D-197. + pub prosperity_baseline: f32, + pub surrounding_biome: SettingType, + /// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint. + pub road_entry_directions: Vec, + /// City footprint radius in km. Derived from body_radius_km (D-204) + population. + pub footprint_radius_km: f32, + pub founding_orientation: FoundingOrientation, + pub world_tier: WorldTier, +} + // --------------------------------------------------------------------------- // Supporting structs // --------------------------------------------------------------------------- diff --git a/server/src/simulation/name_index.rs b/server/src/simulation/name_index.rs new file mode 100644 index 000000000..b0e9c2dda --- /dev/null +++ b/server/src/simulation/name_index.rs @@ -0,0 +1,256 @@ +//! SystemNameIndex — Aho-Corasick automaton for event-driven pre-generation (D-206). +//! +//! Loaded once at startup from `systems.db`. Scans NPC dialogue and news ticker +//! text; any match names a body_id to enqueue for background generation at Low +//! priority (D-206 §event-driven pre-generation). +//! +//! The automaton is case-insensitive and matches overlapping patterns so that +//! "New Chengdu" and "Chengdu" both fire independently when present. + +use std::path::Path; + +use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind}; +use bevy_ecs::prelude::*; +use rusqlite::{Connection, OpenFlags}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A single match returned by [`SystemNameIndex::scan`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameMatch { + /// The `body_id` (or `system_id`) that was matched. + pub id: String, + /// The matched text span (byte offsets into the input string). + pub start: usize, + pub end: usize, +} + +// --------------------------------------------------------------------------- +// Resource +// --------------------------------------------------------------------------- + +/// Aho-Corasick automaton over all body/system/station proper names in systems.db. +/// +/// Built once from the DB at startup; immutable thereafter. +/// All queries are `O(n)` in the length of the scanned text regardless of +/// how many names the automaton holds. +/// +/// Returned IDs are body_ids for bodies/stations, or system_ids for star systems +/// that have no body entries. The caller (background generation queue, D-206) +/// decides which IDs are actionable. +#[derive(Resource)] +pub struct SystemNameIndex { + automaton: AhoCorasick, + /// Maps automaton pattern index → the body_id / system_id it represents. + ids: Vec, +} + +impl SystemNameIndex { + /// Build the index from `systems.db` at `path`. + /// + /// Loads proper names from `bodies`, `stations`, and `star_systems`. + /// Returns `None` on DB open failure (logged at warn level; the game + /// runs without the index, just without event-driven pre-generation). + pub fn load(path: &Path) -> Option { + let conn = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(c) => c, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "SystemNameIndex: failed to open systems.db"); + return None; + } + }; + + let entries = match collect_names(&conn) { + Ok(e) => e, + Err(e) => { + tracing::warn!(error = %e, "SystemNameIndex: failed to collect names"); + return None; + } + }; + + if entries.is_empty() { + tracing::warn!("SystemNameIndex: no names found in systems.db — index empty"); + } + + let (patterns, ids): (Vec, Vec) = entries.into_iter().unzip(); + + let automaton = AhoCorasickBuilder::new() + .ascii_case_insensitive(true) + .match_kind(MatchKind::LeftmostFirst) + .build(&patterns) + .unwrap_or_else(|e| panic!("SystemNameIndex: automaton build failed: {e}")); + + tracing::info!(pattern_count = ids.len(), "SystemNameIndex built"); + Some(Self { automaton, ids }) + } + + /// Scan `text` and return all name matches. + /// + /// Each match carries the body_id / system_id and the byte span. + /// Overlapping matches are not reported (leftmost-first wins per AhoCorasick + /// `MatchKind::LeftmostFirst`). + pub fn scan(&self, text: &str) -> Vec { + self.automaton + .find_iter(text) + .map(|m| NameMatch { + id: self.ids[m.pattern().as_usize()].clone(), + start: m.start(), + end: m.end(), + }) + .collect() + } + + /// How many patterns the automaton holds (for diagnostics). + pub fn pattern_count(&self) -> usize { + self.ids.len() + } +} + +// --------------------------------------------------------------------------- +// DB helpers +// --------------------------------------------------------------------------- + +fn collect_names(conn: &Connection) -> rusqlite::Result> { + let mut entries: Vec<(String, String)> = Vec::new(); + + // Bodies — use proper_name only (body_id like "GJ-15Ab" is not natural language) + { + let mut stmt = conn.prepare( + "SELECT body_id, proper_name FROM bodies WHERE proper_name IS NOT NULL AND proper_name != ''", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + })?; + for row in rows { + entries.push(row?); + } + } + + // Stations + { + let mut stmt = conn.prepare( + "SELECT station_id, proper_name FROM stations WHERE proper_name IS NOT NULL AND proper_name != ''", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + })?; + for row in rows { + entries.push(row?); + } + } + + // Star systems — include both system_name and proper_name as separate patterns + // so "Van Maanen's Star" and "GJ 35" both trigger if used in dialogue. + { + let mut stmt = conn.prepare( + "SELECT system_id, system_name, proper_name FROM star_systems", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for row in rows { + let (system_id, system_name, proper_name) = row?; + if let Some(name) = system_name { + if !name.is_empty() { + entries.push((name, system_id.clone())); + } + } + if let Some(name) = proper_name { + if !name.is_empty() { + entries.push((name, system_id.clone())); + } + } + } + } + + Ok(entries) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind}; + + /// Build a minimal index directly (no DB) for unit testing. + fn make_index(pairs: &[(&str, &str)]) -> SystemNameIndex { + let (patterns, ids): (Vec<&str>, Vec) = + pairs.iter().map(|(p, id)| (*p, id.to_string())).unzip(); + let automaton = AhoCorasickBuilder::new() + .ascii_case_insensitive(true) + .match_kind(MatchKind::LeftmostFirst) + .build(&patterns) + .unwrap(); + SystemNameIndex { automaton, ids } + } + + #[test] + fn scan_finds_exact_match() { + let idx = make_index(&[("Xin Chengdu", "GJ-380c")]); + let matches = idx.scan("The freighter docked at Xin Chengdu yesterday."); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, "GJ-380c"); + } + + #[test] + fn scan_is_case_insensitive() { + let idx = make_index(&[("Horizon Station", "GJ-380-oort-S1")]); + let matches = idx.scan("HORIZON STATION cargo rates up 12%."); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, "GJ-380-oort-S1"); + } + + #[test] + fn scan_returns_empty_on_no_match() { + let idx = make_index(&[("Xin Chengdu", "GJ-380c")]); + let matches = idx.scan("Nothing here matches."); + assert!(matches.is_empty()); + } + + #[test] + fn scan_returns_multiple_distinct_matches() { + let idx = make_index(&[ + ("Xin Chengdu", "GJ-380c"), + ("Horizon Station", "GJ-380-oort-S1"), + ]); + let text = "Xin Chengdu imports from Horizon Station."; + let matches = idx.scan(text); + assert_eq!(matches.len(), 2); + let ids: Vec<&str> = matches.iter().map(|m| m.id.as_str()).collect(); + assert!(ids.contains(&"GJ-380c")); + assert!(ids.contains(&"GJ-380-oort-S1")); + } + + #[test] + fn scan_span_is_correct() { + let idx = make_index(&[("Chengdu", "GJ-380c")]); + let text = "0123456Chengdu rest"; + let matches = idx.scan(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, 7); + assert_eq!(matches[0].end, 14); + assert_eq!(&text[matches[0].start..matches[0].end], "Chengdu"); + } + + #[test] + fn empty_index_scans_without_panic() { + let idx = make_index(&[]); + let matches = idx.scan("Any text at all."); + assert!(matches.is_empty()); + } + + #[test] + fn pattern_count_matches_entries() { + let idx = make_index(&[("Alpha", "sys-1"), ("Beta", "sys-2"), ("Gamma", "sys-3")]); + assert_eq!(idx.pattern_count(), 3); + } +}