//! 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" ); } }