Files
settled-reach/server/src/content/loader.rs
T
jpmschweitzerandClaude Opus 4.6 d4fdbf426e fix(server): address PR #23 review — 4 critical bugs, 3 warnings, 11 suggestions
Critical fixes:
- Add PendingRecognitionWire serialization roundtrip test
- Check Option return from delay.cancel() before logging
- InputQueue capacity limit (1000) with drop-oldest and warning
- TODO in observation.rs references ticket #450

Warning fixes:
- Hot-reload guards against invalid/empty content root
- Location header mismatch warning in line pool indexing
- walk_yaml() depth limit (100) against symlink loops
- Consecutive reload failure counter (warns after 5+)

Test additions:
- Negative prerequisite filtering test for monologue lines
- Integration test for pending_recognitions in observer snapshot
- Eavesdrop threshold ordering assertion (Careful < default)

Documentation:
- Playtesting expectation comments on delay constants
- is_pending() scalability note for future NPC cognitive delay
- ID format regex validation in line pool spec
- BTreeMap vs sort() ordering clarification in loader
- Multiplayer TODO in relationships.rs references D-010
- ContentSlug ticket #452 filed for entity slug resolution

394 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:51 +01:00

622 lines
20 KiB
Rust

//! 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);
}
// Discovery order from fs::read_dir is platform-dependent. Sort the Vec
// here so districts load in a deterministic order regardless of OS.
// ContentStore.districts uses BTreeMap for deterministic *iteration* later,
// but sorted discovery ensures deterministic *load* order (and thus
// deterministic ID derivation and log output).
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
}
}
}
}
}