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:
@@ -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