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>
This commit is contained in:
2026-02-17 17:41:14 +01:00
co-authored by Claude Opus 4.6
parent ed7498cb24
commit 695d2ac843
5 changed files with 745 additions and 5 deletions
+264
View File
@@ -0,0 +1,264 @@
//! Anomaly detection system (#450, D-060).
//!
//! Marks entities as anomalous when the observer's KnowledgeGraph has them as
//! PersonOfInterest or Contradicted. AnomalyMarker is a transient per-tick
//! component cleared at tick start and recomputed from KG state.
//!
//! Used by:
//! - emit_observation_events: RecognitionTrigger::Urgent for fog recognition
//! - #451 (future): monologue ObserveAnomaly trigger priority
use bevy_ecs::prelude::*;
use crate::bridge::types::RelationshipState;
use crate::knowledge::types::KnowledgeState;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::movement::PlayerCharacter;
/// Transient marker: entity flagged as anomalous this tick.
///
/// Cleared at tick start, recomputed by `detect_anomalies` each tick
/// from the observer's KnowledgeGraph. An entity is anomalous when
/// the observer knows it as PersonOfInterest or its KG state is Contradicted.
///
/// The player entity is never marked anomalous (prevents self-checks).
#[derive(Component, Debug)]
pub struct AnomalyMarker;
/// Clear all AnomalyMarker components at tick start.
///
/// System ordering: runs before detect_anomalies.
pub fn clear_anomaly_markers(mut commands: Commands, markers: Query<Entity, With<AnomalyMarker>>) {
for entity in markers.iter() {
commands.entity(entity).remove::<AnomalyMarker>();
}
}
/// Detect anomalous entities based on observer's KG state.
///
/// Marks entities as anomalous when KG.relationship == PersonOfInterest
/// OR KG.state == Contradicted. Skips the player entity.
///
/// System ordering: after clear_anomaly_markers, before emit_observation_events.
pub fn detect_anomalies(
mut commands: Commands,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
) {
let Ok((player_entity, observer_kg)) = observer_query.single() else {
return;
};
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
let Some(entity) = registry.to_entity(stable_id) else {
continue;
};
// Don't mark Player entity as anomalous
if entity == player_entity {
continue;
}
let is_anomalous = knowledge.relationship == RelationshipState::PersonOfInterest
|| knowledge.state == KnowledgeState::Contradicted;
if is_anomalous {
commands.entity(entity).insert(AnomalyMarker);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn detect_marks_contradicted_entity() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
// Apply deferred commands
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"Contradicted entity should have AnomalyMarker"
);
}
#[test]
fn detect_marks_person_of_interest() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"PersonOfInterest entity should have AnomalyMarker"
);
}
#[test]
fn detect_skips_active_known_entity() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
// Active state, Known relationship — not anomalous
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_none(),
"Active/Known entity should NOT have AnomalyMarker"
);
}
#[test]
fn detect_does_not_mark_player() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
KnowledgeGraph::new(),
))
.id();
let player_sid = registry.register(player);
// Even if player is somehow in own KG as POI, don't mark
let mut kg = world.get_mut::<KnowledgeGraph>(player).unwrap();
kg.observe_entity(player_sid, TilePosition::new(10, 10, 0), 50);
kg.set_relationship(&player_sid, RelationshipState::PersonOfInterest);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(player).is_none(),
"Player entity should never be marked anomalous"
);
}
#[test]
fn clear_removes_all_markers() {
let mut world = setup_world();
// Manually add markers
let e1 = world.spawn(AnomalyMarker).id();
let e2 = world.spawn(AnomalyMarker).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(clear_anomaly_markers);
schedule.run(&mut world);
world.flush();
assert!(world.get::<AnomalyMarker>(e1).is_none());
assert!(world.get::<AnomalyMarker>(e2).is_none());
}
#[test]
fn clear_then_detect_refreshes_markers() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
// Run clear then detect
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
clear_anomaly_markers,
detect_anomalies.after(clear_anomaly_markers),
));
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"Marker should be refreshed after clear+detect cycle"
);
}
}
+22
View File
@@ -69,6 +69,9 @@ pub struct PendingRecognition {
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).
@@ -123,6 +126,11 @@ impl CognitiveDelay {
&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()
@@ -206,6 +214,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert_eq!(delay.len(), 1);
@@ -225,6 +234,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let cancelled = delay.cancel(&StableId(1));
@@ -251,6 +261,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(105);
@@ -270,6 +281,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(106);
@@ -290,6 +302,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(200);
@@ -310,6 +323,7 @@ mod tests {
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,
@@ -317,6 +331,7 @@ mod tests {
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
@@ -345,6 +360,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert!(delay.is_pending(&StableId(1)));
@@ -356,6 +372,7 @@ mod tests {
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert!(delay.is_pending(&StableId(1)));
@@ -379,6 +396,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -413,6 +431,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -449,6 +468,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 103, // Urgent
trigger: RecognitionTrigger::Urgent,
monologue_fired: false,
});
cd.push(PendingRecognition {
target: t2,
@@ -456,6 +476,7 @@ mod tests {
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106, // Normal
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -502,6 +523,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let observer = world.spawn((KnowledgeGraph::new(), cd)).id();
+5
View File
@@ -5,6 +5,7 @@
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod anomaly;
pub mod cognitive_delay;
pub mod interpretation;
pub mod observation;
@@ -25,6 +26,10 @@ impl Plugin for PerceptionPlugin {
.add_systems(
Update,
(
anomaly::clear_anomaly_markers
.before(anomaly::detect_anomalies),
anomaly::detect_anomalies
.before(observation::emit_observation_events),
cognitive_delay::process_cognitive_delay
.after(observation::emit_observation_events)
.before(crate::knowledge::events::process_knowledge_events),
+13 -5
View File
@@ -28,6 +28,7 @@ pub fn emit_observation_events(
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
) {
let Some(snapshot) = &buffer.snapshot else {
return;
@@ -78,22 +79,29 @@ pub fn emit_observation_events(
} else if let Some(ref mut delay) = cognitive_delay {
// New entity + cognitive delay available: buffer recognition
if !delay.is_pending(&stable_id) {
// TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers
let trigger = RecognitionTrigger::Normal;
// #450: Urgent trigger for anomalous entities (D-060)
let trigger = if anomaly_markers.get(entity).is_ok() {
RecognitionTrigger::Urgent
} else {
RecognitionTrigger::Normal
};
let delay_until = time.tick + trigger.delay_ticks();
delay.push(PendingRecognition {
target: entity,
stable_id,
position: *pos,
delay_until_tick: time.tick + trigger.delay_ticks(),
delay_until_tick: delay_until,
trigger,
monologue_fired: false,
});
tracing::debug!(
"Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}",
"Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}",
stable_id.0,
pos.x,
pos.y,
pos.z,
time.tick + trigger.delay_ticks(),
trigger,
delay_until,
);
}
} else {
+441
View File
@@ -33,6 +33,24 @@ const DISPLAY_DURATION: f32 = 5.0;
/// Tunable: adjust based on actual client frame rate.
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
/// Hardcoded v0.1 recognition monologue lines (#451, D-060).
/// Fire DURING cognitive delay (when grey blob appears). Future: move to
/// content pools with trigger="observe_anomaly" + character match.
const RECOGNITION_LINES: &[(&str, &str)] = &[
(
"recognition_01",
"Wait \u{2014} I know that walk.",
),
(
"recognition_02",
"Those footsteps... I've heard that pattern before.",
),
(
"recognition_03",
"Something about that silhouette...",
),
];
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
/// Future: move to content pools with trigger="sprint_anomaly".
const ANOMALY_LINES: &[(&str, &str)] = &[
@@ -195,6 +213,144 @@ pub fn process_sprint_anomaly_monologue(
}
}
/// Recognition monologue trigger (#451, D-060).
///
/// Fires DURING cognitive delay, not after — "the monologue IS the recognition."
/// When a new entity enters fog (PendingRecognition queued by emit_observation_events),
/// this system fires a recognition monologue on the next tick.
///
/// Priority: anomalous entities (AnomalyMarker) get first pick. Only one
/// recognition monologue fires per tick. Bypasses normal monologue cooldown
/// (event-driven), but updates last_fired_tick for normal cooldown tracking.
///
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
pub fn trigger_recognition_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
(
&mut crate::perception::cognitive_delay::CognitiveDelay,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
) {
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
return;
};
// Don't override existing monologue from trigger_monologue
if buffer.event.is_some() {
return;
}
// No pending recognitions → nothing to do
if cognitive_delay.is_empty() {
return;
}
// Find the first unfired pending recognition. Prioritize anomalous entities.
let pending = cognitive_delay.pending_mut();
let target_idx = {
// First pass: anomalous + unfired
let anomaly_idx = pending.iter().position(|p| {
!p.monologue_fired && anomaly_markers.get(p.target).is_ok()
});
if let Some(idx) = anomaly_idx {
Some(idx)
} else {
// Second pass: any unfired
pending.iter().position(|p| !p.monologue_fired)
}
};
let Some(idx) = target_idx else {
return;
};
// Try content pools for observe_anomaly trigger lines
let line = if let Some(ref content) = content {
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new();
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != "observe_anomaly" {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// Fallback: allow repeats from content pools
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != "observe_anomaly" {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if !candidates.is_empty() {
let i = rng.rng.random_range(0..candidates.len());
Some((candidates[i].0.to_string(), candidates[i].1.to_string()))
} else {
None
}
} else {
None
};
// Use content pool line or hardcoded fallback
let (id, text) = if let Some((id, text)) = line {
(id, text)
} else {
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
(
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
)
};
buffer.event = Some(MonologueEvent {
id: id.clone(),
text,
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.push(id.clone());
state.last_fired_tick = time.tick;
// Mark this pending recognition as having fired its monologue
pending[idx].monologue_fired = true;
tracing::debug!(
"Recognition monologue fired: id={}, tick={}, target_stable_id={}",
id,
time.tick,
pending[idx].stable_id.0,
);
}
/// Monologue trigger system.
///
/// Runs each tick. Checks trigger conditions against loaded content pools
@@ -710,6 +866,291 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// trigger_recognition_monologue tests (#451, D-060)
// -----------------------------------------------------------------------
use crate::perception::cognitive_delay::{
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
};
use crate::knowledge::types::StableId;
fn setup_recognition_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world
}
#[test]
fn recognition_monologue_fires_for_pending_recognition() {
let mut world = setup_recognition_world();
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(
buffer.event.is_some(),
"recognition monologue should fire for pending recognition"
);
let event = buffer.event.as_ref().unwrap();
assert!(
event.id.starts_with("recognition_"),
"should use hardcoded recognition lines (no content pool)"
);
}
#[test]
fn recognition_monologue_does_not_fire_twice() {
let mut world = setup_recognition_world();
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
// First tick: fires
schedule.run(&mut world);
assert!(
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_some(),
"first tick should fire"
);
// Consume the buffer
world.get_mut::<MonologueBuffer>(player).unwrap().take();
// Second tick: should NOT fire (monologue_fired = true)
schedule.run(&mut world);
assert!(
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_none(),
"second tick should not fire (already fired for this recognition)"
);
}
#[test]
fn recognition_monologue_does_not_override_existing_buffer() {
let mut world = setup_recognition_world();
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
// Pre-fill MonologueBuffer (e.g., from trigger_monologue)
let mut buffer = MonologueBuffer::default();
buffer.event = Some(MonologueEvent {
id: "existing_line".to_string(),
text: "Already have something to say.".to_string(),
duration_seconds: 5.0,
});
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
buffer,
cd,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert_eq!(
buffer.event.as_ref().unwrap().id,
"existing_line",
"should not override existing monologue"
);
// monologue_fired should still be false (wasn't consumed)
let mut cd_query = world.query::<&CognitiveDelay>();
let cd = cd_query.single(&world).unwrap();
assert!(
!cd.pending()[0].monologue_fired,
"should not mark as fired when buffer was full"
);
}
#[test]
fn recognition_monologue_no_pending_is_noop() {
let mut world = setup_recognition_world();
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
CognitiveDelay::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
assert!(
buf_query.single(&world).unwrap().event.is_none(),
"no pending recognitions → no monologue"
);
}
#[test]
fn recognition_monologue_prioritizes_anomalous_entities() {
let mut world = setup_recognition_world();
let normal_target = world.spawn_empty().id();
let anomalous_target = world
.spawn(crate::perception::anomaly::AnomalyMarker)
.id();
let mut cd = CognitiveDelay::default();
// Normal entity added first
cd.push(PendingRecognition {
target: normal_target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
// Anomalous entity added second
cd.push(PendingRecognition {
target: anomalous_target,
stable_id: StableId(2),
position: TilePosition::new(8, 8, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
// Monologue should fire for anomalous entity (idx 1), not normal (idx 0)
let cd = world.get::<CognitiveDelay>(player).unwrap();
assert!(
!cd.pending()[0].monologue_fired,
"normal entity should NOT be fired first"
);
assert!(
cd.pending()[1].monologue_fired,
"anomalous entity should be fired first (priority)"
);
}
#[test]
fn recognition_monologue_updates_last_fired_tick() {
let mut world = setup_recognition_world();
world.resource_mut::<SimulationTime>().tick = 50;
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: 50 + NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut state_query = world.query::<&MonologueState>();
assert_eq!(
state_query.single(&world).unwrap().last_fired_tick,
50,
"last_fired_tick should be updated for normal cooldown tracking"
);
}
#[test]
fn recognition_lines_all_valid() {
assert!(!RECOGNITION_LINES.is_empty());
for (id, text) in RECOGNITION_LINES {
assert!(
id.starts_with("recognition_"),
"id={} should start with recognition_",
id
);
assert!(!text.is_empty(), "text for {} should be non-empty", id);
}
}
#[test]
fn anomaly_full_cycle_detect_then_fire() {
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90