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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod content;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
|
||||
@@ -49,6 +49,7 @@ pub enum WantKind {
|
||||
Freedom,
|
||||
Justice,
|
||||
Revenge,
|
||||
Happiness,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user