feat(perception): add cognitive delay system for fog recognition (#423)
New CognitiveDelay component buffers perception events before emitting KnowledgeEvents. Normal delay: 6 ticks (0.6s), urgent: 3 ticks (0.3s). Drain system runs after emit_observation_events, before process_knowledge_events. Adds pending_recognitions to ObserverSnapshot (protocol v7) for client fog entity visualization. Includes cancellation when entity leaves LOS. Monologue fires during delay (not after) per D-060 — cross-system wiring deferred to follow-up ticket #451. Ref: D-060 (cognitive delay), D-031 (10 tps), D-059 (fog layers) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
//! 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<PendingRecognition>,
|
||||
}
|
||||
|
||||
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<PendingRecognition> {
|
||||
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<PendingRecognition> {
|
||||
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<SimulationTime>,
|
||||
mut query: Query<(Entity, &mut CognitiveDelay)>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
) {
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<KnowledgeEventQueue>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
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::<KnowledgeEventQueue>();
|
||||
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::<CognitiveDelay>().unwrap();
|
||||
assert!(cd.is_empty(), "pending should be cleared after drain");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user