diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 179fe4823..dd150c73e 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -20,6 +20,8 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use std::path::PathBuf; +use crate::knowledge::ContentEntityRegistry; + /// Configuration for the content loader. /// Set the content root path before adding ContentPlugin. #[derive(Resource, Debug, Clone)] @@ -52,6 +54,10 @@ impl Plugin for ContentPlugin { app.insert_resource(ContentConfig::default()); } + // ContentEntityRegistry is required by spawn_npc (D-079). + // Init here so ContentPlugin works standalone without KnowledgePlugin. + app.init_resource::(); + app.add_systems(Startup, load_and_spawn_content); app.add_systems(PostUpdate, hot_reload::hot_reload_content); diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 9e0c5e75e..43d6fe09f 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -22,6 +22,7 @@ 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::{ @@ -213,6 +214,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR 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); @@ -296,6 +303,7 @@ fn resolve_information( source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ) }) @@ -648,6 +656,7 @@ mod tests { fn create_test_world() -> World { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 0d67cc99e..ab9d687c2 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -491,10 +491,30 @@ pub struct DialogueLine { pub knowledge_grant: Option, } +/// Knowledge grant attached to a dialogue line (D-079). +/// +/// Untagged enum — serde tries each variant in order: +/// `Fact` matches YAML with `fact_id` field. +/// `Entity` matches YAML with `entity_ref` field. +/// `Compound` variant deferred to Sprint 18. #[derive(Debug, Clone, Deserialize)] -pub struct KnowledgeGrant { - pub fact_id: String, - pub confidence: String, +#[serde(untagged)] +pub enum KnowledgeGrant { + /// Grant knowledge of a non-entity fact. + /// Format: fact_id "category.topic", confidence string. + Fact { + fact_id: String, + confidence: String, + }, + /// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG). + /// Required for contradiction detection: testimony must create ToldBy EntityKnowledge + /// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083). + Entity { + entity_ref: String, + #[serde(default)] + attributes: BTreeMap, + confidence: String, + }, } // --------------------------------------------------------------------------- diff --git a/server/src/knowledge/content_registry.rs b/server/src/knowledge/content_registry.rs new file mode 100644 index 000000000..03017414c --- /dev/null +++ b/server/src/knowledge/content_registry.rs @@ -0,0 +1,86 @@ +//! ContentEntityRegistry resource (D-079). +//! +//! Maps NPC canonical_id strings (e.g., "kael-davan") to their runtime StableIds. +//! Populated at NPC spawn time; queried at KnowledgeGrant processing time to +//! resolve `entity_ref` strings in `KnowledgeGrant::Entity` variants. +//! +//! BTreeMap for deterministic iteration (D-010 principle 4). + +use bevy_ecs::prelude::*; +use std::collections::BTreeMap; + +use super::types::StableId; + +/// Content ID → StableId registry. +/// +/// Populated by `spawn_npc` for every authored NPC. Read by the +/// `KnowledgeGranted` event handler to resolve `entity_ref` strings at +/// grant processing time (D-079). +#[derive(Resource, Debug, Default)] +pub struct ContentEntityRegistry { + entries: BTreeMap, +} + +impl ContentEntityRegistry { + /// Register a content_id → StableId mapping. + /// + /// Idempotent for the same (content_id, stable_id) pair. + /// If the same content_id is registered twice with different StableIds, + /// the latest call wins (last-write semantics; warn in caller if this is unexpected). + pub fn register(&mut self, content_id: impl Into, stable_id: StableId) { + self.entries.insert(content_id.into(), stable_id); + } + + /// Resolve a content_id string to a StableId. + /// + /// Returns `None` if the entity_ref is not registered. Callers should + /// emit `tracing::warn!` and drop the grant when `None` is returned. + pub fn resolve(&self, entity_ref: &str) -> Option { + self.entries.get(entity_ref).copied() + } + + /// Number of registered entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_and_resolve() { + let mut registry = ContentEntityRegistry::default(); + let sid = StableId(42); + registry.register("kael-davan", sid); + + assert_eq!(registry.resolve("kael-davan"), Some(sid)); + assert_eq!(registry.resolve("unknown-npc"), None); + assert_eq!(registry.len(), 1); + } + + #[test] + fn register_overwrites() { + let mut registry = ContentEntityRegistry::default(); + let sid_a = StableId(1); + let sid_b = StableId(2); + registry.register("npc-x", sid_a); + registry.register("npc-x", sid_b); + + assert_eq!(registry.resolve("npc-x"), Some(sid_b)); + assert_eq!(registry.len(), 1); + } + + #[test] + fn empty_registry() { + let registry = ContentEntityRegistry::default(); + assert!(registry.is_empty()); + assert_eq!(registry.resolve("any"), None); + } +} diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index 48615e68f..45b8a8982 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -4,6 +4,7 @@ //! KnowledgeEvents; the processing system drains them per tick. use bevy_ecs::prelude::*; +use std::collections::BTreeMap; use crate::simulation::movement::TilePosition; @@ -11,6 +12,41 @@ use super::graph::KnowledgeGraph; use super::registry::EntityRegistry; use super::types::*; +// --------------------------------------------------------------------------- +// Processed knowledge grant types (D-079) +// --------------------------------------------------------------------------- + +/// Processed Fact grant — confidence string parsed to typed enum at creation time. +/// Used in `KnowledgeEventType::KnowledgeGranted`. +#[derive(Debug, Clone)] +pub struct ProcessedFactGrant { + pub fact_id: FactId, + pub confidence: KnowledgeConfidence, +} + +/// Processed Entity grant — entity_ref resolved to StableId at creation time. +/// Used in `KnowledgeEventType::KnowledgeGranted`. +/// +/// Creates an `EntityKnowledge` entry in the observer's KG with `ToldBy` source, +/// enabling contradiction detection when a subsequent `DirectObservation` disagrees. +#[derive(Debug, Clone)] +pub struct ProcessedEntityGrant { + pub target_id: StableId, + pub attributes: BTreeMap, + pub confidence: KnowledgeConfidence, +} + +/// Typed knowledge grant payload — all string fields resolved at event creation. +#[derive(Debug, Clone)] +pub enum ProcessedKnowledgeGrant { + Fact(ProcessedFactGrant), + Entity(ProcessedEntityGrant), +} + +// --------------------------------------------------------------------------- +// Knowledge event types +// --------------------------------------------------------------------------- + /// Events that modify knowledge graphs. Produced by perception and /// other systems. Consumed by the knowledge update system. #[derive(Debug, Clone)] @@ -36,6 +72,16 @@ pub enum KnowledgeEventType { target: Entity, interaction_type: InteractionType, }, + /// Knowledge granted to observer via dialogue line selection (D-079). + /// + /// Fires at line selection time in `process_talk_interaction`. + /// Source is `ToldBy { source_id, tick }` for NPC testimony. + /// For Fact grants, the granting NPC's KG must contain the fact (guardrail enforced + /// at event creation time — event is only pushed if guardrail passes). + KnowledgeGranted { + grant: ProcessedKnowledgeGrant, + source: KnowledgeSource, + }, } /// Type of interaction for walk-away recording (D-064). @@ -57,6 +103,55 @@ pub struct KnowledgeEventQueue { pub(crate) events: Vec, } +// --------------------------------------------------------------------------- +// Contradiction detection output (D-083) +// --------------------------------------------------------------------------- + +/// Event emitted when `observe_entity()` detects a position contradiction +/// between a `ToldBy` source and a `DirectObservation`. +/// +/// Consumed by the monologue system (D-083 → monologue trigger) and +/// potentially the storyteller. One event per detected contradiction per tick. +/// +/// Display names are pre-resolved by `process_knowledge_events` via EntityRegistry +/// and NpcName, so downstream consumers (monologue) are pure string consumers. +#[derive(Debug, Clone)] +pub struct ContradictionDetectedEvent { + /// The observer who detected the contradiction. + pub observer: Entity, + /// The entity whose position was contradicted. + pub target: StableId, + /// Full contradiction details (who told what, where observed, when). + pub claim: ContradictionClaim, + /// Pre-resolved display name of the NPC who told the false position (told_by source). + pub source_display_name: String, + /// Pre-resolved display name of the entity whose position was contradicted (target). + pub subject_display_name: String, +} + +/// Resource: queue of contradictions detected this tick. +/// +/// Populated by `process_knowledge_events` when `observe_entity()` returns +/// a `ContradictionClaim`. Drained by downstream systems (monologue, storyteller). +#[derive(Resource, Default)] +pub struct ContradictionDetectedQueue { + events: Vec, +} + +impl ContradictionDetectedQueue { + pub fn push(&mut self, event: ContradictionDetectedEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + impl KnowledgeEventQueue { /// Push a knowledge event into the queue. pub fn push(&mut self, event: KnowledgeEvent) { @@ -82,9 +177,15 @@ impl KnowledgeEventQueue { /// System: process pending knowledge events. /// Runs once per tick, drains KnowledgeEventQueue and applies updates /// to the relevant KnowledgeGraph components. +/// +/// On contradiction detection (D-083): +/// - Shifts the ToldBy source entity to PersonOfInterest in the observer's KG. +/// - Pre-resolves display names for downstream monologue consumer. pub fn process_knowledge_events( mut queue: ResMut, + mut contradiction_queue: ResMut, registry: Res, + npc_names: Query<&crate::simulation::conversation::NpcName>, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { let events = queue.drain(); @@ -96,7 +197,59 @@ pub fn process_knowledge_events( match event.event_type { KnowledgeEventType::DirectObservation { target, position } => { if let Some(stable_id) = registry.to_stable(target) { - observer_kg.observe_entity(stable_id, position, event.tick); + if let Some(claim) = + observer_kg.observe_entity(stable_id, position, event.tick) + { + // StableId is Copy — capture before moving claim into event. + let told_by = claim.told_by; + + // Relationship shift (D-083): NPC who provided false info + // becomes PersonOfInterest in the observer's knowledge graph. + // Upsert: create a minimal entry if the source isn't yet known. + observer_kg + .entities + .entry(told_by) + .and_modify(|e| e.relationship = RelationshipState::PersonOfInterest) + .or_insert_with(|| EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: event.tick, + confidence: KnowledgeConfidence::Suspects, + source: KnowledgeSource::Inferred { basis: vec![] }, + state: KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }); + + // Pre-resolve display names for the monologue consumer. + let source_entity = registry.to_entity(&told_by); + let source_display_name = source_entity + .and_then(|e| npc_names.get(e).ok()) + .map(|n| n.0.clone()) + .unwrap_or_else(|| format!("#{}", told_by.0)); + let subject_display_name = npc_names + .get(target) + .ok() + .map(|n| n.0.clone()) + .unwrap_or_else(|| format!("#{}", stable_id.0)); + + tracing::info!( + observer = ?event.observer, + target = stable_id.0, + told_by = told_by.0, + source = source_display_name, + subject = subject_display_name, + "Contradiction detected: ToldBy position differs from direct observation (D-083)" + ); + contradiction_queue.push(ContradictionDetectedEvent { + observer: event.observer, + target: stable_id, + claim, + source_display_name, + subject_display_name, + }); + } } else { debug_assert!( false, @@ -135,6 +288,68 @@ pub fn process_knowledge_events( ); } } + KnowledgeEventType::KnowledgeGranted { grant, source } => { + match grant { + ProcessedKnowledgeGrant::Fact(fg) => { + let should_insert = observer_kg + .facts + .get(&fg.fact_id) + .map(|existing| fg.confidence > existing.confidence) + .unwrap_or(true); + if should_insert { + observer_kg.facts.insert( + fg.fact_id.clone(), + FactKnowledge { + confidence: fg.confidence, + source, + state: KnowledgeState::Active, + acquired_tick: event.tick, + disclosure_blocked: false, + }, + ); + tracing::debug!( + "KnowledgeGranted(Fact): {:?} at confidence {:?}, tick {}", + fg.fact_id, + fg.confidence, + event.tick, + ); + } + } + ProcessedKnowledgeGrant::Entity(eg) => { + // Insert or upgrade entity knowledge entry. + // Always use ToldBy source — entity grants come from NPC testimony. + let entry = observer_kg.entities.entry(eg.target_id).or_insert_with(|| { + EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: event.tick, + confidence: eg.confidence, + source: source.clone(), + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + } + }); + // Upgrade confidence and source if new grant is higher. + if eg.confidence > entry.confidence { + entry.confidence = eg.confidence; + entry.source = source; + entry.last_updated_tick = event.tick; + } + // Merge attributes (grant may supply partial attribute set). + for (k, v) in eg.attributes { + entry.known_attributes.insert(k, v); + } + tracing::debug!( + "KnowledgeGranted(Entity): StableId {:?} at confidence {:?}, tick {}", + eg.target_id, + eg.confidence, + event.tick, + ); + } + } + } } } } @@ -196,6 +411,7 @@ mod tests { let _ = observer_sid; // registered for completeness world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { @@ -236,6 +452,7 @@ mod tests { registry.register(observer); world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { @@ -261,6 +478,7 @@ mod tests { let mut world = World::new(); let registry = EntityRegistry::new(0); world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph let fake_target = world.spawn_empty().id(); @@ -339,4 +557,175 @@ mod tests { "decay should run on tick 10 and downgrade confidence" ); } + + #[test] + fn process_direct_observation_detects_contradiction() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target_ecs = world.spawn_empty().id(); + let target_sid = registry.register(target_ecs); + let informant_sid = StableId(999); + + // Observer has ToldBy knowledge: target at (10, 10) at tick 100 + let mut kg = KnowledgeGraph::new(); + kg.entities.insert( + target_sid, + EntityKnowledge { + last_known_position: Some(TilePosition::new(10, 10, 0)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant_sid, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let observer = world.spawn(kg).id(); + registry.register(observer); + world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); + + // Push DirectObservation at DIFFERENT position, within window + let mut queue = KnowledgeEventQueue::default(); + queue.push(KnowledgeEvent { + observer, + tick: 200, + event_type: KnowledgeEventType::DirectObservation { + target: target_ecs, + position: TilePosition::new(15, 10, 0), + }, + }); + world.insert_resource(queue); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + // Verify: KG entry is Contradicted + let kg = world.entity(observer).get::().unwrap(); + let entry = kg.entity_knowledge(&target_sid).unwrap(); + assert_eq!(entry.state, KnowledgeState::Contradicted); + assert!(entry.contradicted_claim.is_some()); + let claim = entry.contradicted_claim.as_ref().unwrap(); + assert_eq!(claim.told_by, informant_sid); + assert_eq!(claim.claimed_position, TilePosition::new(10, 10, 0)); + assert_eq!(claim.observed_position, TilePosition::new(15, 10, 0)); + + // Verify: ContradictionDetectedQueue has the event + let cq = world.resource::(); + assert_eq!(cq.events.len(), 1); + assert_eq!(cq.events[0].target, target_sid); + assert_eq!(cq.events[0].claim.told_by, informant_sid); + } + + #[test] + fn no_contradiction_event_when_position_matches() { + // DirectObservation at the SAME position as ToldBy: + // ContradictionDetectedQueue must stay empty. + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target_ecs = world.spawn_empty().id(); + let target_sid = registry.register(target_ecs); + let informant_sid = StableId(77); + + let mut kg = KnowledgeGraph::new(); + kg.entities.insert( + target_sid, + EntityKnowledge { + last_known_position: Some(TilePosition::new(10, 10, 0)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant_sid, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let observer = world.spawn(kg).id(); + registry.register(observer); + world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); + + // DirectObservation at the SAME position + let mut queue = KnowledgeEventQueue::default(); + queue.push(KnowledgeEvent { + observer, + tick: 200, + event_type: KnowledgeEventType::DirectObservation { + target: target_ecs, + position: TilePosition::new(10, 10, 0), // same position + }, + }); + world.insert_resource(queue); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + let cq = world.resource::(); + assert!( + cq.is_empty(), + "Matching position should not produce a contradiction event" + ); + let kg = world.entity(observer).get::().unwrap(); + let entry = kg.entity_knowledge(&target_sid).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + } + + #[test] + fn contradiction_detected_queue_drains_correctly() { + // ContradictionDetectedQueue.drain() should empty the queue + // and return all accumulated events. + let mut queue = ContradictionDetectedQueue::default(); + assert!(queue.is_empty()); + + let mut world = World::new(); + let e = world.spawn_empty().id(); + + queue.push(ContradictionDetectedEvent { + observer: e, + target: StableId(1), + claim: ContradictionClaim { + told_by: StableId(99), + told_tick: 50, + claimed_position: TilePosition::new(1, 1, 0), + observed_position: TilePosition::new(5, 5, 0), + detected_tick: 100, + }, + source_display_name: "Sera".to_string(), + subject_display_name: "Kael".to_string(), + }); + queue.push(ContradictionDetectedEvent { + observer: e, + target: StableId(2), + claim: ContradictionClaim { + told_by: StableId(88), + told_tick: 60, + claimed_position: TilePosition::new(2, 2, 0), + observed_position: TilePosition::new(6, 6, 0), + detected_tick: 100, + }, + source_display_name: "NPC_88".to_string(), + subject_display_name: "NPC_2".to_string(), + }); + + assert!(!queue.is_empty()); + let drained = queue.drain(); + assert_eq!(drained.len(), 2); + assert!(queue.is_empty(), "Queue should be empty after drain"); + } } diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index 77e725155..5b9cf8bb9 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -108,7 +108,46 @@ impl KnowledgeGraph { // --- Write Operations --- /// Record a direct observation of another entity (entity is in LOS). - pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) { + /// + /// Returns `Some(ContradictionClaim)` if a position contradiction was + /// detected against a recent `ToldBy` source (D-083). The caller should + /// push a `ContradictionDetected` event when this returns `Some`. + pub fn observe_entity( + &mut self, + target: StableId, + position: TilePosition, + tick: u64, + ) -> Option { + // --- Pre-overwrite contradiction check (D-083) --- + // + // If the existing entry has a ToldBy source with a different position, + // and the told-tick is within CONTRADICTION_WINDOW_TICKS of now, + // this is a contradiction: someone lied or was wrong about where + // this entity would be. + let contradiction = self.entities.get(&target).and_then(|existing| { + if let KnowledgeSource::ToldBy { + source_id, + tick: told_tick, + } = &existing.source + { + let age = tick.saturating_sub(*told_tick); + let claimed_pos = existing.last_known_position?; + if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position { + Some(ContradictionClaim { + told_by: *source_id, + told_tick: *told_tick, + claimed_position: claimed_pos, + observed_position: position, + detected_tick: tick, + }) + } else { + None + } + } else { + None + } + }); + let entry = self .entities .entry(target) @@ -121,18 +160,25 @@ impl KnowledgeGraph { state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), + contradicted_claim: None, }); entry.last_known_position = Some(position); entry.last_observed_tick = tick; entry.last_updated_tick = tick; entry.confidence = KnowledgeConfidence::Direct; entry.source = KnowledgeSource::DirectObservation { tick }; - // Stale entries become Active again on fresh observation. - // Contradicted entries stay Contradicted even if you're looking - // at the entity right now — the contradiction is still unresolved. - if entry.state == KnowledgeState::Stale { + + if contradiction.is_some() { + entry.state = KnowledgeState::Contradicted; + entry.contradicted_claim = contradiction.clone(); + } else if entry.state == KnowledgeState::Stale { + // Stale entries become Active again on fresh observation. entry.state = KnowledgeState::Active; } + // Contradicted entries without a new contradiction stay Contradicted — + // the previous contradiction is still unresolved. + + contradiction } /// Entity has left the observer's LOS. Downgrade from Direct. @@ -168,6 +214,7 @@ impl KnowledgeGraph { state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), + contradicted_claim: None, }); let type_str = match interaction_type { @@ -387,6 +434,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }; let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]); @@ -513,6 +561,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ), ( @@ -522,6 +571,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ), ]; @@ -626,6 +676,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, )]); @@ -648,6 +699,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, )]); @@ -746,4 +798,362 @@ mod tests { "FactionOnly must pass when observer knows the matching faction_id" ); } + + // --- Contradiction detection tests (D-083, #547) --- + + #[test] + fn contradiction_detected_when_told_by_position_differs() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Someone told us the target is at (10, 10) at tick 100 + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Direct observation at (15, 10) at tick 200 — within window (600 ticks) + let result = g.observe_entity(target, make_position(15, 10), 200); + + // Contradiction should be detected + assert!(result.is_some(), "Should detect contradiction"); + let claim = result.unwrap(); + assert_eq!(claim.told_by, informant); + assert_eq!(claim.told_tick, 100); + assert_eq!(claim.claimed_position, make_position(10, 10)); + assert_eq!(claim.observed_position, make_position(15, 10)); + assert_eq!(claim.detected_tick, 200); + + // Entry should be Contradicted + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Contradicted); + assert!(entry.contradicted_claim.is_some()); + // But confidence is upgraded to Direct (we're looking at them) + assert_eq!(entry.confidence, KnowledgeConfidence::Direct); + } + + #[test] + fn no_contradiction_when_told_by_position_matches() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Told target is at (10, 10) + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Observe at SAME position — no contradiction + let result = g.observe_entity(target, make_position(10, 10), 200); + assert!(result.is_none(), "Same position should not be a contradiction"); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + assert!(entry.contradicted_claim.is_none()); + } + + #[test] + fn no_contradiction_outside_time_window() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Told at tick 100, position (10, 10) + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Observe at different position BUT outside window (100 + 601 = 701) + let result = g.observe_entity(target, make_position(15, 10), 701); + assert!( + result.is_none(), + "Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction" + ); + } + + #[test] + fn contradiction_at_exact_window_boundary() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS) + let result = g.observe_entity(target, make_position(15, 10), 700); + assert!( + result.is_some(), + "Exactly at window boundary (age == 600) should still detect contradiction" + ); + } + + #[test] + fn no_contradiction_for_direct_observation_source() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + + // Previous knowledge from DirectObservation (not ToldBy) + g.observe_entity(target, make_position(10, 10), 100); + + // New observation at different position — NOT a contradiction + // (we just moved, or they moved; no one lied) + let result = g.observe_entity(target, make_position(15, 10), 200); + assert!( + result.is_none(), + "DirectObservation source should never trigger contradiction" + ); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + } + + #[test] + fn no_contradiction_for_background_source() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + + // Background knowledge with a position + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 0, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let result = g.observe_entity(target, make_position(15, 10), 100); + assert!( + result.is_none(), + "Background source should not trigger contradiction" + ); + } + + #[test] + fn no_contradiction_when_told_by_has_no_position() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // ToldBy but no position was claimed + g.entities.insert( + target, + EntityKnowledge { + last_known_position: None, // no position claimed + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let result = g.observe_entity(target, make_position(15, 10), 200); + assert!( + result.is_none(), + "ToldBy without position should not trigger contradiction" + ); + } + + #[test] + fn contradicted_claim_entry_field_matches_returned_claim() { + // Verify that entry.contradicted_claim is populated with identical + // data to the ContradictionClaim returned by observe_entity (D-083). + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(42); + + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(5, 5)), + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 50, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let returned = g + .observe_entity(target, make_position(12, 5), 300) + .expect("contradiction should be detected"); + + let entry = g.entity_knowledge(&target).unwrap(); + let stored = entry.contradicted_claim.as_ref().expect("field should be populated"); + + assert_eq!(stored.told_by, returned.told_by); + assert_eq!(stored.told_tick, returned.told_tick); + assert_eq!(stored.claimed_position, returned.claimed_position); + assert_eq!(stored.observed_position, returned.observed_position); + assert_eq!(stored.detected_tick, returned.detected_tick); + } + + #[test] + fn second_observation_keeps_contradicted_state_when_no_new_told_by() { + // After a contradiction is detected, subsequent DirectObservation + // does NOT clear the Contradicted state (D-083: "unresolved"). + let mut g = KnowledgeGraph::new(); + let target = StableId(7); + let informant = StableId(8); + + // Set up ToldBy knowledge + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(3, 3)), + last_observed_tick: 0, + last_updated_tick: 10, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 10, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // First observation: contradiction detected + let claim = g.observe_entity(target, make_position(9, 3), 200); + assert!(claim.is_some(), "contradiction should fire"); + assert_eq!( + g.entity_knowledge(&target).unwrap().state, + KnowledgeState::Contradicted + ); + + // Second observation (now source is DirectObservation, different position): + // state must stay Contradicted — contradiction is still unresolved. + let claim2 = g.observe_entity(target, make_position(11, 3), 300); + assert!(claim2.is_none(), "no new contradiction: DirectObservation source"); + assert_eq!( + g.entity_knowledge(&target).unwrap().state, + KnowledgeState::Contradicted, + "Contradicted state must persist until explicitly resolved" + ); + } + + #[test] + fn multiple_entities_only_told_by_one_contradicts() { + // Edge case: observer knows two entities. + // Entity A has ToldBy source, Entity B has DirectObservation. + // Only Entity A should produce a contradiction. + let mut g = KnowledgeGraph::new(); + let entity_a = StableId(10); + let entity_b = StableId(20); + let informant = StableId(99); + + // Entity A: ToldBy with position + g.entities.insert( + entity_a, + EntityKnowledge { + last_known_position: Some(make_position(1, 1)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Entity B: DirectObservation (no informant to lie) + g.observe_entity(entity_b, make_position(5, 5), 100); + + // Observe both at different positions at tick 200 + let a_result = g.observe_entity(entity_a, make_position(8, 1), 200); + let b_result = g.observe_entity(entity_b, make_position(9, 5), 200); + + assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict"); + assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict"); + assert_eq!( + g.entity_knowledge(&entity_a).unwrap().state, + KnowledgeState::Contradicted + ); + assert_eq!( + g.entity_knowledge(&entity_b).unwrap().state, + KnowledgeState::Active + ); + } } diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 2f9f8ffba..281e92ee0 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -7,12 +7,18 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; +pub mod content_registry; pub mod events; pub mod graph; pub mod registry; pub mod types; -pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +pub use content_registry::ContentEntityRegistry; +pub use events::{ + ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent, + KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant, + ProcessedKnowledgeGrant, +}; pub use graph::KnowledgeGraph; pub use registry::{EntityRegistry, StableEntityId}; pub use types::*; @@ -24,7 +30,9 @@ pub struct KnowledgePlugin; impl Plugin for KnowledgePlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_systems( Update, diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 77315be3d..76bed1018 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -54,6 +54,27 @@ const _: () = { assert!(KnowledgeConfidence::Direct as u8 == 3); }; +impl TryFrom<&str> for KnowledgeConfidence { + type Error = String; + + /// Parse a confidence string from YAML content into the typed enum. + /// + /// Case-insensitive. Accepts both camelCase and underscore/hyphen variants. + /// Used by KnowledgeGrant processing (D-079). + fn try_from(s: &str) -> Result { + match s.to_lowercase().as_str() { + "suspects" => Ok(Self::Suspects), + "knowsof" | "knows_of" | "knows-of" => Ok(Self::KnowsOf), + "knowsdetails" | "knows_details" | "knows-details" => Ok(Self::KnowsDetails), + "direct" => Ok(Self::Direct), + other => Err(format!( + "unknown confidence level '{}': expected one of suspects, knowsof, knowsdetails, direct", + other + )), + } + } +} + impl KnowledgeConfidence { /// Step down one confidence level (used by decay system). pub fn decayed(self) -> Self { @@ -146,6 +167,33 @@ impl RelationshipState { } } +// --- Contradiction Detection (D-083) --- + +/// Ticks within which a position discrepancy counts as a contradiction. +/// 600 ticks = 1 game-hour (at 10 tps per D-031). +/// Outside this window, stale ToldBy information is simply overwritten. +pub const CONTRADICTION_WINDOW_TICKS: u64 = 600; + +/// Records details of a detected contradiction on an EntityKnowledge entry. +/// +/// Populated when `observe_entity()` finds a position discrepancy with a +/// recent `ToldBy` source. Both the ToldBy entry and the DirectObservation +/// receive `Contradicted` state (epistemic neutrality — the engine does +/// not determine which is wrong). D-083, Q-026 resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContradictionClaim { + /// Who told us the (now-contradicted) information. + pub told_by: StableId, + /// Tick when the ToldBy information was received. + pub told_tick: u64, + /// Position the source claimed the entity was at. + pub claimed_position: TilePosition, + /// Position we directly observed the entity at. + pub observed_position: TilePosition, + /// Tick when the contradiction was detected. + pub detected_tick: u64, +} + // --- Entity Knowledge --- /// What entity A knows about entity B. @@ -169,6 +217,10 @@ pub struct EntityKnowledge { /// Known attributes of the target entity. /// Keys are structured (name, role, faction, etc.) pub known_attributes: BTreeMap, + /// Populated when a contradiction is detected between ToldBy and + /// DirectObservation sources (D-083). None when no contradiction exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contradicted_claim: Option, } /// Non-entity fact knowledge (locations, events, abstract knowledge). @@ -183,6 +235,10 @@ pub struct FactKnowledge { pub state: KnowledgeState, /// Tick when this fact was learned. pub acquired_tick: u64, + /// When true, this fact must not be transferred to other entities via NPC-to-NPC propagation. + /// D-080: models secrets whose sharing is existentially dangerous regardless of trust tier. + #[serde(default)] + pub disclosure_blocked: bool, } // --- Decay Configuration --- diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index b71ce5671..8ea8ef2f0 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -208,6 +208,7 @@ mod tests { world.insert_resource(WalkabilityMap::new(32, 32, 1)); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index c397cd166..af16f5259 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -505,6 +505,7 @@ fn knowledge_without_position_not_shown() { state: crate::knowledge::KnowledgeState::Active, relationship: RelationshipState::PersonOfInterest, known_attributes: std::collections::BTreeMap::new(), + contradicted_claim: None, }, ); @@ -800,6 +801,7 @@ fn phase2_no_confront_without_knows_details() { state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: std::collections::BTreeMap::new(), + contradicted_claim: None, }, ); @@ -2271,6 +2273,7 @@ fn access_rule_knowledge_gated_passes_with_matching_fact() { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ); diff --git a/server/src/simulation/contraband.rs b/server/src/simulation/contraband.rs index a772b6b45..24fda918c 100644 --- a/server/src/simulation/contraband.rs +++ b/server/src/simulation/contraband.rs @@ -127,6 +127,7 @@ pub fn check_contraband_scan( source: KnowledgeSource::DirectObservation { tick: time.tick }, state: KnowledgeState::Active, acquired_tick: time.tick, + disclosure_blocked: false, }, ); @@ -463,6 +464,7 @@ mod tests { source: KnowledgeSource::DirectObservation { tick: 0 }, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ); diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 0a6b6cd42..2f87facd3 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; +use crate::content::types::KnowledgeGrant; use crate::content::LinePoolIndexResource; +use crate::knowledge::content_registry::ContentEntityRegistry; +use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant}; +use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId}; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; use crate::npc::relationships::{TrustEvent, TrustEventQueue}; @@ -422,6 +426,7 @@ pub fn process_talk_interaction( time: Res, line_pool: Option>, registry: Res, + content_registry: Res, mut rng: ResMut, mut event_queue: ResMut, mut trust_queue: ResMut, @@ -442,6 +447,7 @@ pub fn process_talk_interaction( Option<&mut InteractionMemory>, Option<&NpcName>, Option<&NpcColorIndex>, + Option<&KnowledgeGraph>, )>, ) { let Some(line_pool) = line_pool else { @@ -462,8 +468,8 @@ pub fn process_talk_interaction( let target = talk_request.target; - // Look up NPC dialogue profile, mood, interaction history, name, and color (#325) - let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = + // Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079) + let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) = npc_query.get_mut(target) else { tracing::debug!( @@ -541,6 +547,19 @@ pub fn process_talk_interaction( cooldown.record(&line.id, time.tick); + // Knowledge grant (D-079): fire at line selection time, server-authoritative. + if let Some(grant) = &line.knowledge_grant { + emit_knowledge_grant( + grant, + player_entity, + speaker_stable, + &content_registry, + npc_kg_opt, + time.tick, + &mut event_queue, + ); + } + // Emit IncompleteInteraction if overwriting an existing dialogue session if let Some(prev) = active_dialogue_opt { event_queue.push(crate::knowledge::KnowledgeEvent { @@ -594,6 +613,102 @@ pub fn process_talk_interaction( commands.entity(player_entity).remove::(); } +// --------------------------------------------------------------------------- +// Knowledge grant helper (D-079) +// --------------------------------------------------------------------------- + +/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field. +/// +/// Called at line selection time (server-authoritative, tick-stamped). +/// Source is always `ToldBy { source_id: speaker_stable, tick }`. +/// +/// Fact grants: dropped with tracing::warn! if the granting NPC's KG +/// does not contain the fact (D-079 runtime guardrail). +/// Entity grants: no guardrail — always emitted if entity_ref resolves. +#[allow(clippy::too_many_arguments)] +fn emit_knowledge_grant( + grant: &KnowledgeGrant, + player_entity: Entity, + speaker_stable: StableId, + content_registry: &ContentEntityRegistry, + npc_kg_opt: Option<&KnowledgeGraph>, + tick: u64, + event_queue: &mut crate::knowledge::KnowledgeEventQueue, +) { + let source = KnowledgeSource::ToldBy { + source_id: speaker_stable, + tick, + }; + + match grant { + KnowledgeGrant::Fact { fact_id, confidence } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let fid = FactId(fact_id.clone()); + // Guardrail: NPC must know this fact to grant it (D-079). + let npc_knows = npc_kg_opt + .map(|kg| kg.knows_fact(&fid)) + .unwrap_or(false); + if !npc_knows { + tracing::warn!( + "KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail", + speaker_stable, + fact_id + ); + return; + } + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { + fact_id: fid, + confidence: conf, + }), + source, + }, + }); + } + KnowledgeGrant::Entity { + entity_ref, + attributes, + confidence, + } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let Some(target_id) = content_registry.resolve(entity_ref) else { + tracing::warn!( + "KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry", + entity_ref + ); + return; + }; + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { + target_id, + attributes: attributes.clone(), + confidence: conf, + }), + source, + }, + }); + } + } +} + // --------------------------------------------------------------------------- // System: process_walk_away (D-064) // --------------------------------------------------------------------------- @@ -1330,6 +1445,7 @@ mod tests { world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -1828,6 +1944,7 @@ mod tests { let mut world = setup_dialogue_world(); world.init_resource::(); + world.init_resource::(); let npc = world.spawn_empty().id(); let npc_sid = world.resource_mut::().register(npc); diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index cec058f99..bedfbd9bf 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -214,6 +214,7 @@ fn verify_template_role_slots() { fn spawn_npc_from_content_store() { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); // Create a minimal content store with one test NPC let mut store = ContentStore::default(); @@ -333,6 +334,7 @@ fn spawn_real_content_with_relationships_and_secrets() { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); let store = load_content(&root).expect("content loading should succeed");