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>
262 lines
9.0 KiB
Rust
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);
|
|
}
|
|
}
|