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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user