feat(simulation): implement content loader Phase 2 (#408)

Extend content loader to read and instantiate real YAML content into
ECS entities. 2-phase spawn pipeline: Phase 1 creates entities with
core components (Want, Tolerance, Contentment, Personality, Tells,
Skills), Phase 2 resolves cross-references (Relationships, Secrets,
Information via KnowledgeGraph, DailyRoutine).

Loads enums, entity attributes, pools, templates, triangles, and NPC
profiles. Handles stub files gracefully. 7 integration tests against
real content files plus edge case tests for invalid data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 01:19:29 +01:00
co-authored by Claude Opus 4.6
parent 4c008d6191
commit 267d415173
9 changed files with 2740 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
//! 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.
//!
//! 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)
//!
//! Content schema is decoupled from ECS components. The spawn module
//! handles the mapping between the two representations.
pub mod loader;
pub mod spawn;
pub mod types;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::path::PathBuf;
/// Configuration for the content loader.
/// Set the content root path before adding ContentPlugin.
#[derive(Resource, Debug, Clone)]
pub struct ContentConfig {
/// Root directory containing content.yaml and campaign directories.
pub content_root: PathBuf,
}
impl Default for ContentConfig {
fn default() -> Self {
Self {
content_root: PathBuf::from("content"),
}
}
}
/// Content loading plugin.
///
/// Loads content from YAML files at startup and spawns ECS entities.
/// Requires ContentConfig resource to be inserted before the plugin runs.
pub struct ContentPlugin;
impl Plugin for ContentPlugin {
fn build(&self, app: &mut App) {
if !app.world().contains_resource::<ContentConfig>() {
app.insert_resource(ContentConfig::default());
}
app.add_systems(Startup, load_and_spawn_content);
tracing::debug!("ContentPlugin initialized");
}
}
/// Startup system: load content from disk and spawn entities.
fn load_and_spawn_content(world: &mut World) {
let config = world.resource::<ContentConfig>().clone();
tracing::info!("Loading content from: {:?}", config.content_root);
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
);
// Insert the content store as a resource for runtime access
// (triangle queries, pool lookups, dialogue selection)
world.insert_resource(ContentStoreResource(store));
}
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()));
}
}
}
/// Wrapper resource holding the loaded content store.
/// Available for runtime systems that need to query content data
/// (e.g., dialogue selection, triangle fork evaluation).
#[derive(Resource, Debug)]
pub struct ContentStoreResource(pub loader::ContentStore);