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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user