//! 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::content_registry::ContentEntityRegistry; 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::tier::ActiveSim; 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, } /// 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)); // All spawned NPCs start in the Active tier (D-026, #94). // The tier transition system (#99) will demote NPCs that are far from the player. entity_commands.insert(ActiveSim); // Mark NPC as interactable for proximity-based verb detection (#413) entity_commands.insert(Interactable); // Interaction history — drives Layer 2 situation activation (#325, D-028) entity_commands.insert(npc::interaction::InteractionMemory::default()); // Mood state — drives Layer 4 dialogue selection and monologue tone (#323) entity_commands.insert(npc::mood::MoodState::default()); // 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` — 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 = 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 = 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. // Vision + awareness components (#115, #244) — must match generate_npc(). // Without these, vision/awareness systems silently skip content-spawned NPCs. entity_commands.insert(( npc::vision::NpcVisionState::default(), npc::vision::NpcMemory::default(), npc::awareness::PlayerAwareness::default(), )); let entity = entity_commands.id(); // Register in EntityRegistry for StableId mapping let stable_id = world.resource_mut::().register(entity); world.entity_mut(entity).insert(( StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone()), )); // Register in ContentEntityRegistry so KnowledgeGrant::Entity can resolve entity_ref strings // (D-079: ContentEntityRegistry populated at NPC spawn time) world .resource_mut::() .register(profile.canonical_id.clone(), stable_id); 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, ) { 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::().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, disclosure_blocked: false, }, ) }) .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, ) { // 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, } let registry = world.resource::(); 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::() .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, ) { 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::().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 { 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 { 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 { 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 { 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::(); world.init_resource::(); 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::() .to_entity(&stable_id) .unwrap(); // Verify components assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); let want = world.get::(entity).unwrap(); assert_eq!(want.primary, npc::WantKind::Wealth); assert_eq!(want.intensity, 7); let tolerance = world.get::(entity).unwrap(); assert_eq!(tolerance.threshold, 60); let contentment = world.get::(entity).unwrap(); assert_eq!(contentment.level, 25); let personality = world.get::(entity).unwrap(); assert_eq!(personality.traits.len(), 2); let tells = world.get::(entity).unwrap(); assert_eq!(tells.tells.len(), 1); assert_eq!( tells.tells[0].trigger, npc::TellTrigger::StressAboveThreshold ); let skills = world.get::(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::() .to_entity(&stable_id) .unwrap(); let slug = world .get::(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::() .to_entity(&stable_id) .unwrap(); // Only Npc marker and TilePosition should be present assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_none()); assert!(world.get::(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::() .to_entity(&stable_id) .unwrap(); // But Want component should NOT be attached (invalid kind was dropped) assert!(world.get::(entity).is_some()); assert!(world.get::(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::() .to_entity(&stable_id) .unwrap(); let kg = world .get::(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::() .to_entity(&stable_id) .unwrap(); let kg = world .get::(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::() .to_entity(&stable_id) .unwrap(); let secret = world .get::(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::(); // 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::() .to_entity(&result.npc_ids["npc-a"]) .unwrap(); let rels_a = world.get::(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::() .to_entity(&result.npc_ids["npc-b"]) .unwrap(); let rels_b = world.get::(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::(); 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] #[test] fn spawn_npc_combat_trained_sets_skill_flag() { // #91: combat_trained: true in YAML sets SkillSet.combat_trained = true. // Known gap: CombatCapability is NOT yet attached for content-spawned NPCs // (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural // path (generate.rs) correctly attaches CombatCapability. This test documents // current behavior so the gap is visible in CI. let mut world = create_test_world(); let mut profile = create_test_profile(); profile.skills = Some(NpcSkills { combat_trained: Some(true), skills: Some({ let mut m = std::collections::BTreeMap::new(); m.insert("combat".to_string(), 7); m }), }); let mut result = SpawnResult::default(); spawn_npc(&mut world, &profile, &mut result); let entity = world .resource::() .to_entity(&result.npc_ids["test-npc"]) .unwrap(); // SkillSet.combat_trained is correctly set from YAML (#91 — done) let skills = world.get::(entity).unwrap(); assert!( skills.combat_trained, "SkillSet.combat_trained should be true when YAML sets combat_trained: true" ); // Known gap: CombatCapability not yet attached in content-spawn path. // The procedural path (generate.rs) does attach it — content path has TODO. // Update this assertion when the TODO is resolved. assert!( world.get::(entity).is_none(), "CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)" ); } #[test] fn resolve_relationships_skips_unknown_targets() { let mut world = create_test_world(); world.init_resource::(); 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::() .to_entity(&result.npc_ids["test-npc"]) .unwrap(); assert!(world.get::(entity).is_none()); } }