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
+32
View File
@@ -616,6 +616,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -902,6 +908,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.27"
@@ -938,6 +950,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "settled-reach-server"
version = "0.1.0"
@@ -950,6 +975,7 @@ dependencies = [
"rand_chacha",
"rmp-serde",
"serde",
"serde_yaml",
"thiserror",
"tracing",
"tracing-subscriber",
@@ -1164,6 +1190,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "uuid"
version = "1.20.0"
+1
View File
@@ -7,6 +7,7 @@ edition = "2021"
bevy_ecs = "0.18"
bevy_app = "0.18"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
rmp-serde = "1"
bincode = "1"
rand = "0.9"
+619
View File
@@ -0,0 +1,619 @@
//! Content discovery and deserialization.
//!
//! Reads content.yaml, discovers campaigns and districts via directory
//! structure, deserializes YAML files into intermediate content types.
//! Comment-only YAML files (stubs) are skipped gracefully.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::content::types::*;
/// All content loaded from disk, organized by district.
/// Inserted as a bevy Resource after loading completes.
#[derive(Debug, Default)]
pub struct ContentStore {
pub manifest: Option<ContentManifest>,
pub districts: BTreeMap<String, DistrictContent>,
}
/// Content for a single district.
#[derive(Debug, Default)]
pub struct DistrictContent {
pub meta: Option<DistrictMeta>,
pub district_path: PathBuf,
pub pools: Vec<Pool>,
pub templates: Vec<Template>,
pub triangles: Vec<Triangle>,
pub npc_profiles: Vec<NpcProfile>,
pub locations: Vec<Location>,
pub routines: Option<RoutineFile>,
pub dialogue_pools: Vec<DialoguePool>,
pub monologue_pools: Vec<MonologuePool>,
}
/// Errors that can occur during content loading.
#[derive(Debug, thiserror::Error)]
pub enum ContentError {
#[error("IO error: {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("YAML parse error: {path}: {source}")]
Yaml {
path: PathBuf,
source: serde_yaml::Error,
},
#[error("Content manifest not found at {0}")]
ManifestNotFound(PathBuf),
}
/// Load all content from the given root directory.
///
/// The root should contain `content.yaml` and the campaign directories.
/// Comment-only YAML stubs are skipped (logged at debug level).
pub fn load_content(content_root: &Path) -> Result<ContentStore, ContentError> {
let mut store = ContentStore::default();
// 1. Load content manifest
let manifest_path = content_root.join("content.yaml");
if !manifest_path.exists() {
return Err(ContentError::ManifestNotFound(manifest_path));
}
let manifest: ContentManifest = load_yaml(&manifest_path)?;
// 2. Discover districts for each enabled campaign
for campaign in &manifest.campaigns {
if !campaign.enabled {
tracing::debug!("Skipping disabled campaign: {}", campaign.id);
continue;
}
let campaign_path = content_root.join(&campaign.path);
let district_dirs = discover_districts(&campaign_path);
for district_dir in district_dirs {
let district_id = derive_district_id(content_root, &district_dir);
tracing::info!("Loading district: {} from {:?}", district_id, district_dir);
let content = load_district(&district_dir)?;
store.districts.insert(district_id, content);
}
}
store.manifest = Some(manifest);
Ok(store)
}
/// Discover district directories by recursively searching for district.yaml.
fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
let mut districts = Vec::new();
let systems_path = campaign_path.join("systems");
if systems_path.is_dir() {
walk_for_districts(&systems_path, &mut districts);
}
// BTreeMap ordering guarantees deterministic district processing,
// but sort the discovery order too for consistency.
districts.sort();
districts
}
/// Recursively walk directories looking for district.yaml files.
fn walk_for_districts(dir: &Path, results: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
// Collect and sort entries for deterministic traversal order
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
for entry in sorted_entries {
let path = entry.path();
if path.is_dir() {
let district_yaml = path.join("district.yaml");
if district_yaml.exists() {
results.push(path);
} else {
walk_for_districts(&path, results);
}
}
}
}
/// Derive a district ID from its filesystem path.
/// e.g. campaigns/main/systems/krenn/stations/sova/districts/transit → krenn.sova.transit
fn derive_district_id(content_root: &Path, district_dir: &Path) -> String {
let rel = district_dir
.strip_prefix(content_root)
.unwrap_or(district_dir);
let components: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
// Extract meaningful path segments: system, station, district name
// Path pattern: campaigns/{id}/systems/{system}/stations/{station}/districts/{district}
let mut parts = Vec::new();
let mut iter = components.iter().peekable();
while let Some(&segment) = iter.next() {
match segment {
"systems" | "stations" | "districts" => {
if let Some(&&name) = iter.peek() {
parts.push(name.to_string());
iter.next();
}
}
_ => {}
}
}
if parts.is_empty() {
// Fallback: use the directory name
district_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
} else {
parts.join(".")
}
}
/// Load all content for a single district directory.
fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
let mut content = DistrictContent {
district_path: district_dir.to_path_buf(),
..Default::default()
};
// District metadata
let meta_path = district_dir.join("district.yaml");
if meta_path.exists() {
match load_yaml::<DistrictMeta>(&meta_path) {
Ok(meta) => content.meta = Some(meta),
Err(e) => tracing::warn!("Failed to parse district metadata: {}", e),
}
}
// Pools
let pools_path = district_dir.join("pools.yaml");
if pools_path.exists() {
match load_yaml::<PoolFile>(&pools_path) {
Ok(pool_file) => content.pools = pool_file.pools,
Err(e) => tracing::warn!("Failed to parse pools: {}", e),
}
}
// Templates
let templates_dir = district_dir.join("templates");
if templates_dir.is_dir() {
content.templates = load_yaml_dir::<Template>(&templates_dir);
}
// Triangles
let triangles_dir = district_dir.join("triangles");
if triangles_dir.is_dir() {
content.triangles = load_yaml_dir::<Triangle>(&triangles_dir);
}
// NPC profiles
let npcs_dir = district_dir.join("npcs");
if npcs_dir.is_dir() {
content.npc_profiles = load_yaml_dir::<NpcProfile>(&npcs_dir);
}
// Locations
let locations_dir = district_dir.join("locations");
if locations_dir.is_dir() {
content.locations = load_yaml_dir::<Location>(&locations_dir);
}
// Routines
let routines_path = district_dir.join("routines").join("schedules.yaml");
if routines_path.exists() {
match load_yaml::<RoutineFile>(&routines_path) {
Ok(routines) => content.routines = Some(routines),
Err(e) => tracing::debug!("Skipping routines (stub or invalid): {}", e),
}
}
// Dialogue pools
let dialogue_dir = district_dir.join("dialogue");
if dialogue_dir.is_dir() {
content.dialogue_pools = load_yaml_recursive::<DialoguePool>(&dialogue_dir);
}
// Monologue pools
let monologue_dir = district_dir.join("monologue");
if monologue_dir.is_dir() {
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
}
let npc_count = content.npc_profiles.len();
let triangle_count = content.triangles.len();
let template_count = content.templates.len();
let pool_count = content.pools.len();
let dialogue_count = content.dialogue_pools.len();
let monologue_count = content.monologue_pools.len();
tracing::info!(
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools",
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count
);
Ok(content)
}
/// Load and parse a single YAML file.
fn load_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, ContentError> {
let text = std::fs::read_to_string(path).map_err(|e| ContentError::Io {
path: path.to_path_buf(),
source: e,
})?;
serde_yaml::from_str(&text).map_err(|e| ContentError::Yaml {
path: path.to_path_buf(),
source: e,
})
}
/// Load all YAML files in a directory (non-recursive), skipping stubs.
fn load_yaml_dir<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
let mut results = Vec::new();
for entry in sorted_entries {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
match load_yaml::<T>(&path) {
Ok(item) => results.push(item),
Err(e) => {
// Check if this is a comment-only stub
if is_comment_only_file(&path) {
tracing::debug!("Skipping stub file: {:?}", path);
} else {
tracing::warn!("Failed to parse {:?}: {}", path, e);
}
}
}
}
results
}
/// 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);
}
}
}
});
results
}
/// Walk a directory recursively, calling the callback for each .yaml file.
fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
for entry in sorted_entries {
let path = entry.path();
if path.is_dir() {
walk_yaml_files(&path, callback);
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
callback(&path);
}
}
}
/// Check if a YAML file contains only comments and whitespace (stub file).
fn is_comment_only_file(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
text.lines()
.all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_temp_content(dir: &Path) {
// Create content.yaml
fs::write(
dir.join("content.yaml"),
r#"version: "0.1.0"
campaigns:
- id: test
path: campaigns/test
enabled: true
discovery:
districts: "systems/**/districts/*/district.yaml"
"#,
)
.unwrap();
// Create district directory structure
let district_dir = dir.join("campaigns/test/systems/alpha/stations/beta/districts/gamma");
fs::create_dir_all(&district_dir).unwrap();
// district.yaml
fs::write(
district_dir.join("district.yaml"),
r#"display_name: "Test District"
description: "A test district"
locations: ["loc-a"]
npc_count: 2
"#,
)
.unwrap();
// pools.yaml
fs::write(
district_dir.join("pools.yaml"),
r#"pools:
- pool_id: "test:pool_a"
category: npc_role
candidates:
- npc_id: "npc:alice"
weight: 1
"#,
)
.unwrap();
// triangles/
let tri_dir = district_dir.join("triangles");
fs::create_dir_all(&tri_dir).unwrap();
fs::write(
tri_dir.join("test-triangle.yaml"),
r#"canonical_id: test-triangle
display_name: "Test Triangle"
members:
- npc: "npc:alice"
role: "role-a"
- npc: "npc:bob"
role: "role-b"
- npc: "npc:carol"
role: "role-c"
forks: []
resolution_states: []
"#,
)
.unwrap();
// templates/
let tpl_dir = district_dir.join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
fs::write(
tpl_dir.join("test-site.yaml"),
r#"template_id: test-site
display_name: "Test Site"
location: loc-a
role_slots:
- role: worker
display_name: "Worker"
count: 1
required: true
"#,
)
.unwrap();
// npcs/ — one stub, one real
let npc_dir = district_dir.join("npcs");
fs::create_dir_all(&npc_dir).unwrap();
fs::write(
npc_dir.join("alice.yaml"),
r#"canonical_id: alice
display_name: "Alice"
tier: 1
pattern: "FRIEND"
motivation: "HANDLER"
"#,
)
.unwrap();
fs::write(
npc_dir.join("bob.yaml"),
"# NPC Profile: bob\n# canonical_id: test.bob\n",
)
.unwrap();
}
#[test]
fn load_content_discovers_district() {
let dir = std::env::temp_dir().join("sr_content_test_discover");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
assert!(store.manifest.is_some());
assert_eq!(store.districts.len(), 1);
let (id, content) = store.districts.iter().next().unwrap();
assert_eq!(id, "alpha.beta.gamma");
assert!(content.meta.is_some());
assert_eq!(content.meta.as_ref().unwrap().display_name, "Test District");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_pools() {
let dir = std::env::temp_dir().join("sr_content_test_pools");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.pools.len(), 1);
assert_eq!(content.pools[0].pool_id, "test:pool_a");
assert_eq!(content.pools[0].candidates.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_triangles() {
let dir = std::env::temp_dir().join("sr_content_test_triangles");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.triangles.len(), 1);
assert_eq!(content.triangles[0].canonical_id, "test-triangle");
assert_eq!(content.triangles[0].members.len(), 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_skips_stub_npcs() {
let dir = std::env::temp_dir().join("sr_content_test_stubs");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
// Only alice.yaml should parse; bob.yaml is a stub
assert_eq!(content.npc_profiles.len(), 1);
assert_eq!(content.npc_profiles[0].canonical_id, "alice");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_templates() {
let dir = std::env::temp_dir().join("sr_content_test_templates");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.templates.len(), 1);
assert_eq!(content.templates[0].template_id, "test-site");
assert_eq!(content.templates[0].role_slots.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[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 id = derive_district_id(root, district);
assert_eq!(id, "krenn.sova.transit");
}
#[test]
fn is_comment_only_detects_stubs() {
let dir = std::env::temp_dir().join("sr_content_test_comment");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let stub = dir.join("stub.yaml");
fs::write(&stub, "# comment\n# another\n").unwrap();
assert!(is_comment_only_file(&stub));
let real = dir.join("real.yaml");
fs::write(&real, "key: value\n").unwrap();
assert!(!is_comment_only_file(&real));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn role_count_range_deserialization() {
// RoleCount::Range uses untagged enum — verify {min, max} object parses
let yaml = r#"
template_id: test
display_name: "Test"
role_slots:
- role: worker
display_name: "Worker"
count:
min: 2
max: 4
required: true
- role: manager
display_name: "Manager"
count: 1
required: true
"#;
let template: crate::content::types::Template =
serde_yaml::from_str(yaml).expect("template with RoleCount::Range should parse");
assert_eq!(template.role_slots.len(), 2);
match &template.role_slots[0].count {
crate::content::types::RoleCount::Range { min, max } => {
assert_eq!(*min, 2);
assert_eq!(*max, 4);
}
other => panic!("Expected RoleCount::Range, got {:?}", other),
}
match &template.role_slots[1].count {
crate::content::types::RoleCount::Fixed(n) => assert_eq!(*n, 1),
other => panic!("Expected RoleCount::Fixed, got {:?}", other),
}
}
#[test]
fn pool_constraint_deserialization() {
// PoolConstraint uses untagged enum — verify both variants parse
let yaml = r#"
pools:
- pool_id: "test:pool"
category: npc_role
constraints:
- must_be_in_template: "logistics-hub"
- must_have_pattern: "FRIEND"
- bonded_character: "smuggler"
candidates:
- npc_id: "npc:alice"
weight: 1
"#;
let pool_file: crate::content::types::PoolFile =
serde_yaml::from_str(yaml).expect("pool with constraints should parse");
assert_eq!(pool_file.pools.len(), 1);
assert_eq!(pool_file.pools[0].constraints.len(), 3);
// All constraints in this format are key-value strings (plain scalars)
// which match PoolConstraint::KeyValue
for constraint in &pool_file.pools[0].constraints {
match constraint {
crate::content::types::PoolConstraint::KeyValue(s) => {
assert!(!s.is_empty());
}
crate::content::types::PoolConstraint::Structured(_) => {
// Structured constraints are also valid
}
}
}
}
}
+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);
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
//! Intermediate content types for YAML deserialization.
//!
//! These types mirror the JSON Schema definitions in content/_schema/.
//! They are decoupled from ECS components — the spawn module handles
//! the mapping from content types to bevy_ecs Components/Resources.
//!
//! Load order: content files → seed config → entity instantiation.
//! Per Tyre's architecture guidance (D-020, #394).
use serde::Deserialize;
use std::collections::BTreeMap;
// ---------------------------------------------------------------------------
// Content manifest (content.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct ContentManifest {
pub version: String,
pub campaigns: Vec<CampaignRef>,
}
#[derive(Debug, Deserialize)]
pub struct CampaignRef {
pub id: String,
pub path: String,
pub enabled: bool,
#[serde(default)]
pub discovery: Option<Discovery>,
}
#[derive(Debug, Deserialize)]
pub struct Discovery {
#[serde(default)]
pub districts: Option<String>,
}
// ---------------------------------------------------------------------------
// District metadata (district.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct DistrictMeta {
pub display_name: String,
pub description: String,
#[serde(default)]
pub locations: Vec<String>,
#[serde(default)]
pub npc_count: u32,
#[serde(default)]
pub canonical_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Pool configuration (pools.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct PoolFile {
pub pools: Vec<Pool>,
}
#[derive(Debug, Deserialize)]
pub struct Pool {
pub pool_id: String,
pub category: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub constraints: Vec<PoolConstraint>,
#[serde(default)]
pub candidates: Vec<PoolCandidate>,
}
/// Pool constraints are stored as key-value strings.
/// The seed system interprets them; the loader just preserves them.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum PoolConstraint {
KeyValue(String),
Structured(BTreeMap<String, String>),
}
#[derive(Debug, Deserialize)]
pub struct PoolCandidate {
/// NPC candidates use `npc_id`, contraband uses `id`.
#[serde(alias = "id")]
pub npc_id: Option<String>,
#[serde(default = "default_weight")]
pub weight: u32,
}
fn default_weight() -> u32 {
1
}
// ---------------------------------------------------------------------------
// Template configuration (templates/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Template {
pub template_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub capacity: Option<Capacity>,
#[serde(default)]
pub role_slots: Vec<RoleSlot>,
#[serde(default)]
pub v01_assignments: Option<BTreeMap<String, String>>,
#[serde(default)]
pub reference_links: Vec<ReferenceLink>,
#[serde(default)]
pub triangle_constraints: Vec<TriangleConstraint>,
#[serde(default)]
pub dialogue_pools: Vec<DialoguePoolRef>,
}
#[derive(Debug, Deserialize)]
pub struct Capacity {
pub min: u32,
pub max: u32,
}
#[derive(Debug, Deserialize)]
pub struct RoleSlot {
pub role: String,
pub display_name: String,
#[serde(default)]
pub count: RoleCount,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub pool_ref: Option<String>,
#[serde(default)]
pub flags: Vec<String>,
}
/// Role count can be a plain integer or a {min, max} object.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum RoleCount {
Fixed(u32),
Range { min: u32, max: u32 },
}
impl Default for RoleCount {
fn default() -> Self {
Self::Fixed(1)
}
}
#[derive(Debug, Deserialize)]
pub struct ReferenceLink {
pub npc: String,
#[serde(default)]
pub owning_template: Option<String>,
#[serde(default)]
pub relationship: Option<String>,
#[serde(default)]
pub presence_phases: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct TriangleConstraint {
pub triangle: String,
#[serde(default)]
pub required_roles: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct DialoguePoolRef {
pub location: String,
#[serde(default)]
pub roles: Vec<String>,
}
// ---------------------------------------------------------------------------
// Triangle (triangles/*.yaml) — mirrors triangle.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Triangle {
pub canonical_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
pub members: Vec<TriangleMember>,
#[serde(default)]
pub forks: Vec<Fork>,
#[serde(default)]
pub resolution_states: Vec<Resolution>,
}
#[derive(Debug, Deserialize)]
pub struct TriangleMember {
pub npc: String,
pub role: String,
}
#[derive(Debug, Deserialize)]
pub struct Fork {
pub id: String,
#[serde(default)]
pub condition: Option<String>,
#[serde(default)]
pub outcomes: Vec<ForkOutcome>,
}
#[derive(Debug, Deserialize)]
pub struct ForkOutcome {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub effects: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Resolution {
pub id: String,
pub description: String,
}
// ---------------------------------------------------------------------------
// NPC Profile (npcs/*.yaml) — mirrors npc-profile.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct NpcProfile {
pub canonical_id: String,
pub display_name: String,
#[serde(default = "default_tier")]
pub tier: u8,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub motivation: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub want: Option<NpcWant>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub relationships: Vec<NpcRelationship>,
#[serde(default)]
pub tolerance: Option<NpcTolerance>,
#[serde(default)]
pub routine: Option<NpcRoutineSummary>,
#[serde(default)]
pub information: Option<NpcInformation>,
#[serde(default)]
pub contentment: Option<NpcContentment>,
#[serde(default)]
pub personality: Option<BTreeMap<String, String>>,
#[serde(default)]
pub tells: Vec<NpcTell>,
#[serde(default)]
pub skills: Option<NpcSkills>,
#[serde(default)]
pub triangle_membership: Vec<String>,
#[serde(default)]
pub trust_levels: Option<NpcTrustLevels>,
#[serde(default)]
pub friend_arc: Option<NpcFriendArc>,
#[serde(default)]
pub dual_lens: Option<NpcDualLens>,
}
fn default_tier() -> u8 {
3
}
#[derive(Debug, Deserialize)]
pub struct NpcWant {
pub primary: String,
#[serde(default)]
pub intensity: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcRelationship {
pub target: String,
pub kind: String,
#[serde(default)]
pub trust: Option<i32>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTolerance {
#[serde(default)]
pub threshold: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcRoutineSummary {
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcInformation {
#[serde(default)]
pub knows: Vec<String>,
#[serde(default)]
pub access_tier: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcContentment {
#[serde(default)]
pub level: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTell {
pub trigger: String,
pub behavior: String,
#[serde(default)]
pub visible_to: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcSkills {
#[serde(default)]
pub combat_trained: Option<bool>,
#[serde(default)]
pub skills: Option<BTreeMap<String, i32>>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTrustLevels {
#[serde(default)]
pub surface: Option<String>,
#[serde(default)]
pub real: Option<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcFriendArc {
pub bonded_character: String,
#[serde(default)]
pub phases: Vec<NpcFriendPhase>,
}
#[derive(Debug, Deserialize)]
pub struct NpcFriendPhase {
pub phase: u8,
pub description: String,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub routine_deviation: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcDualLens {
#[serde(default)]
pub smuggler: Option<String>,
#[serde(default)]
pub detective: Option<String>,
}
// ---------------------------------------------------------------------------
// Location (locations/*.yaml) — mirrors location.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Location {
pub canonical_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tile_bounds: Option<TileBounds>,
#[serde(default)]
pub sightlines: Option<Sightlines>,
#[serde(default)]
pub ambient_sound: Option<String>,
#[serde(default)]
pub social_site: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TileBounds {
pub x_min: i32,
pub y_min: i32,
pub x_max: i32,
pub y_max: i32,
pub z: i32,
}
#[derive(Debug, Deserialize)]
pub struct Sightlines {
#[serde(default)]
pub open: Option<bool>,
#[serde(default)]
pub notes: Option<String>,
}
// ---------------------------------------------------------------------------
// Routine schedules (routines/schedules.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct RoutineFile {
pub district: String,
pub schedules: Vec<NpcSchedule>,
}
#[derive(Debug, Deserialize)]
pub struct NpcSchedule {
pub npc: String,
pub entries: Vec<RoutineEntry>,
#[serde(default)]
pub deviations: Vec<Deviation>,
}
#[derive(Debug, Deserialize)]
pub struct RoutineEntry {
pub phase: String,
pub location: String,
#[serde(default)]
pub tile: Option<TileCoord>,
#[serde(default)]
pub activity: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Deviation {
pub trigger: String,
#[serde(default)]
pub phase: Option<String>,
pub location: String,
#[serde(default)]
pub tile: Option<TileCoord>,
#[serde(default)]
pub activity: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TileCoord {
pub x: i32,
pub y: i32,
}
// ---------------------------------------------------------------------------
// Dialogue pool (dialogue/**/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct DialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<DialogueLine>,
}
#[derive(Debug, Deserialize)]
pub struct DialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<String>,
pub trust: String,
pub situation: Vec<String>,
#[serde(default)]
pub topic: Vec<String>,
#[serde(default)]
pub mood: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[derive(Debug, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
// ---------------------------------------------------------------------------
// Monologue pool (monologue/**/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct MonologuePool {
pub character: String,
pub location: String,
pub lines: Vec<MonologueLine>,
}
#[derive(Debug, Deserialize)]
pub struct MonologueLine {
pub id: String,
pub text: String,
pub trigger: String,
#[serde(default)]
pub prerequisites: Option<Prerequisites>,
#[serde(default)]
pub priority: Option<i32>,
#[serde(default)]
pub cooldown: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(default)]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
+1
View File
@@ -3,6 +3,7 @@
pub mod bridge;
pub mod cause_chain;
pub mod content;
pub mod knowledge;
pub mod npc;
pub mod perception;
+1
View File
@@ -49,6 +49,7 @@ pub enum WantKind {
Freedom,
Justice,
Revenge,
Happiness,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
+405
View File
@@ -0,0 +1,405 @@
//! Integration test: content loading pipeline.
//!
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
use settled_reach_server::content::loader::{load_content, ContentStore};
use settled_reach_server::content::spawn::spawn_content;
use settled_reach_server::content::types::*;
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::npc;
use settled_reach_server::simulation::SimulationPlugin;
/// Find the content root relative to the test binary location.
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
// -----------------------------------------------------------------------
// Test: real content discovery and structural loading
// -----------------------------------------------------------------------
#[test]
fn discover_real_content_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
// Skip if content directory is not present (e.g. CI without content)
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let store = load_content(&root).expect("content loading should succeed");
// Manifest should be present
assert!(store.manifest.is_some());
let manifest = store.manifest.as_ref().unwrap();
assert_eq!(manifest.version, "0.1.0");
assert!(!manifest.campaigns.is_empty());
// At least one district should be discovered
assert!(!store.districts.is_empty(), "should discover at least one district");
}
#[test]
fn load_real_transit_district() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
// The transit district should be discovered
let transit = store
.districts
.get("krenn.sova.transit")
.expect("transit district should be discovered");
// District metadata
assert!(transit.meta.is_some());
let meta = transit.meta.as_ref().unwrap();
assert_eq!(meta.display_name, "Sova Transit District");
assert_eq!(meta.npc_count, 17);
// 5 triangles from ticket #391
assert_eq!(transit.triangles.len(), 5);
let triangle_ids: Vec<&str> = transit
.triangles
.iter()
.map(|t| t.canonical_id.as_str())
.collect();
assert!(triangle_ids.contains(&"hub-power"));
assert!(triangle_ids.contains(&"worried-knowledge"));
assert!(triangle_ids.contains(&"bar-tensions"));
assert!(triangle_ids.contains(&"worried-partner"));
assert!(triangle_ids.contains(&"informant-question"));
// Each triangle should have exactly 3 members
for triangle in &transit.triangles {
assert_eq!(
triangle.members.len(),
3,
"Triangle {} should have 3 members",
triangle.canonical_id
);
}
// 5 pools from ticket #389
assert_eq!(transit.pools.len(), 5);
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
assert!(pool_ids.contains(&"transit:friend_smuggler"));
assert!(pool_ids.contains(&"transit:friend_detective"));
assert!(pool_ids.contains(&"transit:bar_regulars"));
assert!(pool_ids.contains(&"transit:compromised_inspector"));
assert!(pool_ids.contains(&"transit:primary_contraband"));
// 3 templates from ticket #390
assert_eq!(transit.templates.len(), 3);
let template_ids: Vec<&str> = transit
.templates
.iter()
.map(|t| t.template_id.as_str())
.collect();
assert!(template_ids.contains(&"logistics-hub"));
assert!(template_ids.contains(&"bar"));
assert!(template_ids.contains(&"smuggling-ring"));
// 20 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan)
// Populated by #398 (wiki→YAML NPC conversion)
assert_eq!(
transit.npc_profiles.len(),
20,
"Expected 20 parseable NPC profiles (17 NPCs + 2 PCs + 1 extended)"
);
}
#[test]
fn verify_triangle_fork_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Hub power triangle: should have 1 fork with 3 outcomes
let hub_power = transit
.triangles
.iter()
.find(|t| t.canonical_id == "hub-power")
.expect("hub-power triangle should exist");
assert_eq!(hub_power.forks.len(), 1);
assert_eq!(hub_power.forks[0].id, "volume-escalation");
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
let outcome_ids: Vec<&str> = hub_power.forks[0]
.outcomes
.iter()
.filter_map(|o| o.id.as_deref())
.collect();
assert!(outcome_ids.contains(&"escalate"));
assert!(outcome_ids.contains(&"stabilize"));
assert!(outcome_ids.contains(&"mediate"));
// Resolution states
assert_eq!(hub_power.resolution_states.len(), 3);
}
#[test]
fn verify_pool_candidates() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// bar_regulars pool should have 5 candidates
let bar_regulars = transit
.pools
.iter()
.find(|p| p.pool_id == "transit:bar_regulars")
.expect("bar_regulars pool should exist");
assert_eq!(bar_regulars.candidates.len(), 5);
assert_eq!(bar_regulars.category, "npc_group");
}
#[test]
fn verify_template_role_slots() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Logistics hub should have 5 role slots
let hub = transit
.templates
.iter()
.find(|t| t.template_id == "logistics-hub")
.expect("logistics-hub template should exist");
assert_eq!(hub.role_slots.len(), 5);
// Should have v01_assignments
assert!(hub.v01_assignments.is_some());
let assignments = hub.v01_assignments.as_ref().unwrap();
assert!(assignments.contains_key("shift-supervisor"));
}
// -----------------------------------------------------------------------
// Test: NPC spawning pipeline with test fixture data
// -----------------------------------------------------------------------
#[test]
fn spawn_npc_from_content_store() {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
// Create a minimal content store with one test NPC
let mut store = ContentStore::default();
let mut district = settled_reach_server::content::loader::DistrictContent::default();
district.npc_profiles.push(NpcProfile {
canonical_id: "test-worker".to_string(),
display_name: "Test Worker".to_string(),
tier: 2,
pattern: Some("ANCHOR".to_string()),
motivation: Some("CIVILIAN".to_string()),
description: Some("A test dock worker".to_string()),
want: Some(NpcWant {
primary: "Safety".to_string(),
intensity: Some(5),
description: Some("Wants a quiet life".to_string()),
}),
secret: None,
relationships: vec![],
tolerance: Some(NpcTolerance {
threshold: Some(70),
description: None,
}),
routine: None,
information: None,
contentment: Some(NpcContentment {
level: Some(30),
description: None,
}),
personality: None,
tells: vec![],
skills: Some(NpcSkills {
combat_trained: Some(false),
skills: Some({
let mut m = BTreeMap::new();
m.insert("technical".to_string(), 5);
m
}),
}),
triangle_membership: vec![],
trust_levels: None,
friend_arc: None,
dual_lens: None,
});
store
.districts
.insert("test.district".to_string(), district);
let result = spawn_content(&mut world, &store);
// Verify entity was spawned
assert_eq!(result.npcs_spawned, 1);
assert!(result.npc_ids.contains_key("test-worker"));
// Verify ECS components
let stable_id = result.npc_ids["test-worker"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
assert!(world.get::<npc::Npc>(entity).is_some());
let want = world.get::<npc::Want>(entity).unwrap();
assert_eq!(want.primary, npc::WantKind::Safety);
assert_eq!(want.intensity, 5);
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
assert_eq!(tolerance.threshold, 70);
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
}
// -----------------------------------------------------------------------
// Test: ContentPlugin integration with bevy App
// -----------------------------------------------------------------------
#[test]
fn content_plugin_loads_via_app() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
});
app.add_plugins(ContentPlugin);
// Run startup systems
app.update();
// ContentStoreResource should be inserted
assert!(
app.world().contains_resource::<ContentStoreResource>(),
"ContentStoreResource should be present after startup"
);
let store = &app.world().resource::<ContentStoreResource>().0;
assert!(!store.districts.is_empty());
}
// -----------------------------------------------------------------------
// Test: Full spawn pipeline with real content — 10-axis gap closure
// -----------------------------------------------------------------------
#[test]
fn spawn_real_content_with_relationships_and_secrets() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// All 20 profiles should spawn
assert_eq!(result.npcs_spawned, 20);
assert!(result.npc_ids.contains_key("npc:kael-davan"));
assert!(result.npc_ids.contains_key("npc:voss"));
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
assert!(result.npc_ids.contains_key("npc:nils-davan"));
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
let registry = world.resource::<EntityRegistry>();
let mut npcs_with_want = 0;
for (canonical_id, stable_id) in &result.npc_ids {
let entity = registry
.to_entity(stable_id)
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
assert!(
world.get::<npc::Want>(entity).is_some(),
"NPC {} should have a Want component",
canonical_id
);
npcs_with_want += 1;
}
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");
assert_eq!(kael_want.primary, npc::WantKind::Safety);
// Verify Kael has a Secret component
let kael_secret = world
.get::<npc::Secret>(kael_entity)
.expect("Kael should have Secret");
assert!(kael_secret.description.contains("ring"));
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
// Verify Kael has Relationships (7 defined in YAML)
let kael_rels = world
.get::<npc::Relationships>(kael_entity)
.expect("Kael should have Relationships");
assert!(
kael_rels.entries.len() >= 5,
"Kael should have at least 5 resolved relationships, got {}",
kael_rels.entries.len()
);
// Verify Kael has KnowledgeGraph (background facts from information.knows)
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()
)));
// Verify global RelationshipGraph was populated
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
assert!(
graph.edge_count() >= 20,
"Expected at least 20 relationship edges, got {}",
graph.edge_count()
);
// Verify Nils (off-stage) also has correct data
let nils_entity = world
.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");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}