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:
2026-02-16 00:41:08 +01:00
co-authored by Claude Opus 4.6
parent 3a2cfc37e0
commit 4d8fe996fc
17 changed files with 933 additions and 105 deletions
+67 -11
View File
@@ -9,6 +9,7 @@ use crate::bridge::types::*;
use crate::knowledge::{
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
@@ -21,7 +22,10 @@ pub fn emit_observation_events(
time: Res<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Res<EntityRegistry>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
mut observer_query: Query<
(Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>),
With<PlayerCharacter>,
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
) {
@@ -29,7 +33,8 @@ pub fn emit_observation_events(
return;
};
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
let Ok((observer_entity, observer_kg, mut cognitive_delay)) = observer_query.single_mut()
else {
return;
};
@@ -41,7 +46,9 @@ pub fn emit_observation_events(
.map(|e| e.entity_id)
.collect();
// Emit DirectObservation for each visible non-player entity
// Emit DirectObservation for each visible non-player entity.
// NEW entities (not in observer's KG) are routed through cognitive delay
// when the CognitiveDelay component is present (#423, D-060).
for visible in &snapshot.entities {
if matches!(visible.kind, EntityKind::Player) {
continue;
@@ -54,7 +61,43 @@ pub fn emit_observation_events(
};
// Get tile position for knowledge tracking
if let Ok(pos) = entity_positions.get(entity) {
let Ok(pos) = entity_positions.get(entity) else {
continue;
};
if observer_kg.knows_entity(&stable_id) {
// Known entity: immediate DirectObservation (position update)
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target: entity,
position: *pos,
},
});
} else if let Some(ref mut delay) = cognitive_delay {
// New entity + cognitive delay available: buffer recognition
if !delay.is_pending(&stable_id) {
// TODO: wire RecognitionTrigger::Urgent for observe_anomaly triggers
let trigger = RecognitionTrigger::Normal;
delay.push(PendingRecognition {
target: entity,
stable_id,
position: *pos,
delay_until_tick: time.tick + trigger.delay_ticks(),
trigger,
});
tracing::debug!(
"Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}",
stable_id.0,
pos.x,
pos.y,
pos.z,
time.tick + trigger.delay_ticks(),
);
}
} else {
// No CognitiveDelay component: immediate (backward compat)
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
@@ -83,6 +126,20 @@ pub fn emit_observation_events(
}
}
}
// Cancel pending cognitive delays for entities that left perception range.
// Without this, a delay could expire and emit a DirectObservation for an
// entity that is no longer visible (stale position, 1-tick flash).
if let Some(ref mut delay) = cognitive_delay {
let pending_ids: Vec<crate::knowledge::types::StableId> =
delay.pending().iter().map(|p| p.stable_id).collect();
for sid in pending_ids {
if !visible_stable_ids.contains(&sid.0) {
delay.cancel(&sid);
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
}
}
}
#[cfg(test)]
@@ -124,9 +181,7 @@ mod tests {
.id();
registry.register(player);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id();
registry.register(npc);
world.insert_resource(registry);
@@ -152,9 +207,7 @@ mod tests {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id();
let npc_sid = registry.register(npc);
// Player with pre-existing Direct knowledge of NPC
@@ -195,7 +248,10 @@ mod tests {
KnowledgeEventType::LeftLOS { target } if target == npc
)
});
assert!(has_left_los, "should emit LeftLOS when NPC is no longer visible");
assert!(
has_left_los,
"should emit LeftLOS when NPC is no longer visible"
);
}
#[test]