266 lines
8.4 KiB
Rust
266 lines
8.4 KiB
Rust
//! 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.
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
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"
|
|
);
|
|
}
|
|
}
|