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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user