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) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 18:40:44 +02:00
co-authored by Claude Opus 4.6
parent b6ca785f74
commit ca9ab189e0
5 changed files with 413 additions and 7 deletions
+2
View File
@@ -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",
+2
View File
@@ -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" }
+1
View File
@@ -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;
+152 -7
View File
@@ -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. 14 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 (0359).
Coastal { facing_degrees: u16 },
/// Street grid parallel to founding river. `bearing_degrees`: river flow direction (0359).
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`: 0359.
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.01.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.03.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.01.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<u8>,
/// 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
// ---------------------------------------------------------------------------
+256
View File
@@ -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<String>,
}
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<Self> {
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<String>, Vec<String>) = 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<NameMatch> {
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<Vec<(String, String)>> {
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<String>>(1)?,
row.get::<_, Option<String>>(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<String>) =
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);
}
}