diff --git a/server/src/perception/anomaly.rs b/server/src/perception/anomaly.rs new file mode 100644 index 000000000..bea13b139 --- /dev/null +++ b/server/src/perception/anomaly.rs @@ -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>) { + for entity in markers.iter() { + commands.entity(entity).remove::(); + } +} + +/// 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>, + registry: Res, +) { + 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::(); + 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::(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::(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::(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::(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::(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::(e1).is_none()); + assert!(world.get::(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::(npc).is_some(), + "Marker should be refreshed after clear+detect cycle" + ); + } +} diff --git a/server/src/perception/cognitive_delay.rs b/server/src/perception/cognitive_delay.rs index 1fba0c2ae..14517ca7b 100644 --- a/server/src/perception/cognitive_delay.rs +++ b/server/src/perception/cognitive_delay.rs @@ -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 { + &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(); diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index b182770f3..ee9003379 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -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), diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 5f29c3394..9a5bf1dc3 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -28,6 +28,7 @@ pub fn emit_observation_events( >, mut event_queue: ResMut, entity_positions: Query<&TilePosition>, + anomaly_markers: Query<(), With>, ) { 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 { diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index c0eabd33c..434167f83 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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, + content: Option>, + mut rng: ResMut, + mut query: Query< + ( + &mut crate::perception::cognitive_delay::CognitiveDelay, + &mut MonologueBuffer, + &mut MonologueState, + ), + With, + >, + anomaly_markers: Query<(), With>, +) { + 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::(); + 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::(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::(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::().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