Files
settled-reach/server/src/content/spawn.rs
T
jpmschweitzerandClaude Opus 4.6 35f55cfa46 feat(simulation): dialogue pipeline, ContentSlug, and walk-away KG recording
Implement full dialogue selection pipeline (D-028): 4-layer filtering
engine with access tier, situation derivation, trust tier, and weighted
topic+mood scoring via SimRng. Add ContentSlug component for stable
content identity across save/load. Add walk-away KG recording with
IncompleteInteraction events per D-064 three-phase consequences. Bump
protocol to v8 with DialogueResponseEvent. Fixes #305, #427, #452.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:23 +01:00

1112 lines
39 KiB
Rust

//! Content → ECS entity spawning (two-phase).
//!
//! Maps intermediate content types from the loader into bevy_ecs
//! Components and Resources. The separation ensures content schema
//! changes don't require ECS component changes (and vice versa).
//!
//! **Phase 1** (spawn_entities): Create NPC entities with per-entity components
//! (Want, Secret, Tolerance, Contentment, Personality, Tells, Skills). Builds
//! the canonical_id → StableId map needed for cross-reference resolution.
//!
//! **Phase 2** (resolve_cross_references): Wire up components that reference
//! other entities by canonical_id — Relationships (per-entity + global graph),
//! KnowledgeGraph (background facts), DailyRoutine (schedules.yaml join).
//! Requires the npc_ids map from Phase 1.
//!
//! NPC spawn follows the 10-axis model (D-024):
//! Want, Secret, Relationships, Tolerance, Routine, Information,
//! Contentment, Personality, Tells, Skills
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use crate::content::loader::{ContentStore, DistrictContent};
use crate::content::types;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::knowledge::types::{
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, StableId,
};
use crate::npc;
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::DayPhase;
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
///
/// Bridges authoring identity to ECS entities. Independent of StableId —
/// StableId is runtime entity tracking (KG references), ContentSlug is
/// authoring/content identity (which authored NPC template). Not all entities
/// have ContentSlugs (e.g., procedurally spawned NPCs, furniture).
///
/// Used by #427 (walk-away KG recording) to record interaction memory
/// against a stable content identity rather than an Entity (which is
/// unstable across save/load).
#[derive(Component, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContentSlug(pub String);
/// Result of spawning content into the ECS world.
#[derive(Debug, Default)]
pub struct SpawnResult {
/// Number of NPC entities spawned.
pub npcs_spawned: u32,
/// Mapping from NPC canonical_id to their StableId.
pub npc_ids: BTreeMap<String, StableId>,
}
/// Spawn all loaded content into the ECS world.
///
/// This is the main entry point for content → ECS conversion.
/// Runs Phase 1 (entity spawning) then Phase 2 (cross-reference resolution).
pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult {
// Phase 1: spawn entities and build canonical_id → StableId map
let result = spawn_entities(world, store);
// Phase 2: resolve cross-references using the id map
resolve_cross_references(world, store, &result);
tracing::info!(
"Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2)",
result.npcs_spawned
);
result
}
// ===========================================================================
// Phase 1: Entity spawning
// ===========================================================================
/// Phase 1: Spawn all NPC entities with per-entity components.
/// Returns the canonical_id → StableId map for Phase 2.
fn spawn_entities(world: &mut World, store: &ContentStore) -> SpawnResult {
let mut result = SpawnResult::default();
for (district_id, content) in &store.districts {
tracing::info!("Phase 1 — spawning entities for district: {}", district_id);
for profile in &content.npc_profiles {
spawn_npc(world, profile, &mut result);
}
}
tracing::info!("Phase 1 complete: {} NPCs spawned", result.npcs_spawned);
result
}
/// Spawn a single NPC entity from a content profile.
///
/// Maps per-entity axes of the 10-axis NPC model (D-024) to ECS components.
/// Cross-entity axes (Relationships, Routine, Information) are handled in Phase 2.
fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnResult) {
let mut entity_commands = world.spawn(npc::Npc);
// Default position — will be overridden by routine system on first phase transition
entity_commands.insert(TilePosition::new(0, 0, 0));
// Mark NPC as interactable for proximity-based verb detection (#413)
entity_commands.insert(Interactable);
// Axis 1: Want
if let Some(want) = &profile.want {
if let Some(kind) = parse_want_kind(&want.primary) {
entity_commands.insert(npc::Want {
primary: kind,
intensity: want.intensity.unwrap_or(5).clamp(1, 10) as u8,
description: want
.description
.clone()
.unwrap_or_else(|| want.primary.clone()),
});
}
}
// Axis 2: Secret
// Content has `secret: Option<String>` — freeform narrative description.
// Severity defaults to Moderate (most secrets in v0.1 are career/life-threatening).
// known_by starts empty — runtime systems populate it when secrets are discovered.
if let Some(secret_text) = &profile.secret {
let trimmed = secret_text.trim();
if !trimmed.is_empty() {
entity_commands.insert(npc::Secret {
description: trimmed.to_string(),
severity: parse_secret_severity(trimmed),
known_by: Vec::new(),
});
}
}
// Axis 4: Tolerance threshold
if let Some(tolerance) = &profile.tolerance {
entity_commands.insert(npc::ToleranceThreshold {
current_stress: 0,
threshold: tolerance.threshold.unwrap_or(50).clamp(0, 100) as i16,
});
}
// Axis 7: Contentment
if let Some(contentment) = &profile.contentment {
entity_commands.insert(npc::Contentment {
level: contentment.level.unwrap_or(0).clamp(-100, 100) as i16,
});
}
// Supporting axis 1: Personality traits
if let Some(personality_map) = &profile.personality {
let traits: Vec<npc::PersonalityTrait> = personality_map
.keys()
.filter_map(|k| parse_personality_trait(k))
.collect();
if !traits.is_empty() {
entity_commands.insert(npc::PersonalityTraits { traits });
}
}
// Supporting axis 2: Tell system
if !profile.tells.is_empty() {
let tells: Vec<npc::Tell> = profile
.tells
.iter()
.map(|t| npc::Tell {
trigger: parse_tell_trigger(&t.trigger),
behavior: t.behavior.clone(),
})
.collect();
entity_commands.insert(npc::TellSystem { tells });
}
// Supporting axis 3: Skills
if let Some(skills) = &profile.skills {
let mut skill_map = BTreeMap::new();
if let Some(ref s) = skills.skills {
for (name, &level) in s {
if let Some(skill) = parse_skill(name) {
skill_map.insert(skill, level.clamp(1, 10) as u8);
}
}
}
entity_commands.insert(npc::SkillSet {
skills: skill_map,
combat_trained: skills.combat_trained.unwrap_or(false),
});
}
// TODO: CombatCapability — no content schema type exists yet. When combat content
// is authored, add weapon_proficiency + combat_style mapping here.
let entity = entity_commands.id();
// Register in EntityRegistry for StableId mapping
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
world
.entity_mut(entity)
.insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone())));
result
.npc_ids
.insert(profile.canonical_id.clone(), stable_id);
result.npcs_spawned += 1;
tracing::debug!(
"Spawned NPC: {} (StableId: {:?}, tier: {})",
profile.canonical_id,
stable_id,
profile.tier
);
}
// ===========================================================================
// Phase 2: Cross-reference resolution
// ===========================================================================
/// Phase 2: Resolve cross-references that depend on the canonical_id → StableId map.
///
/// This runs after all entities are spawned so that canonical_id references
/// (e.g., relationship targets, routine NPC keys) can be resolved to StableIds.
fn resolve_cross_references(world: &mut World, store: &ContentStore, result: &SpawnResult) {
for (district_id, content) in &store.districts {
tracing::info!(
"Phase 2 — resolving cross-references for district: {}",
district_id
);
// Axis 6: Information → KnowledgeGraph via with_background()
resolve_information(world, content, &result.npc_ids);
// Axis 3: Relationships → per-entity component + global RelationshipGraph
resolve_relationships(world, content, &result.npc_ids);
// Axis 5: DailyRoutine from schedules.yaml
resolve_routines(world, content, &result.npc_ids);
}
tracing::info!("Phase 2 complete");
}
/// Axis 6: Populate KnowledgeGraph with background facts from NPC information.knows.
///
/// Per Tyre's recommendation: populate KnowledgeGraph directly via `with_background()`,
/// skip InformationInventory component — one source of truth.
/// Default confidence: KnowsOf for all background knowledge (D-041).
fn resolve_information(
world: &mut World,
content: &DistrictContent,
npc_ids: &BTreeMap<String, StableId>,
) {
for profile in &content.npc_profiles {
let Some(info) = &profile.information else {
continue;
};
if info.knows.is_empty() {
continue;
}
let Some(&stable_id) = npc_ids.get(&profile.canonical_id) else {
tracing::warn!(
"Cannot resolve information for {}: not in npc_ids map",
profile.canonical_id
);
continue;
};
let Some(entity) = world.resource::<EntityRegistry>().to_entity(&stable_id) else {
continue;
};
// Build background facts with KnowsOf confidence
let facts: Vec<(FactId, FactKnowledge)> = info
.knows
.iter()
.map(|fact_name| {
(
FactId(fact_name.clone()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
},
)
})
.collect();
let fact_count = facts.len();
let knowledge = KnowledgeGraph::with_background(facts);
world.entity_mut(entity).insert(knowledge);
tracing::debug!(
"Populated {} background facts for NPC: {}",
fact_count,
profile.canonical_id
);
}
}
/// Axis 3: Populate per-entity Relationships components and the global RelationshipGraph.
///
/// Two-pass resolution: canonical_id targets are resolved to StableIds using
/// the npc_ids map from Phase 1. Unresolvable targets are logged and skipped.
fn resolve_relationships(
world: &mut World,
content: &DistrictContent,
npc_ids: &BTreeMap<String, StableId>,
) {
// Collect all relationship data first, then apply to world.
// (Avoids holding immutable + mutable borrows on world simultaneously.)
struct ResolvedRelationship {
subject_entity: Entity,
subject_stable_id: StableId,
entries: Vec<npc::Relationship>,
}
let registry = world.resource::<EntityRegistry>();
let mut resolved = Vec::new();
for profile in &content.npc_profiles {
if profile.relationships.is_empty() {
continue;
}
let Some(&subject_stable_id) = npc_ids.get(&profile.canonical_id) else {
continue;
};
let Some(subject_entity) = registry.to_entity(&subject_stable_id) else {
continue;
};
let mut entries = Vec::new();
for rel in &profile.relationships {
let Some(&target_stable_id) = npc_ids.get(&rel.target) else {
tracing::debug!(
"Skipping relationship {}->{}: target not in npc_ids map",
profile.canonical_id,
rel.target
);
continue;
};
let kind = parse_relationship_kind(&rel.kind);
let trust_level = rel.trust.unwrap_or(0).clamp(-10, 10) as i8;
entries.push(npc::Relationship {
target_id: target_stable_id,
kind,
trust_level,
history: Vec::new(),
});
}
if !entries.is_empty() {
resolved.push(ResolvedRelationship {
subject_entity,
subject_stable_id,
entries,
});
}
}
// Apply: insert per-entity Relationships component + populate global RelationshipGraph
let rel_count: usize = resolved.iter().map(|r| r.entries.len()).sum();
for r in &resolved {
world
.entity_mut(r.subject_entity)
.insert(npc::Relationships {
entries: r.entries.clone(),
});
}
// Populate global RelationshipGraph resource
let mut graph = world
.get_resource::<npc::relationships::RelationshipGraph>()
.cloned()
.unwrap_or_default();
for r in &resolved {
for entry in &r.entries {
graph.set_relationship(
r.subject_stable_id,
entry.target_id,
npc::relationships::RelationshipEdge {
kind: entry.kind,
trust: entry.trust_level,
history: Vec::new(),
last_interaction_tick: 0,
},
);
}
}
world.insert_resource(graph);
tracing::debug!(
"Resolved {} relationship edges across {} NPCs",
rel_count,
resolved.len()
);
}
/// Axis 5: Populate DailyRoutine from routines/schedules.yaml.
///
/// Joins schedule entries (npc canonical_id → phase + location + tile) with
/// spawned NPC entities. Location strings are mapped to TilePosition via
/// tile coordinates when available; defaults to (0,0,0) otherwise.
/// Deviations are logged and skipped for v0.1 (v0.2 scope per Tyre).
fn resolve_routines(
world: &mut World,
content: &DistrictContent,
npc_ids: &BTreeMap<String, StableId>,
) {
let Some(routine_file) = &content.routines else {
tracing::debug!("No routine schedules loaded for district — skipping");
return;
};
// Collect all resolved routines first (avoids borrow conflict with world).
struct ResolvedRoutine {
entity: Entity,
routine: npc::DailyRoutine,
npc_id: String,
deviation_count: usize,
}
let mut resolved = Vec::new();
for schedule in &routine_file.schedules {
let Some(&stable_id) = npc_ids.get(&schedule.npc) else {
tracing::debug!("Skipping routine for {}: not in npc_ids map", schedule.npc);
continue;
};
let Some(entity) = world.resource::<EntityRegistry>().to_entity(&stable_id) else {
continue;
};
let mut entries = Vec::new();
for entry in &schedule.entries {
let Some(phase) = parse_day_phase(&entry.phase) else {
tracing::warn!(
"Unknown day phase '{}' in routine for {}",
entry.phase,
schedule.npc
);
continue;
};
// Use explicit tile coordinates if provided, otherwise default position
let location = entry
.tile
.as_ref()
.map(|t| TilePosition::new(t.x, t.y, 0))
.unwrap_or_else(|| TilePosition::new(0, 0, 0));
entries.push(npc::RoutineEntry {
phase,
location,
activity: entry
.activity
.clone()
.unwrap_or_else(|| entry.location.clone()),
});
}
if !entries.is_empty() {
resolved.push(ResolvedRoutine {
entity,
routine: npc::DailyRoutine {
entries,
description: String::new(),
},
npc_id: schedule.npc.clone(),
deviation_count: schedule.deviations.len(),
});
}
}
// Apply routines to world
let routines_resolved = resolved.len();
for r in resolved {
world.entity_mut(r.entity).insert(r.routine);
if r.deviation_count > 0 {
tracing::debug!(
"Skipping {} deviations for {} (v0.2 scope)",
r.deviation_count,
r.npc_id
);
}
}
tracing::debug!("Resolved routines for {} NPCs", routines_resolved);
}
// ---------------------------------------------------------------------------
// Content value → ECS enum mapping functions
// ---------------------------------------------------------------------------
/// Parse a want.primary string into a WantKind enum value.
///
/// Content files must use exact WantKind enum names (case-insensitive):
/// Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge.
/// Narrative descriptions belong in want.description, not want.primary.
fn parse_want_kind(s: &str) -> Option<npc::WantKind> {
match s.to_lowercase().as_str() {
"wealth" => Some(npc::WantKind::Wealth),
"safety" => Some(npc::WantKind::Safety),
"knowledge" => Some(npc::WantKind::Knowledge),
"connection" => Some(npc::WantKind::Connection),
"power" => Some(npc::WantKind::Power),
"freedom" => Some(npc::WantKind::Freedom),
"justice" => Some(npc::WantKind::Justice),
"revenge" => Some(npc::WantKind::Revenge),
"happiness" => Some(npc::WantKind::Happiness),
other => {
tracing::warn!("Unknown WantKind '{}': must be one of Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness", other);
None
}
}
}
fn parse_relationship_kind(s: &str) -> npc::RelationshipKind {
match s.to_lowercase().as_str() {
"colleague" => npc::RelationshipKind::Colleague,
"friend" => npc::RelationshipKind::Friend,
"rival" => npc::RelationshipKind::Rival,
"romantic" => npc::RelationshipKind::Romantic,
"family" => npc::RelationshipKind::Family,
"superior" => npc::RelationshipKind::Superior,
"subordinate" => npc::RelationshipKind::Subordinate,
other => {
tracing::warn!(
"Unknown relationship kind '{}', defaulting to Colleague",
other
);
npc::RelationshipKind::Colleague
}
}
}
fn parse_personality_trait(s: &str) -> Option<npc::PersonalityTrait> {
match s.to_lowercase().as_str() {
"cautious" => Some(npc::PersonalityTrait::Cautious),
"bold" => Some(npc::PersonalityTrait::Bold),
"honest" => Some(npc::PersonalityTrait::Honest),
"deceptive" => Some(npc::PersonalityTrait::Deceptive),
"compassionate" => Some(npc::PersonalityTrait::Compassionate),
"ruthless" => Some(npc::PersonalityTrait::Ruthless),
"curious" => Some(npc::PersonalityTrait::Curious),
"incurious" => Some(npc::PersonalityTrait::Incurious),
"social" => Some(npc::PersonalityTrait::Social),
"reclusive" => Some(npc::PersonalityTrait::Reclusive),
_ => None,
}
}
fn parse_tell_trigger(s: &str) -> npc::TellTrigger {
match s.to_lowercase().as_str() {
"stress_above_threshold" | "stressed" => npc::TellTrigger::StressAboveThreshold,
"always" => npc::TellTrigger::Always,
other => {
// Could be a phase-based or activity-based trigger
if let Some(phase) = parse_day_phase(other) {
npc::TellTrigger::TimeOfDay(phase)
} else {
npc::TellTrigger::DuringActivity(other.to_string())
}
}
}
}
fn parse_skill(s: &str) -> Option<npc::Skill> {
match s.to_lowercase().as_str() {
"combat" => Some(npc::Skill::Combat),
"intimidation" => Some(npc::Skill::Intimidation),
"medical" => Some(npc::Skill::Medical),
"observation" => Some(npc::Skill::Observation),
"persuasion" => Some(npc::Skill::Persuasion),
"piloting" => Some(npc::Skill::Piloting),
"stealth" => Some(npc::Skill::Stealth),
"technical" => Some(npc::Skill::Technical),
_ => None,
}
}
/// Infer secret severity from the narrative description.
///
/// Keyword-based heuristic: secrets mentioning criminal activity, violence,
/// or life-threatening stakes are Major; those mentioning career/exposure are
/// Moderate; mild social secrets are Minor. Default: Moderate.
fn parse_secret_severity(description: &str) -> npc::SecretSeverity {
let lower = description.to_lowercase();
// Major: criminal, life-threatening, ring coordination, smuggling operations
if lower.contains("ring coordinator")
|| lower.contains("smuggling operation")
|| lower.contains("violently")
|| lower.contains("killed")
|| lower.contains("criminal")
|| lower.contains("security breach")
{
return npc::SecretSeverity::Major;
}
// Minor: social embarrassment, mild secrets, ambiguous situations
if lower.contains("ambiguous") || lower.contains("embarrassment") || lower.contains("gossip") {
return npc::SecretSeverity::Minor;
}
// Default: Moderate (career-threatening, covers most v0.1 secrets)
npc::SecretSeverity::Moderate
}
/// Parse a day phase string into the DayPhase enum.
pub fn parse_day_phase(s: &str) -> Option<DayPhase> {
match s.to_lowercase().as_str() {
"morning" => Some(DayPhase::Morning),
"afternoon" => Some(DayPhase::Afternoon),
"evening" => Some(DayPhase::Evening),
"night" => Some(DayPhase::Night),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::types::*;
fn create_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
fn create_test_profile() -> NpcProfile {
NpcProfile {
canonical_id: "test-npc".to_string(),
display_name: "Test NPC".to_string(),
tier: 1,
pattern: Some("FRIEND".to_string()),
motivation: Some("HANDLER".to_string()),
description: Some("A test NPC".to_string()),
want: Some(NpcWant {
primary: "Wealth".to_string(),
intensity: Some(7),
description: Some("Wants money".to_string()),
}),
secret: None,
relationships: vec![],
tolerance: Some(NpcTolerance {
threshold: Some(60),
description: None,
}),
routine: None,
information: None,
contentment: Some(NpcContentment {
level: Some(25),
description: None,
}),
personality: Some({
let mut m = BTreeMap::new();
m.insert("cautious".to_string(), "Very careful".to_string());
m.insert("deceptive".to_string(), "Good liar".to_string());
m
}),
tells: vec![NpcTell {
trigger: "stressed".to_string(),
behavior: "Taps fingers on table".to_string(),
visible_to: None,
}],
skills: Some(NpcSkills {
combat_trained: Some(false),
skills: Some({
let mut m = BTreeMap::new();
m.insert("persuasion".to_string(), 6);
m.insert("stealth".to_string(), 4);
m
}),
}),
triangle_membership: vec!["hub-power".to_string()],
trust_levels: None,
friend_arc: None,
dual_lens: None,
}
}
#[test]
fn spawn_npc_creates_entity_with_components() {
let mut world = create_test_world();
let profile = create_test_profile();
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
assert_eq!(result.npcs_spawned, 1);
assert!(result.npc_ids.contains_key("test-npc"));
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
// Verify components
assert!(world.get::<npc::Npc>(entity).is_some());
assert!(world.get::<TilePosition>(entity).is_some());
let want = world.get::<npc::Want>(entity).unwrap();
assert_eq!(want.primary, npc::WantKind::Wealth);
assert_eq!(want.intensity, 7);
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
assert_eq!(tolerance.threshold, 60);
let contentment = world.get::<npc::Contentment>(entity).unwrap();
assert_eq!(contentment.level, 25);
let personality = world.get::<npc::PersonalityTraits>(entity).unwrap();
assert_eq!(personality.traits.len(), 2);
let tells = world.get::<npc::TellSystem>(entity).unwrap();
assert_eq!(tells.tells.len(), 1);
assert_eq!(
tells.tells[0].trigger,
npc::TellTrigger::StressAboveThreshold
);
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert_eq!(skills.skills.len(), 2);
assert!(!skills.combat_trained);
}
#[test]
fn spawn_npc_attaches_content_slug() {
let mut world = create_test_world();
let profile = create_test_profile();
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
let slug = world
.get::<ContentSlug>(entity)
.expect("ContentSlug should be attached during spawn");
assert_eq!(slug.0, "test-npc");
}
#[test]
fn spawn_npc_minimal_profile() {
let mut world = create_test_world();
let profile = NpcProfile {
canonical_id: "minimal".to_string(),
display_name: "Minimal NPC".to_string(),
tier: 3,
pattern: None,
motivation: None,
description: None,
want: None,
secret: None,
relationships: vec![],
tolerance: None,
routine: None,
information: None,
contentment: None,
personality: None,
tells: vec![],
skills: None,
triangle_membership: vec![],
trust_levels: None,
friend_arc: None,
dual_lens: None,
};
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
assert_eq!(result.npcs_spawned, 1);
let stable_id = result.npc_ids["minimal"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
// Only Npc marker and TilePosition should be present
assert!(world.get::<npc::Npc>(entity).is_some());
assert!(world.get::<TilePosition>(entity).is_some());
assert!(world.get::<npc::Want>(entity).is_none());
assert!(world.get::<npc::ToleranceThreshold>(entity).is_none());
}
#[test]
fn parse_want_kinds() {
// Exact enum names (case-insensitive)
assert_eq!(parse_want_kind("Wealth"), Some(npc::WantKind::Wealth));
assert_eq!(parse_want_kind("safety"), Some(npc::WantKind::Safety));
assert_eq!(parse_want_kind("KNOWLEDGE"), Some(npc::WantKind::Knowledge));
assert_eq!(
parse_want_kind("Connection"),
Some(npc::WantKind::Connection)
);
assert_eq!(parse_want_kind("Power"), Some(npc::WantKind::Power));
assert_eq!(parse_want_kind("Freedom"), Some(npc::WantKind::Freedom));
assert_eq!(parse_want_kind("Justice"), Some(npc::WantKind::Justice));
assert_eq!(parse_want_kind("Revenge"), Some(npc::WantKind::Revenge));
assert_eq!(parse_want_kind("Happiness"), Some(npc::WantKind::Happiness));
// Narrative strings are NOT matched — content must use exact enum keywords
assert_eq!(parse_want_kind("operational stability"), None);
assert_eq!(parse_want_kind("control"), None);
assert_eq!(parse_want_kind("answers"), None);
// Unknown
assert_eq!(parse_want_kind("bogus"), None);
}
#[test]
fn parse_day_phases() {
assert_eq!(parse_day_phase("morning"), Some(DayPhase::Morning));
assert_eq!(parse_day_phase("Evening"), Some(DayPhase::Evening));
assert_eq!(parse_day_phase("unknown"), None);
}
#[test]
fn invalid_want_kind_drops_component() {
let mut world = create_test_world();
let mut profile = create_test_profile();
profile.want = Some(NpcWant {
primary: "nonexistent_desire".to_string(),
intensity: Some(5),
description: None,
});
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
// NPC should still spawn
assert_eq!(result.npcs_spawned, 1);
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
// But Want component should NOT be attached (invalid kind was dropped)
assert!(world.get::<npc::Npc>(entity).is_some());
assert!(world.get::<npc::Want>(entity).is_none());
}
#[test]
fn resolve_information_populates_knowledge_graph() {
let mut world = create_test_world();
// Spawn an NPC with information.knows
let mut profile = create_test_profile();
profile.information = Some(NpcInformation {
knows: vec![
"contraband.ring_exists".to_string(),
"relationship.kael_trust".to_string(),
],
access_tier: Some("insider".to_string()),
});
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
// Run Phase 2 information resolution
let district = crate::content::loader::DistrictContent {
npc_profiles: vec![profile],
..Default::default()
};
resolve_information(&mut world, &district, &result.npc_ids);
// Verify KnowledgeGraph was attached with background facts
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
let kg = world
.get::<KnowledgeGraph>(entity)
.expect("KnowledgeGraph should be attached");
assert!(kg.knows_fact(&FactId("contraband.ring_exists".to_string())));
assert!(kg.knows_fact(&FactId("relationship.kael_trust".to_string())));
assert!(kg.fact_at_least(
&FactId("contraband.ring_exists".to_string()),
KnowledgeConfidence::KnowsOf
));
}
#[test]
fn two_phase_spawn_via_spawn_content() {
let mut world = create_test_world();
let mut profile = create_test_profile();
profile.information = Some(NpcInformation {
knows: vec!["investigation.inspection_lapses".to_string()],
access_tier: None,
});
let mut store = ContentStore::default();
let mut district = crate::content::loader::DistrictContent::default();
district.npc_profiles.push(profile);
store.districts.insert("test".to_string(), district);
let result = spawn_content(&mut world, &store);
assert_eq!(result.npcs_spawned, 1);
// Verify Phase 2 ran: KnowledgeGraph should be present
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
let kg = world
.get::<KnowledgeGraph>(entity)
.expect("Phase 2 should attach KnowledgeGraph");
assert!(kg.knows_fact(&FactId("investigation.inspection_lapses".to_string())));
}
#[test]
fn spawn_npc_with_secret() {
let mut world = create_test_world();
let mut profile = create_test_profile();
profile.secret = Some("Takes a cut from the smuggling operation".to_string());
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
let secret = world
.get::<npc::Secret>(entity)
.expect("Secret should be attached");
assert!(secret.description.contains("smuggling operation"));
assert_eq!(secret.severity, npc::SecretSeverity::Major);
assert!(secret.known_by.is_empty());
}
#[test]
fn parse_secret_severity_keywords() {
assert_eq!(
parse_secret_severity("Ring coordinator who sets volume targets"),
npc::SecretSeverity::Major
);
assert_eq!(
parse_secret_severity("Handles cargo routing for the smuggling operation"),
npc::SecretSeverity::Major
);
assert_eq!(
parse_secret_severity("Security breach — unauthorized contact"),
npc::SecretSeverity::Major
);
assert_eq!(
parse_secret_severity("Spending is deliberately ambiguous"),
npc::SecretSeverity::Minor
);
assert_eq!(
parse_secret_severity("Gambling debt and compromised inspections"),
npc::SecretSeverity::Moderate
);
}
#[test]
fn parse_relationship_kinds() {
assert_eq!(
parse_relationship_kind("friend"),
npc::RelationshipKind::Friend
);
assert_eq!(
parse_relationship_kind("colleague"),
npc::RelationshipKind::Colleague
);
assert_eq!(
parse_relationship_kind("family"),
npc::RelationshipKind::Family
);
assert_eq!(
parse_relationship_kind("romantic"),
npc::RelationshipKind::Romantic
);
assert_eq!(
parse_relationship_kind("superior"),
npc::RelationshipKind::Superior
);
assert_eq!(
parse_relationship_kind("subordinate"),
npc::RelationshipKind::Subordinate
);
assert_eq!(
parse_relationship_kind("rival"),
npc::RelationshipKind::Rival
);
// Unknown defaults to Colleague
assert_eq!(
parse_relationship_kind("acquaintance"),
npc::RelationshipKind::Colleague
);
}
#[test]
fn resolve_relationships_populates_components_and_graph() {
let mut world = create_test_world();
world.init_resource::<npc::relationships::RelationshipGraph>();
// Spawn two NPCs
let mut profile_a = create_test_profile();
profile_a.canonical_id = "npc-a".to_string();
profile_a.relationships = vec![NpcRelationship {
target: "npc-b".to_string(),
kind: "friend".to_string(),
trust: Some(7),
notes: None,
}];
let mut profile_b = create_test_profile();
profile_b.canonical_id = "npc-b".to_string();
profile_b.relationships = vec![NpcRelationship {
target: "npc-a".to_string(),
kind: "colleague".to_string(),
trust: Some(3),
notes: None,
}];
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile_a, &mut result);
spawn_npc(&mut world, &profile_b, &mut result);
// Run Phase 2 relationship resolution
let district = crate::content::loader::DistrictContent {
npc_profiles: vec![profile_a, profile_b],
..Default::default()
};
resolve_relationships(&mut world, &district, &result.npc_ids);
// Verify per-entity Relationships components
let entity_a = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc-a"])
.unwrap();
let rels_a = world.get::<npc::Relationships>(entity_a).unwrap();
assert_eq!(rels_a.entries.len(), 1);
assert_eq!(rels_a.entries[0].kind, npc::RelationshipKind::Friend);
assert_eq!(rels_a.entries[0].trust_level, 7);
let entity_b = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc-b"])
.unwrap();
let rels_b = world.get::<npc::Relationships>(entity_b).unwrap();
assert_eq!(rels_b.entries.len(), 1);
assert_eq!(rels_b.entries[0].kind, npc::RelationshipKind::Colleague);
// Verify global RelationshipGraph
let graph = world.resource::<npc::relationships::RelationshipGraph>();
assert_eq!(graph.edge_count(), 2);
let edge = graph
.get_relationship(&result.npc_ids["npc-a"], &result.npc_ids["npc-b"])
.unwrap();
assert_eq!(edge.kind, npc::RelationshipKind::Friend);
assert_eq!(edge.trust, 7);
}
#[test]
fn resolve_relationships_skips_unknown_targets() {
let mut world = create_test_world();
world.init_resource::<npc::relationships::RelationshipGraph>();
let mut profile = create_test_profile();
profile.relationships = vec![NpcRelationship {
target: "nonexistent-npc".to_string(),
kind: "friend".to_string(),
trust: Some(5),
notes: None,
}];
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let district = crate::content::loader::DistrictContent {
npc_profiles: vec![profile],
..Default::default()
};
resolve_relationships(&mut world, &district, &result.npc_ids);
// No Relationships component should be attached (all targets unresolvable)
let entity = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["test-npc"])
.unwrap();
assert!(world.get::<npc::Relationships>(entity).is_none());
}
}