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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 00:41:29 +01:00
co-authored by Claude Opus 4.6
parent ea0a3f0b35
commit 91d6b1b1cb
6 changed files with 1382 additions and 37 deletions
+228
View File
@@ -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<PathBuf, SystemTime>,
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<PathBuf, SystemTime>) {
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<ContentConfig>,
watcher: Option<ResMut<ContentWatcher>>,
store_res: Option<ResMut<ContentStoreResource>>,
index_res: Option<ResMut<LinePoolIndexResource>>,
) {
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);
}
}
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -291,15 +291,13 @@ fn load_yaml_dir<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
/// Load all YAML files recursively under a directory, skipping stubs.
fn load_yaml_recursive<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
let mut results = Vec::new();
walk_yaml_files(dir, &mut |path| {
match load_yaml::<T>(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::<T>(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");
}
+42 -15
View File
@@ -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::<ContentConfig>().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);
+5 -5
View File
@@ -491,7 +491,7 @@ pub struct DialogueLine {
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
@@ -533,20 +533,20 @@ pub struct Prerequisites {
pub relationship: Option<RelationshipPrerequisite>,
}
#[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<String>,
+20 -7
View File
@@ -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::<npc::Want>(kael_entity).expect("Kael should have Want");
let kael_want = world
.get::<npc::Want>(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::<settled_reach_server::knowledge::graph::KnowledgeGraph>(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::<settled_reach_server::npc::relationships::RelationshipGraph>();
@@ -400,6 +411,8 @@ fn spawn_real_content_with_relationships_and_secrets() {
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc:nils-davan"])
.unwrap();
let nils_want = world.get::<npc::Want>(nils_entity).expect("Nils should have Want");
let nils_want = world
.get::<npc::Want>(nils_entity)
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}