From 91d6b1b1cbdda5f31351ab8c77f301d773ffc046 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 16 Feb 2026 00:41:29 +0100 Subject: [PATCH] feat(simulation): add YAML content loader with hot-reload (#326) LinePool system parses dialogue.yaml and monologue.yaml into indexed BTreeMap structures. Supports 4-layer query filtering (access > situation > trust > topic+mood) per D-028. Hot-reload via timestamp polling every 20 ticks (dev-only). Graceful failure preserves previous content on reload error. 30+ tests covering enum parsing, index building, query filtering, monologue fallback, and content watching. Ref: D-028, D-032, D-035, D-041 Co-Authored-By: Claude Opus 4.6 --- server/src/content/hot_reload.rs | 228 +++++++ server/src/content/line_pool.rs | 1078 ++++++++++++++++++++++++++++++ server/src/content/loader.rs | 19 +- server/src/content/mod.rs | 57 +- server/src/content/types.rs | 10 +- server/tests/content_loading.rs | 27 +- 6 files changed, 1382 insertions(+), 37 deletions(-) create mode 100644 server/src/content/hot_reload.rs create mode 100644 server/src/content/line_pool.rs diff --git a/server/src/content/hot_reload.rs b/server/src/content/hot_reload.rs new file mode 100644 index 000000000..85ebc0d65 --- /dev/null +++ b/server/src/content/hot_reload.rs @@ -0,0 +1,228 @@ +//! Content hot-reload via timestamp polling (dev-only). +//! +//! Periodically checks content YAML files for modifications and triggers +//! a full reload when changes are detected. Designed for the authoring +//! workflow — not enabled in production builds. +//! +//! Check interval: every 20 ticks (~2s at 10 tps per D-031). +//! Failures are non-critical: previous content is preserved on reload error. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use bevy_ecs::prelude::*; + +use crate::content::line_pool::LinePoolIndex; +use crate::content::loader; +use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource}; + +/// How often to check for content changes (in system ticks). +/// At 10 tps (D-031), 20 ticks = 2 seconds. +const CHECK_INTERVAL_TICKS: u64 = 20; + +/// Resource tracking content file timestamps for change detection. +#[derive(Resource, Debug)] +pub struct ContentWatcher { + file_timestamps: BTreeMap, + ticks_since_check: u64, +} + +impl ContentWatcher { + /// Create a new watcher and perform initial timestamp scan. + pub fn new(content_root: &Path) -> Self { + let mut watcher = Self { + file_timestamps: BTreeMap::new(), + ticks_since_check: 0, + }; + watcher.scan(content_root); + watcher + } + + /// Scan content directory tree and record all YAML file timestamps. + fn scan(&mut self, content_root: &Path) { + self.file_timestamps.clear(); + walk_yaml(content_root, &mut self.file_timestamps); + tracing::debug!( + "ContentWatcher: tracking {} content files", + self.file_timestamps.len() + ); + } + + /// Check for changes and rescan. Returns true if any files changed. + fn check_and_rescan(&mut self, content_root: &Path) -> bool { + let mut new_timestamps = BTreeMap::new(); + walk_yaml(content_root, &mut new_timestamps); + let changed = new_timestamps != self.file_timestamps; + if changed { + self.file_timestamps = new_timestamps; + } + changed + } + + /// Number of tracked files (for diagnostics). + pub fn tracked_file_count(&self) -> usize { + self.file_timestamps.len() + } +} + +/// Recursively walk a directory, recording .yaml file modification timestamps. +fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + walk_yaml(&path, timestamps); + } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + if let Ok(meta) = std::fs::metadata(&path) { + if let Ok(modified) = meta.modified() { + timestamps.insert(path, modified); + } + } + } + } +} + +/// System: periodically check for content file changes and reload. +/// +/// Only runs when a ContentWatcher resource exists (hot-reload enabled). +/// Runs in PostUpdate to avoid interfering with the current tick. +pub fn hot_reload_content( + config: Res, + watcher: Option>, + store_res: Option>, + index_res: Option>, +) { + let Some(mut watcher) = watcher else { + return; + }; + let Some(mut store_res) = store_res else { + return; + }; + let Some(mut index_res) = index_res else { + return; + }; + + watcher.ticks_since_check += 1; + if watcher.ticks_since_check < CHECK_INTERVAL_TICKS { + return; + } + watcher.ticks_since_check = 0; + + if !watcher.check_and_rescan(&config.content_root) { + return; + } + + tracing::info!("Content files changed, reloading..."); + + match loader::load_content(&config.content_root) { + Ok(store) => { + let index = LinePoolIndex::build(&store); + let d_count = index.dialogue_line_count(); + let m_count = index.monologue_line_count(); + store_res.0 = store; + index_res.0 = index; + tracing::info!( + "Content hot-reloaded: {} dialogue lines, {} monologue lines", + d_count, + m_count + ); + } + Err(e) => { + tracing::warn!("Content hot-reload failed (keeping previous): {}", e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn watcher_tracks_yaml_files() { + let dir = std::env::temp_dir().join("sr_hotreload_test_track"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("test.yaml"), "key: value\n").unwrap(); + fs::write(dir.join("other.txt"), "ignored\n").unwrap(); + + let watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 1); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_new_file() { + let dir = std::env::temp_dir().join("sr_hotreload_test_new"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + assert!(!watcher.check_and_rescan(&dir)); // no change yet + + fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); + assert!(watcher.check_and_rescan(&dir)); // new file detected + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_deleted_file() { + let dir = std::env::temp_dir().join("sr_hotreload_test_del"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 2); + + fs::remove_file(dir.join("b.yaml")).unwrap(); + assert!(watcher.check_and_rescan(&dir)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_modification() { + let dir = std::env::temp_dir().join("sr_hotreload_test_mod"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + + // Sleep briefly to ensure modification time differs + std::thread::sleep(std::time::Duration::from_millis(50)); + fs::write(dir.join("a.yaml"), "key: modified\n").unwrap(); + + assert!(watcher.check_and_rescan(&dir)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_recurses_subdirectories() { + let dir = std::env::temp_dir().join("sr_hotreload_test_recurse"); + let _ = fs::remove_dir_all(&dir); + let sub = dir.join("sub/deep"); + fs::create_dir_all(&sub).unwrap(); + + fs::write(dir.join("root.yaml"), "key: root\n").unwrap(); + fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap(); + + let watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 2); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs new file mode 100644 index 000000000..eee384e9c --- /dev/null +++ b/server/src/content/line_pool.rs @@ -0,0 +1,1078 @@ +//! Indexed line pool data structures and query API (D-028 four-layer filtering). +//! +//! Provides typed, pre-indexed runtime representations of dialogue and monologue +//! content pools. Built from the raw ContentStore after YAML deserialization. +//! +//! Indexing strategy (D-041 determinism): +//! - All maps use BTreeMap for deterministic iteration order +//! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering +//! - Monologue: BTreeMap<(character, location), pool> with trigger grouping + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use crate::content::loader::ContentStore; +use crate::content::types; + +// --------------------------------------------------------------------------- +// Tag enums (D-035 converged taxonomy) +// --------------------------------------------------------------------------- + +/// D-028 Layer 1: Access tier — hard filter on who can hear this line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AccessTier { + Public, + Insider, + Authority, + Peer, + Hostile, +} + +impl FromStr for AccessTier { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "public" => Ok(Self::Public), + "insider" => Ok(Self::Insider), + "authority" => Ok(Self::Authority), + "peer" => Ok(Self::Peer), + "hostile" => Ok(Self::Hostile), + _ => Err(ParseEnumError { + kind: "AccessTier", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 3: Trust tier — hard filter on relationship depth. +/// +/// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TrustTier { + Surface, + Real, + Secret, +} + +impl TrustTier { + /// Returns true if `self` meets or exceeds the `required` tier. + pub fn meets(self, required: TrustTier) -> bool { + self >= required + } +} + +impl FromStr for TrustTier { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "surface" => Ok(Self::Surface), + "real" => Ok(Self::Real), + "secret" => Ok(Self::Secret), + _ => Err(ParseEnumError { + kind: "TrustTier", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 2: Situation context — when this line can fire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Situation { + Arrival, + ShiftStart, + ShiftEnd, + ShiftTransition, + BarEvening, + NightShift, + Investigation, + Confrontation, + Social, + Alone, + Emergency, + Routine, + Observation, +} + +impl FromStr for Situation { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "arrival" => Ok(Self::Arrival), + "shift_start" => Ok(Self::ShiftStart), + "shift_end" => Ok(Self::ShiftEnd), + "shift_transition" => Ok(Self::ShiftTransition), + "bar_evening" => Ok(Self::BarEvening), + "night_shift" => Ok(Self::NightShift), + "investigation" => Ok(Self::Investigation), + "confrontation" => Ok(Self::Confrontation), + "social" => Ok(Self::Social), + "alone" => Ok(Self::Alone), + "emergency" => Ok(Self::Emergency), + "routine" => Ok(Self::Routine), + "observation" => Ok(Self::Observation), + _ => Err(ParseEnumError { + kind: "Situation", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 4: Topic tag — influences weighted selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Topic { + Colleague, + Routine, + Cargo, + Money, + Trust, + Danger, + Institution, + Personal, + Investigation, +} + +impl FromStr for Topic { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "colleague" => Ok(Self::Colleague), + "routine" => Ok(Self::Routine), + "cargo" => Ok(Self::Cargo), + "money" => Ok(Self::Money), + "trust" => Ok(Self::Trust), + "danger" => Ok(Self::Danger), + "institution" => Ok(Self::Institution), + "personal" => Ok(Self::Personal), + "investigation" => Ok(Self::Investigation), + _ => Err(ParseEnumError { + kind: "Topic", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 4: Mood tag — influences weighted selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Mood { + Fond, + Comfortable, + Worried, + Suspicious, + Analytical, + Conflicted, + Concerned, + Relieved, +} + +impl FromStr for Mood { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "fond" => Ok(Self::Fond), + "comfortable" => Ok(Self::Comfortable), + "worried" => Ok(Self::Worried), + "suspicious" => Ok(Self::Suspicious), + "analytical" => Ok(Self::Analytical), + "conflicted" => Ok(Self::Conflicted), + "concerned" => Ok(Self::Concerned), + "relieved" => Ok(Self::Relieved), + _ => Err(ParseEnumError { + kind: "Mood", + value: s.to_string(), + }), + } + } +} + +/// Monologue trigger type — what causes this line to fire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Trigger { + EnterLocation, + ObserveNpc, + HearSound, + ObserveAnomaly, + PostConversation, + DiscoverEvidence, + WitnessInteraction, + TimeIdle, + ReturnVisit, +} + +impl FromStr for Trigger { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "enter_location" => Ok(Self::EnterLocation), + "observe_npc" => Ok(Self::ObserveNpc), + "hear_sound" => Ok(Self::HearSound), + "observe_anomaly" => Ok(Self::ObserveAnomaly), + "post_conversation" => Ok(Self::PostConversation), + "discover_evidence" => Ok(Self::DiscoverEvidence), + "witness_interaction" => Ok(Self::WitnessInteraction), + "time_idle" => Ok(Self::TimeIdle), + "return_visit" => Ok(Self::ReturnVisit), + _ => Err(ParseEnumError { + kind: "Trigger", + value: s.to_string(), + }), + } + } +} + +/// Hard character partition for monologue pools (D-032). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Character { + Smuggler, + Detective, +} + +impl FromStr for Character { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "smuggler" => Ok(Self::Smuggler), + "detective" => Ok(Self::Detective), + _ => Err(ParseEnumError { + kind: "Character", + value: s.to_string(), + }), + } + } +} + +/// Error type for enum parsing failures. +#[derive(Debug)] +pub struct ParseEnumError { + pub kind: &'static str, + pub value: String, +} + +impl fmt::Display for ParseEnumError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid {} value: {:?}", self.kind, self.value) + } +} + +impl std::error::Error for ParseEnumError {} + +// --------------------------------------------------------------------------- +// Indexed line types — typed runtime representations +// --------------------------------------------------------------------------- + +/// A dialogue line with typed enum fields, ready for filtering. +#[derive(Debug, Clone)] +pub struct IndexedDialogueLine { + pub id: String, + pub text: String, + pub role: String, + pub access: Vec, + pub trust: TrustTier, + pub situation: Vec, + pub topic: Vec, + pub mood: Vec, + pub tags: Vec, + pub knowledge_grant: Option, +} + +/// A monologue line with typed enum fields, ready for filtering. +#[derive(Debug, Clone)] +pub struct IndexedMonologueLine { + pub id: String, + pub text: String, + pub trigger: Trigger, + pub prerequisites: Option, + pub priority: u8, + pub cooldown: u32, + pub tags: Vec, +} + +// --------------------------------------------------------------------------- +// Pool index types +// --------------------------------------------------------------------------- + +/// Dialogue pool indexed for querying. +#[derive(Debug)] +pub struct IndexedDialoguePool { + pub location: String, + pub role: String, + pub lines: Vec, +} + +/// Monologue pool indexed by trigger for fast lookup. +#[derive(Debug)] +pub struct IndexedMonologuePool { + pub character: Character, + pub location: String, + /// Lines grouped by trigger type (BTreeMap for deterministic iteration). + pub by_trigger: BTreeMap>, +} + +// --------------------------------------------------------------------------- +// Top-level index +// --------------------------------------------------------------------------- + +/// Top-level line pool index — the queryable runtime data structure. +/// +/// Built from ContentStore at startup (and rebuilt on hot-reload). +/// All internal maps use BTreeMap per D-041 determinism requirement. +#[derive(Debug, Default)] +pub struct LinePoolIndex { + /// Dialogue pools indexed by (location, role). + pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>, + /// Monologue pools indexed by (character, location). + pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>, +} + +impl LinePoolIndex { + /// Build the index from a ContentStore. + /// + /// Parses string tags into typed enums. Lines with invalid required tags + /// are skipped with a warning log. + pub fn build(store: &ContentStore) -> Self { + let mut index = Self::default(); + + for district in store.districts.values() { + for pool in &district.dialogue_pools { + index.index_dialogue_pool(pool); + } + for pool in &district.monologue_pools { + index.index_monologue_pool(pool); + } + } + + index + } + + fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) { + let key = (pool.location.clone(), pool.role.clone()); + + let indexed_lines: Vec = + pool.lines.iter().filter_map(parse_dialogue_line).collect(); + + let entry = self + .dialogue + .entry(key) + .or_insert_with(|| IndexedDialoguePool { + location: pool.location.clone(), + role: pool.role.clone(), + lines: Vec::new(), + }); + entry.lines.extend(indexed_lines); + } + + fn index_monologue_pool(&mut self, pool: &types::MonologuePool) { + let character = match pool.character.parse::() { + Ok(c) => c, + Err(e) => { + tracing::warn!("Skipping monologue pool: {}", e); + return; + } + }; + + let key = (character, pool.location.clone()); + let entry = self + .monologue + .entry(key) + .or_insert_with(|| IndexedMonologuePool { + character, + location: pool.location.clone(), + by_trigger: BTreeMap::new(), + }); + + for line in &pool.lines { + if let Some(indexed) = parse_monologue_line(line) { + entry + .by_trigger + .entry(indexed.trigger) + .or_default() + .push(indexed); + } + } + } + + /// Query dialogue lines through Layers 1-3 of the D-028 pipeline. + /// + /// Returns lines that pass: + /// - Layer 1: player's access tier is in line.access + /// - Layer 2: any active situation is in line.situation + /// - Layer 3: player's trust >= line.trust + /// + /// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305). + pub fn query_dialogue( + &self, + location: &str, + role: &str, + player_access: AccessTier, + active_situations: &[Situation], + player_trust: TrustTier, + ) -> Vec<&IndexedDialogueLine> { + let key = (location.to_string(), role.to_string()); + let Some(pool) = self.dialogue.get(&key) else { + return Vec::new(); + }; + + pool.lines + .iter() + .filter(|line| { + // Layer 1: Access filter (hard) + line.access.contains(&player_access) + }) + .filter(|line| { + // Layer 2: Situation filter (context) + line.situation.iter().any(|s| active_situations.contains(s)) + }) + .filter(|line| { + // Layer 3: Trust filter (hard) + player_trust.meets(line.trust) + }) + .collect() + } + + /// Query monologue lines for a trigger event. + /// + /// Returns lines matching character + trigger from both: + /// - Location-specific pool (exact match) + /// - General pool (location = "general") + /// + /// Prerequisite evaluation and cooldown checking are the caller's + /// responsibility (they require KG state and tick tracking). + pub fn query_monologue( + &self, + character: Character, + location: &str, + trigger: Trigger, + ) -> Vec<&IndexedMonologueLine> { + let mut results = Vec::new(); + + // Location-specific pool + let key = (character, location.to_string()); + if let Some(pool) = self.monologue.get(&key) { + if let Some(lines) = pool.by_trigger.get(&trigger) { + results.extend(lines.iter()); + } + } + + // General pool fallback + if location != "general" { + let general_key = (character, "general".to_string()); + if let Some(pool) = self.monologue.get(&general_key) { + if let Some(lines) = pool.by_trigger.get(&trigger) { + results.extend(lines.iter()); + } + } + } + + results + } + + /// Returns total number of indexed dialogue lines. + pub fn dialogue_line_count(&self) -> usize { + self.dialogue.values().map(|p| p.lines.len()).sum() + } + + /// Returns total number of indexed monologue lines. + pub fn monologue_line_count(&self) -> usize { + self.monologue + .values() + .flat_map(|p| p.by_trigger.values()) + .map(|lines| lines.len()) + .sum() + } +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +/// Parse a raw DialogueLine into an indexed line with typed enums. +/// Returns None if any required enum field fails to parse. +fn parse_dialogue_line(line: &types::DialogueLine) -> Option { + let access: Vec = line + .access + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + + if access.is_empty() { + tracing::warn!("Line {}: no valid access tiers, skipping", line.id); + return None; + } + + let trust = match line.trust.parse::() { + Ok(t) => t, + Err(e) => { + tracing::warn!("Line {}: {}, skipping", line.id, e); + return None; + } + }; + + let situation: Vec = line + .situation + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + + if situation.is_empty() { + tracing::warn!("Line {}: no valid situations, skipping", line.id); + return None; + } + + let topic: Vec = line.topic.iter().filter_map(|s| s.parse().ok()).collect(); + let mood: Vec = line.mood.iter().filter_map(|s| s.parse().ok()).collect(); + + Some(IndexedDialogueLine { + id: line.id.clone(), + text: line.text.clone(), + role: line.role.clone(), + access, + trust, + situation, + topic, + mood, + tags: line.tags.clone(), + knowledge_grant: line.knowledge_grant.clone(), + }) +} + +/// Parse a raw MonologueLine into an indexed line with typed enums. +/// Returns None if the trigger fails to parse. +fn parse_monologue_line(line: &types::MonologueLine) -> Option { + let trigger = match line.trigger.parse::() { + Ok(t) => t, + Err(e) => { + tracing::warn!("Line {}: {}, skipping", line.id, e); + return None; + } + }; + + Some(IndexedMonologueLine { + id: line.id.clone(), + text: line.text.clone(), + trigger, + prerequisites: line.prerequisites.clone(), + priority: line.priority.unwrap_or(5).clamp(0, 10) as u8, + cooldown: line.cooldown.unwrap_or(0).max(0) as u32, + tags: line.tags.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::loader::{ContentStore, DistrictContent}; + + // -- Enum parsing tests -------------------------------------------------- + + #[test] + fn access_tier_parse_all_values() { + assert_eq!("public".parse::().unwrap(), AccessTier::Public); + assert_eq!( + "insider".parse::().unwrap(), + AccessTier::Insider + ); + assert_eq!( + "authority".parse::().unwrap(), + AccessTier::Authority + ); + assert_eq!("peer".parse::().unwrap(), AccessTier::Peer); + assert_eq!( + "hostile".parse::().unwrap(), + AccessTier::Hostile + ); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn trust_tier_ordering() { + assert!(TrustTier::Surface < TrustTier::Real); + assert!(TrustTier::Real < TrustTier::Secret); + assert!(TrustTier::Secret.meets(TrustTier::Secret)); + assert!(TrustTier::Secret.meets(TrustTier::Surface)); + assert!(!TrustTier::Surface.meets(TrustTier::Real)); + } + + #[test] + fn situation_parse_all_values() { + let values = [ + "arrival", + "shift_start", + "shift_end", + "shift_transition", + "bar_evening", + "night_shift", + "investigation", + "confrontation", + "social", + "alone", + "emergency", + "routine", + "observation", + ]; + for v in values { + assert!( + v.parse::().is_ok(), + "Failed to parse situation: {}", + v + ); + } + assert!("invalid".parse::().is_err()); + } + + #[test] + fn topic_parse_all_values() { + let values = [ + "colleague", + "routine", + "cargo", + "money", + "trust", + "danger", + "institution", + "personal", + "investigation", + ]; + for v in values { + assert!(v.parse::().is_ok(), "Failed to parse topic: {}", v); + } + } + + #[test] + fn mood_parse_all_values() { + let values = [ + "fond", + "comfortable", + "worried", + "suspicious", + "analytical", + "conflicted", + "concerned", + "relieved", + ]; + for v in values { + assert!(v.parse::().is_ok(), "Failed to parse mood: {}", v); + } + } + + #[test] + fn trigger_parse_all_values() { + let values = [ + "enter_location", + "observe_npc", + "hear_sound", + "observe_anomaly", + "post_conversation", + "discover_evidence", + "witness_interaction", + "time_idle", + "return_visit", + ]; + for v in values { + assert!( + v.parse::().is_ok(), + "Failed to parse trigger: {}", + v + ); + } + } + + #[test] + fn character_parse() { + assert_eq!( + "smuggler".parse::().unwrap(), + Character::Smuggler + ); + assert_eq!( + "detective".parse::().unwrap(), + Character::Detective + ); + assert!("other".parse::().is_err()); + } + + // -- Helper: build a test ContentStore ----------------------------------- + + fn make_dialogue_line( + id: &str, + access: &[&str], + trust: &str, + situations: &[&str], + ) -> types::DialogueLine { + types::DialogueLine { + id: id.to_string(), + text: format!("Text for {}", id), + role: "worker".to_string(), + access: access.iter().map(|s| s.to_string()).collect(), + trust: trust.to_string(), + situation: situations.iter().map(|s| s.to_string()).collect(), + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + } + } + + fn make_monologue_line(id: &str, trigger: &str, priority: i32) -> types::MonologueLine { + types::MonologueLine { + id: id.to_string(), + text: format!("Monologue {}", id), + trigger: trigger.to_string(), + prerequisites: None, + priority: Some(priority), + cooldown: None, + tags: vec![], + } + } + + fn test_store() -> ContentStore { + let mut store = ContentStore::default(); + + let dialogue_pools = vec![types::DialoguePool { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + lines: vec![ + make_dialogue_line( + "the-terminal_d_001", + &["public"], + "surface", + &["arrival", "social"], + ), + make_dialogue_line( + "the-terminal_d_002", + &["insider", "peer"], + "real", + &["bar_evening"], + ), + make_dialogue_line( + "the-terminal_d_003", + &["insider"], + "secret", + &["investigation"], + ), + ], + }]; + + let monologue_pools = vec![ + types::MonologuePool { + character: "smuggler".to_string(), + location: "the-terminal".to_string(), + lines: vec![ + make_monologue_line("the-terminal_m_s_001", "enter_location", 5), + make_monologue_line("the-terminal_m_s_002", "observe_npc", 7), + ], + }, + types::MonologuePool { + character: "smuggler".to_string(), + location: "general".to_string(), + lines: vec![make_monologue_line("general_m_s_001", "enter_location", 3)], + }, + ]; + + let district = DistrictContent { + dialogue_pools, + monologue_pools, + ..Default::default() + }; + store + .districts + .insert("test.district".to_string(), district); + + store + } + + // -- Index building tests ------------------------------------------------ + + #[test] + fn build_indexes_dialogue_pools() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + assert_eq!(index.dialogue.len(), 1); + let pool = index + .dialogue + .get(&("the-terminal".to_string(), "dock-worker".to_string())) + .unwrap(); + assert_eq!(pool.lines.len(), 3); + } + + #[test] + fn build_indexes_monologue_pools() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + assert_eq!(index.monologue.len(), 2); + let loc_pool = index + .monologue + .get(&(Character::Smuggler, "the-terminal".to_string())) + .unwrap(); + assert_eq!(loc_pool.by_trigger.len(), 2); + + let general_pool = index + .monologue + .get(&(Character::Smuggler, "general".to_string())) + .unwrap(); + assert_eq!(general_pool.by_trigger.len(), 1); + } + + #[test] + fn line_counts() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + assert_eq!(index.dialogue_line_count(), 3); + assert_eq!(index.monologue_line_count(), 3); + } + + // -- Dialogue query tests (D-028 Layers 1-3) ---------------------------- + + #[test] + fn query_dialogue_layer1_access_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Public access should only get the public line + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Public, + &[Situation::Arrival], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_001"); + } + + #[test] + fn query_dialogue_layer2_situation_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider + secret trust but only bar_evening situation + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::BarEvening], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_002"); + } + + #[test] + fn query_dialogue_layer3_trust_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider + investigation but only surface trust — should miss line 003 (secret) + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Surface, + ); + assert_eq!(results.len(), 0); + + // With real trust — still no (line 003 requires secret) + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Real, + ); + assert_eq!(results.len(), 0); + + // With secret trust — line 003 passes + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_003"); + } + + #[test] + fn query_dialogue_multiple_situations() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider with multiple situations should get lines from both + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Arrival, Situation::BarEvening], + TrustTier::Real, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_002"); + } + + #[test] + fn query_dialogue_nonexistent_pool() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = index.query_dialogue( + "nonexistent", + "worker", + AccessTier::Public, + &[Situation::Arrival], + TrustTier::Surface, + ); + assert!(results.is_empty()); + } + + // -- Monologue query tests ----------------------------------------------- + + #[test] + fn query_monologue_location_specific() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = + index.query_monologue(Character::Smuggler, "the-terminal", Trigger::ObserveNpc); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_m_s_002"); + } + + #[test] + fn query_monologue_includes_general_pool() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // enter_location: 1 from the-terminal + 1 from general + let results = + index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); + assert_eq!(results.len(), 2); + } + + #[test] + fn query_monologue_general_only() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Unknown location — only general pool matches + let results = index.query_monologue( + Character::Smuggler, + "unknown-location", + Trigger::EnterLocation, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "general_m_s_001"); + } + + #[test] + fn query_monologue_wrong_character() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Detective character — no pools exist + let results = + index.query_monologue(Character::Detective, "the-terminal", Trigger::EnterLocation); + assert!(results.is_empty()); + } + + #[test] + fn query_monologue_no_trigger_match() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = index.query_monologue( + Character::Smuggler, + "the-terminal", + Trigger::DiscoverEvidence, + ); + assert!(results.is_empty()); + } + + // -- Parse edge cases ---------------------------------------------------- + + #[test] + fn dialogue_line_with_invalid_access_is_skipped() { + let line = types::DialogueLine { + id: "test_d_001".to_string(), + text: "test".to_string(), + role: "worker".to_string(), + access: vec!["invalid".to_string()], + trust: "surface".to_string(), + situation: vec!["arrival".to_string()], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }; + assert!(parse_dialogue_line(&line).is_none()); + } + + #[test] + fn dialogue_line_with_invalid_trust_is_skipped() { + let line = types::DialogueLine { + id: "test_d_001".to_string(), + text: "test".to_string(), + role: "worker".to_string(), + access: vec!["public".to_string()], + trust: "invalid".to_string(), + situation: vec!["arrival".to_string()], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }; + assert!(parse_dialogue_line(&line).is_none()); + } + + #[test] + fn monologue_line_defaults() { + let line = types::MonologueLine { + id: "test_m_s_001".to_string(), + text: "test".to_string(), + trigger: "enter_location".to_string(), + prerequisites: None, + priority: None, + cooldown: None, + tags: vec![], + }; + let indexed = parse_monologue_line(&line).unwrap(); + assert_eq!(indexed.priority, 5); // default + assert_eq!(indexed.cooldown, 0); // default + } + + #[test] + fn monologue_priority_clamped() { + let line = types::MonologueLine { + id: "test_m_s_001".to_string(), + text: "test".to_string(), + trigger: "enter_location".to_string(), + prerequisites: None, + priority: Some(15), // over max + cooldown: None, + tags: vec![], + }; + let indexed = parse_monologue_line(&line).unwrap(); + assert_eq!(indexed.priority, 10); // clamped + } +} diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index 868d0e9cc..b381dca48 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -291,15 +291,13 @@ fn load_yaml_dir(dir: &Path) -> Vec { /// Load all YAML files recursively under a directory, skipping stubs. fn load_yaml_recursive(dir: &Path) -> Vec { let mut results = Vec::new(); - walk_yaml_files(dir, &mut |path| { - match load_yaml::(path) { - Ok(item) => results.push(item), - Err(e) => { - if is_comment_only_file(path) { - tracing::debug!("Skipping stub: {:?}", path); - } else { - tracing::warn!("Failed to parse {:?}: {}", path, e); - } + walk_yaml_files(dir, &mut |path| match load_yaml::(path) { + Ok(item) => results.push(item), + Err(e) => { + if is_comment_only_file(path) { + tracing::debug!("Skipping stub: {:?}", path); + } else { + tracing::warn!("Failed to parse {:?}: {}", path, e); } } }); @@ -525,7 +523,8 @@ motivation: "HANDLER" #[test] fn derive_district_id_from_path() { let root = Path::new("/content"); - let district = Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit"); + let district = + Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit"); let id = derive_district_id(root, district); assert_eq!(id, "krenn.sova.transit"); } diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index e5421fad2..179fe4823 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -1,16 +1,17 @@ -//! Content loading and entity spawning system. -//! -//! Phase 2 content loader (ticket #408): loads YAML content files from disk, -//! deserializes into intermediate types, and spawns ECS entities. +//! Content loading, indexing, and entity spawning system. //! //! Architecture (per Tyre's D-020 guidance): -//! 1. Deserialize YAML → intermediate content types (types.rs) -//! 2. Content discovery + loading (loader.rs) → ContentStore resource -//! 3. ContentStore → ECS entity spawning (spawn.rs) +//! 1. Deserialize YAML -> intermediate content types (types.rs) +//! 2. Content discovery + loading (loader.rs) -> ContentStore resource +//! 3. ContentStore -> ECS entity spawning (spawn.rs) +//! 4. ContentStore -> indexed line pools (line_pool.rs) -> LinePoolIndex resource +//! 5. Optional hot-reload (hot_reload.rs) for dev/authoring workflow //! //! Content schema is decoupled from ECS components. The spawn module //! handles the mapping between the two representations. +pub mod hot_reload; +pub mod line_pool; pub mod loader; pub mod spawn; pub mod types; @@ -25,20 +26,24 @@ use std::path::PathBuf; pub struct ContentConfig { /// Root directory containing content.yaml and campaign directories. pub content_root: PathBuf, + /// Enable hot-reload (timestamp polling). Dev-only, not for production. + pub hot_reload: bool, } impl Default for ContentConfig { fn default() -> Self { Self { content_root: PathBuf::from("content"), + hot_reload: false, } } } /// Content loading plugin. /// -/// Loads content from YAML files at startup and spawns ECS entities. -/// Requires ContentConfig resource to be inserted before the plugin runs. +/// Loads content from YAML files at startup, spawns ECS entities, +/// and builds the indexed line pools for dialogue/monologue queries. +/// Optionally enables hot-reload for the authoring workflow. pub struct ContentPlugin; impl Plugin for ContentPlugin { @@ -48,12 +53,13 @@ impl Plugin for ContentPlugin { } app.add_systems(Startup, load_and_spawn_content); + app.add_systems(PostUpdate, hot_reload::hot_reload_content); tracing::debug!("ContentPlugin initialized"); } } -/// Startup system: load content from disk and spawn entities. +/// Startup system: load content from disk, spawn entities, and build line pool index. fn load_and_spawn_content(world: &mut World) { let config = world.resource::().clone(); @@ -62,20 +68,35 @@ fn load_and_spawn_content(world: &mut World) { match loader::load_content(&config.content_root) { Ok(store) => { let result = spawn::spawn_content(world, &store); + tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned); + + // Build line pool index + let index = line_pool::LinePoolIndex::build(&store); tracing::info!( - "Content loaded and spawned: {} NPCs", - result.npcs_spawned + "Line pool index built: {} dialogue lines, {} monologue lines", + index.dialogue_line_count(), + index.monologue_line_count() ); - // Insert the content store as a resource for runtime access - // (triangle queries, pool lookups, dialogue selection) + world.insert_resource(ContentStoreResource(store)); + world.insert_resource(LinePoolIndexResource(index)); } Err(e) => { tracing::error!("Failed to load content: {}", e); - // Insert empty store so downstream systems don't panic on missing resource world.insert_resource(ContentStoreResource(loader::ContentStore::default())); + world.insert_resource(LinePoolIndexResource(line_pool::LinePoolIndex::default())); } } + + // Set up hot-reload if enabled + if config.hot_reload { + let watcher = hot_reload::ContentWatcher::new(&config.content_root); + tracing::info!( + "Content hot-reload enabled — tracking {} files, polling every ~2s", + watcher.tracked_file_count() + ); + world.insert_resource(watcher); + } } /// Wrapper resource holding the loaded content store. @@ -83,3 +104,9 @@ fn load_and_spawn_content(world: &mut World) { /// (e.g., dialogue selection, triangle fork evaluation). #[derive(Resource, Debug)] pub struct ContentStoreResource(pub loader::ContentStore); + +/// Wrapper resource holding the indexed line pools. +/// Available for runtime systems that need to query dialogue/monologue lines +/// through the D-028 four-layer filtering pipeline. +#[derive(Resource, Debug)] +pub struct LinePoolIndexResource(pub line_pool::LinePoolIndex); diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 196898d9b..0d67cc99e 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -491,7 +491,7 @@ pub struct DialogueLine { pub knowledge_grant: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct KnowledgeGrant { pub fact_id: String, pub confidence: String, @@ -523,7 +523,7 @@ pub struct MonologueLine { pub tags: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct Prerequisites { #[serde(default)] pub facts: Vec, @@ -533,20 +533,20 @@ pub struct Prerequisites { pub relationship: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct FactPrerequisite { pub fact_id: String, pub min_confidence: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct AttributePrerequisite { pub entity: String, pub key: String, pub value: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RelationshipPrerequisite { #[serde(default)] pub target: Option, diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index 292f4d418..dfc2dcbf1 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -45,7 +45,10 @@ fn discover_real_content_structure() { assert!(!manifest.campaigns.is_empty()); // At least one district should be discovered - assert!(!store.districts.is_empty(), "should discover at least one district"); + assert!( + !store.districts.is_empty(), + "should discover at least one district" + ); } #[test] @@ -298,6 +301,7 @@ fn content_plugin_loads_via_app() { app.add_plugins(settled_reach_server::npc::NpcPlugin); app.insert_resource(ContentConfig { content_root: root, + ..Default::default() }); app.add_plugins(ContentPlugin); @@ -353,13 +357,18 @@ fn spawn_real_content_with_relationships_and_secrets() { ); npcs_with_want += 1; } - assert_eq!(npcs_with_want, 20, "All 20 NPCs should have Want components"); + assert_eq!( + npcs_with_want, 20, + "All 20 NPCs should have Want components" + ); // Spot-check specific Want values let kael_entity = registry .to_entity(&result.npc_ids["npc:kael-davan"]) .unwrap(); - let kael_want = world.get::(kael_entity).expect("Kael should have Want"); + let kael_want = world + .get::(kael_entity) + .expect("Kael should have Want"); assert_eq!(kael_want.primary, npc::WantKind::Safety); // Verify Kael has a Secret component @@ -383,9 +392,11 @@ fn spawn_real_content_with_relationships_and_secrets() { let kael_kg = world .get::(kael_entity) .expect("Kael should have KnowledgeGraph"); - assert!(kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId( - "contraband.ring_exists".to_string() - ))); + assert!( + kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId( + "contraband.ring_exists".to_string() + )) + ); // Verify global RelationshipGraph was populated let graph = world.resource::(); @@ -400,6 +411,8 @@ fn spawn_real_content_with_relationships_and_secrets() { .resource::() .to_entity(&result.npc_ids["npc:nils-davan"]) .unwrap(); - let nils_want = world.get::(nils_entity).expect("Nils should have Want"); + let nils_want = world + .get::(nils_entity) + .expect("Nils should have Want"); assert_eq!(nils_want.primary, npc::WantKind::Power); }