//! 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, } 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 { 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, Vec) = 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 { 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> { 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>(1)?, row.get::<_, Option>(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) = 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); } }