Files
settled-reach/server/src/perception/cognitive_delay.rs
T
jpmschweitzerandClaude Opus 4.6 695d2ac843 feat(perception): anomaly detection and recognition monologue during delay
Add AnomalyMarker component and detect_anomalies() system that flags
entities with KG relationship PersonOfInterest or Contradicted state
for urgent cognitive delay (0.3s vs 0.6s normal). Add
trigger_recognition_monologue() that fires monologue at delay START
(when grey blob appears), not at completion — the monologue IS the
recognition process per D-060. Includes v0.1 fallback recognition
lines and cooldown tracking. Fixes #450, #451.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:14 +01:00

546 lines
18 KiB
Rust

//! 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.
///
/// Playtesting expectation: 0.6s should feel like a brief "processing" beat —
/// noticeable enough that new entities register as grey blobs before resolving,
/// but short enough to not feel sluggish. If playtesters report recognition
/// feels instant (reduce to test), or laggy (current value may be too high for
/// fast-paced encounters), adjust in 2-tick increments. The 2:1 ratio with
/// URGENT_DELAY_TICKS should be preserved.
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.
///
/// Playtesting expectation: 0.3s should feel nearly instant but still register
/// visually as a "snap to attention" moment. If playtesters don't notice the
/// delay at all, consider whether the grey blob phase is too brief to read.
/// Must remain strictly less than NORMAL_DELAY_TICKS.
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,
/// Whether a recognition monologue has been fired for this entry (#451).
/// Set by trigger_recognition_monologue to prevent re-firing each tick.
pub monologue_fired: bool,
}
/// 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.
// Note: O(n) scan over pending vec. Fine for v0.1 (typically <10 pending).
// If NPC cognitive delay is added, consider HashSet<StableId> index.
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
}
/// Mutable access to pending recognitions (for monologue tracking, #451).
pub fn pending_mut(&mut self) -> &mut Vec<PendingRecognition> {
&mut 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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
// 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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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,
monologue_fired: false,
});
cd.push(PendingRecognition {
target: t2,
stable_id: t2_sid,
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106, // Normal
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
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,
monologue_fired: false,
});
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");
}
}