//! Cognitive delay system for fog recognition (#423, D-060). //! //! Recognition is NOT instant. When the observer first perceives an entity //! not yet in their knowledge graph, recognition is buffered through a //! cognitive delay: 0.6s (6 ticks) normal, 0.3s (3 ticks) urgent. //! //! Flow: observation.rs detects new entity -> CognitiveDelay buffers -> //! process_cognitive_delay drains on expiry -> KnowledgeEvent emitted. //! //! Client-facing: ObserverSnapshot.pending_recognitions lists entities //! undergoing recognition (grey blobs with no identity until delay expires). use bevy_ecs::prelude::*; use crate::knowledge::types::StableId; use crate::knowledge::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; use crate::simulation::movement::TilePosition; use crate::simulation::time::SimulationTime; /// Base cognitive delay: 0.6 seconds = 6 ticks at 10 tps (D-031, D-060). /// Tunable: expect playtesting adjustments. pub const NORMAL_DELAY_TICKS: u64 = 6; /// Urgent cognitive delay: 0.3 seconds = 3 ticks at 10 tps (D-031, D-060). /// Triggered when observe_anomaly context is active. pub const URGENT_DELAY_TICKS: u64 = 3; /// How the recognition was triggered, determines delay duration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecognitionTrigger { /// Standard perception event. 0.6s = 6 ticks. Normal, /// Urgent context (observe_anomaly). 0.3s = 3 ticks. Urgent, } impl RecognitionTrigger { /// Delay in simulation ticks for this trigger type. pub fn delay_ticks(self) -> u64 { match self { Self::Normal => NORMAL_DELAY_TICKS, Self::Urgent => URGENT_DELAY_TICKS, } } } /// A single pending recognition in the cognitive delay pipeline. #[derive(Debug, Clone)] pub struct PendingRecognition { /// Bevy entity being recognized. pub target: Entity, /// Stable entity ID (for KG and wire format). pub stable_id: StableId, /// Position where the entity was perceived. pub position: TilePosition, /// Tick when recognition will complete. pub delay_until_tick: u64, /// What triggered this recognition. pub trigger: RecognitionTrigger, } /// Component: cognitive delay buffer for entity recognition (D-060). /// /// Attached to entities with a KnowledgeGraph (player character, NPCs in future). /// Tracks pending recognitions -- entities that have been perceived but not yet /// identified. Drained by `process_cognitive_delay` each tick. #[derive(Component, Debug, Default)] pub struct CognitiveDelay { pending: Vec, } impl CognitiveDelay { /// Queue a new pending recognition. pub fn push(&mut self, recognition: PendingRecognition) { self.pending.push(recognition); } /// Check if an entity is already pending recognition. pub fn is_pending(&self, stable_id: &StableId) -> bool { self.pending.iter().any(|p| p.stable_id == *stable_id) } /// Cancel a pending recognition (e.g., entity left perception range). /// Returns the cancelled entry if it existed. pub fn cancel(&mut self, stable_id: &StableId) -> Option { if let Some(idx) = self.pending.iter().position(|p| p.stable_id == *stable_id) { Some(self.pending.swap_remove(idx)) } else { None } } /// Drain all recognitions whose delay has expired (delay_until_tick <= current_tick). pub fn drain_ready(&mut self, current_tick: u64) -> Vec { let mut ready = Vec::new(); self.pending.retain(|p| { if current_tick >= p.delay_until_tick { ready.push(p.clone()); false } else { true } }); ready } /// Read-only access to pending recognitions (for snapshot assembly). pub fn pending(&self) -> &[PendingRecognition] { &self.pending } /// Number of pending recognitions. pub fn len(&self) -> usize { self.pending.len() } /// Whether there are any pending recognitions. pub fn is_empty(&self) -> bool { self.pending.is_empty() } } /// System: drain expired cognitive delays and emit KnowledgeEvents (#423, D-060). /// /// Each tick, checks CognitiveDelay components for expired recognition timers. /// Expired recognitions are converted to DirectObservation KnowledgeEvents. /// /// System ordering: after emit_observation_events, before process_knowledge_events. pub fn process_cognitive_delay( time: Res, mut query: Query<(Entity, &mut CognitiveDelay)>, mut event_queue: ResMut, ) { for (observer, mut delay) in query.iter_mut() { let ready = delay.drain_ready(time.tick); for recognition in ready { event_queue.push(KnowledgeEvent { observer, tick: time.tick, event_type: KnowledgeEventType::DirectObservation { target: recognition.target, position: recognition.position, }, }); tracing::debug!( "Cognitive delay resolved: target={:?}, stable_id={}, trigger={:?}, tick={}", recognition.target, recognition.stable_id.0, recognition.trigger, time.tick, ); } } } #[cfg(test)] mod tests { use super::*; use crate::knowledge::registry::EntityRegistry; use crate::knowledge::KnowledgeGraph; use bevy_ecs::world::World; // --- RecognitionTrigger --- #[test] fn trigger_delay_values() { assert_eq!(RecognitionTrigger::Normal.delay_ticks(), NORMAL_DELAY_TICKS); assert_eq!(RecognitionTrigger::Urgent.delay_ticks(), URGENT_DELAY_TICKS); assert_eq!(NORMAL_DELAY_TICKS, 6); assert_eq!(URGENT_DELAY_TICKS, 3); } // --- CognitiveDelay component --- #[test] fn default_is_empty() { let delay = CognitiveDelay::default(); assert!(delay.is_empty()); assert_eq!(delay.len(), 0); assert!(delay.pending().is_empty()); } #[test] fn push_adds_pending() { let mut world = World::new(); let target = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); assert_eq!(delay.len(), 1); assert!(delay.is_pending(&StableId(1))); assert!(!delay.is_pending(&StableId(2))); } #[test] fn cancel_removes_and_returns_entry() { let mut world = World::new(); let target = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); let cancelled = delay.cancel(&StableId(1)); assert!(cancelled.is_some()); assert_eq!(cancelled.unwrap().stable_id, StableId(1)); assert!(delay.is_empty()); } #[test] fn cancel_missing_returns_none() { let mut delay = CognitiveDelay::default(); assert!(delay.cancel(&StableId(999)).is_none()); } #[test] fn drain_ready_before_delay() { let mut world = World::new(); let target = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); let ready = delay.drain_ready(105); assert!(ready.is_empty()); assert_eq!(delay.len(), 1, "entry should remain pending"); } #[test] fn drain_ready_at_exact_tick() { let mut world = World::new(); let target = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); let ready = delay.drain_ready(106); assert_eq!(ready.len(), 1); assert_eq!(ready[0].stable_id, StableId(1)); assert!(delay.is_empty()); } #[test] fn drain_ready_past_tick() { let mut world = World::new(); let target = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); let ready = delay.drain_ready(200); assert_eq!(ready.len(), 1); assert!(delay.is_empty()); } #[test] fn drain_ready_partial_mixed_triggers() { let mut world = World::new(); let t1 = world.spawn_empty().id(); let t2 = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target: t1, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 103, // Urgent: 3 ticks from tick 100 trigger: RecognitionTrigger::Urgent, }); delay.push(PendingRecognition { target: t2, stable_id: StableId(2), position: TilePosition::new(10, 10, 0), delay_until_tick: 106, // Normal: 6 ticks from tick 100 trigger: RecognitionTrigger::Normal, }); // Tick 103: only urgent should drain let ready = delay.drain_ready(103); assert_eq!(ready.len(), 1); assert_eq!(ready[0].stable_id, StableId(1)); assert_eq!(delay.len(), 1); // Tick 106: normal should drain let ready = delay.drain_ready(106); assert_eq!(ready.len(), 1); assert_eq!(ready[0].stable_id, StableId(2)); assert!(delay.is_empty()); } #[test] fn is_pending_checks_stable_id() { let mut world = World::new(); let t1 = world.spawn_empty().id(); let t2 = world.spawn_empty().id(); let mut delay = CognitiveDelay::default(); delay.push(PendingRecognition { target: t1, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); assert!(delay.is_pending(&StableId(1))); assert!(!delay.is_pending(&StableId(2))); delay.push(PendingRecognition { target: t2, stable_id: StableId(2), position: TilePosition::new(10, 10, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); assert!(delay.is_pending(&StableId(1))); assert!(delay.is_pending(&StableId(2))); } // --- process_cognitive_delay system --- #[test] fn process_emits_knowledge_event_on_expiry() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target = world.spawn_empty().id(); let target_sid = registry.register(target); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: target_sid, position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); world.spawn((KnowledgeGraph::new(), cd)); world.insert_resource(registry); world.init_resource::(); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 106; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_cognitive_delay); schedule.run(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1, "should emit one KnowledgeEvent on expiry"); } #[test] fn process_does_not_emit_before_delay() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target = world.spawn_empty().id(); let target_sid = registry.register(target); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: target_sid, position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); world.spawn((KnowledgeGraph::new(), cd)); world.insert_resource(registry); world.init_resource::(); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 105; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_cognitive_delay); schedule.run(&mut world); let queue = world.resource::(); assert!(queue.is_empty(), "should not emit before delay expires"); } #[test] fn process_urgent_resolves_faster() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let t1 = world.spawn_empty().id(); let t1_sid = registry.register(t1); let t2 = world.spawn_empty().id(); let t2_sid = registry.register(t2); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target: t1, stable_id: t1_sid, position: TilePosition::new(5, 5, 0), delay_until_tick: 103, // Urgent trigger: RecognitionTrigger::Urgent, }); cd.push(PendingRecognition { target: t2, stable_id: t2_sid, position: TilePosition::new(10, 10, 0), delay_until_tick: 106, // Normal trigger: RecognitionTrigger::Normal, }); world.spawn((KnowledgeGraph::new(), cd)); world.insert_resource(registry); world.init_resource::(); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 103; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_cognitive_delay); schedule.run(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1, "only urgent should resolve at tick 103"); } #[test] fn process_no_crash_without_pending() { let mut world = World::new(); world.spawn((KnowledgeGraph::new(), CognitiveDelay::default())); world.init_resource::(); world.init_resource::(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_cognitive_delay); schedule.run(&mut world); // Should not panic } #[test] fn process_clears_pending_after_drain() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target = world.spawn_empty().id(); let target_sid = registry.register(target); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: target_sid, position: TilePosition::new(5, 5, 0), delay_until_tick: 106, trigger: RecognitionTrigger::Normal, }); let observer = world.spawn((KnowledgeGraph::new(), cd)).id(); world.insert_resource(registry); world.init_resource::(); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 106; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_cognitive_delay); schedule.run(&mut world); let cd = world.entity(observer).get::().unwrap(); assert!(cd.is_empty(), "pending should be cleared after drain"); } }