Files
settled-reach/server/src/simulation/name_index.rs
T
jpmschweitzerandClaude Opus 4.6 332b9404be fix(simulation): address PR #142 review — 11 issues resolved
Rust fixes: D-218 Backwater complexity (moved to Full arm), priority
queue dispatch gated on thread saturation, panic→soft-fail in
name_index, duplicated LCG unified into atlas::rng module, misleading
Safety comment removed, D-210 SubBiomeVariant deferred to #948.

Schema/data fixes: atlas_city_positions table (D-211 persistence),
settlement_class column on atlas_city_names, per-body transaction
boundaries in import_province_boundaries, import_city_names.py added
to GENERATOR_SOURCES, Python perf documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:50 +02:00

262 lines
9.0 KiB
Rust

//! 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 = match AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostFirst)
.build(&patterns)
{
Ok(a) => a,
Err(e) => {
tracing::error!(error = %e, "SystemNameIndex: automaton build failed — index unavailable");
return None;
}
};
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);
}
}