//! Knowledge event queue and processing system (#363). //! //! Event-driven knowledge updates. Perception and other systems push //! KnowledgeEvents; the processing system drains them per tick. use bevy_ecs::prelude::*; use std::collections::BTreeMap; use crate::simulation::movement::TilePosition; 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)] pub struct KnowledgeEvent { pub observer: Entity, pub tick: u64, pub event_type: KnowledgeEventType, } #[derive(Debug, Clone)] pub enum KnowledgeEventType { /// Observer saw entity at position (sets Direct confidence). DirectObservation { target: Entity, position: TilePosition, }, /// Entity left observer's LOS (downgrades from Direct). LeftLOS { target: Entity }, /// Observer walked away from an active interaction (D-064). /// Records incompleteness in the target's known_attributes for future /// dialogue/monologue consequences. IncompleteInteraction { 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). /// Differentiates casual conversation from confrontation — /// future dialogue may react differently. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InteractionType { /// Normal Talk conversation. Talk, /// Confrontation (D-063). Walking away from confrontation /// carries heavier consequences than casual talk. Confront, } /// Resource: queue of pending knowledge events. /// Drained once per tick by the knowledge update system. #[derive(Resource, Default)] 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) { self.events.push(event); } /// Drain all pending events. pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } /// Number of pending events. pub fn len(&self) -> usize { self.events.len() } /// Whether the queue is empty. pub fn is_empty(&self) -> bool { self.events.is_empty() } } /// 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::npc_components::NpcName>, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { let events = queue.drain(); for event in events { let Ok(mut observer_kg) = knowledge_query.get_mut(event.observer) else { continue; }; match event.event_type { KnowledgeEventType::DirectObservation { target, position } => { if let Some(stable_id) = registry.to_stable(target) { 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, "DirectObservation target {:?} not in EntityRegistry", target ); tracing::error!( "DirectObservation target {:?} not in EntityRegistry", target ); } } KnowledgeEventType::LeftLOS { target } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity_leaving_los(&stable_id, event.tick); } else { debug_assert!(false, "LeftLOS target {:?} not in EntityRegistry", target); tracing::error!("LeftLOS target {:?} not in EntityRegistry", target); } } KnowledgeEventType::IncompleteInteraction { target, interaction_type, } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.record_incomplete_interaction( &stable_id, interaction_type, event.tick, ); tracing::debug!( "Recorded incomplete {:?} interaction with {:?} at tick {}", interaction_type, stable_id, event.tick, ); } } 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, ); } } } } } } /// System: run knowledge decay once per game-minute (every 10 ticks per D-031). pub fn decay_knowledge( time: Res, thresholds: Res, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { // Decay runs every 10 ticks (1 game-minute per D-031) if !time.tick.is_multiple_of(10) { return; } for mut kg in knowledge_query.iter_mut() { kg.decay(time.tick, &thresholds); } } #[cfg(test)] mod tests { use super::*; use bevy_ecs::world::World; #[test] fn queue_push_and_drain() { let mut queue = KnowledgeEventQueue::default(); assert!(queue.is_empty()); let mut world = World::new(); let e1 = world.spawn_empty().id(); let e2 = world.spawn_empty().id(); queue.push(KnowledgeEvent { observer: e1, tick: 100, event_type: KnowledgeEventType::DirectObservation { target: e2, position: TilePosition::new(5, 5, 0), }, }); assert_eq!(queue.len(), 1); let events = queue.drain(); assert_eq!(events.len(), 1); assert!(queue.is_empty()); } #[test] fn process_direct_observation_event() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let observer = world.spawn(KnowledgeGraph::new()).id(); let target = world.spawn_empty().id(); let observer_sid = registry.register(observer); let target_sid = registry.register(target); let _ = observer_sid; // registered for completeness world.insert_resource(registry); world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer, tick: 50, event_type: KnowledgeEventType::DirectObservation { target, position: TilePosition::new(10, 10, 0), }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert!(kg.knows_entity(&target_sid)); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::Direct) ); } #[test] fn process_left_los_event() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target = world.spawn_empty().id(); let target_sid = registry.register(target); // Pre-populate observer with Direct knowledge let mut kg = KnowledgeGraph::new(); kg.observe_entity(target_sid, TilePosition::new(10, 10, 0), 50); let observer = world.spawn(kg).id(); registry.register(observer); world.insert_resource(registry); world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer, tick: 60, event_type: KnowledgeEventType::LeftLOS { target }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsDetails) ); } #[test] fn process_event_for_missing_observer_is_no_op() { 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(); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer: fake_observer, tick: 100, event_type: KnowledgeEventType::DirectObservation { target: fake_target, position: TilePosition::new(5, 5, 0), }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); // Should not panic } #[test] fn decay_skips_non_minute_ticks() { use crate::simulation::time::SimulationTime; let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target_entity = world.spawn_empty().id(); let target_sid = registry.register(target_entity); let mut kg = KnowledgeGraph::new(); kg.observe_entity(target_sid, TilePosition::new(5, 5, 0), 0); kg.observe_entity_leaving_los(&target_sid, 1); // KnowsDetails let observer = world.spawn(kg).id(); registry.register(observer); world.insert_resource(registry); let thresholds = DecayThresholds { decay_after: 5, stale_after: 100, }; world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsDetails), "decay should not run on tick 7 (not a game-minute boundary)" ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsOf), "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"); } }