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
+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);