feat(simulation): wire direct observation to knowledge graph (#364, #367)

Perception system emits DirectObservation/LeftLOS knowledge events
based on snapshot visibility. Entities entering LOS get Direct
confidence; leaving LOS downgrades to KnowsDetails. System params
are optional for backward compatibility when KnowledgePlugin is not
registered. 28 knowledge graph unit tests included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:48:51 +01:00
co-authored by Claude Opus 4.6
parent e733a4c223
commit 804379dee2
3 changed files with 218 additions and 0 deletions
+2
View File
@@ -177,6 +177,8 @@ impl Plugin for BridgePlugin {
crate::perception::observer::compute_observer_snapshot
.after(crate::simulation::movement::validate_movement)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
send_bridge_snapshot
.after(crate::perception::observer::compute_observer_snapshot),
),
+1
View File
@@ -4,6 +4,7 @@
use bevy_app::prelude::*;
pub mod observation;
pub mod observer;
pub mod shadowcast;
pub mod vision_cone;
+215
View File
@@ -0,0 +1,215 @@
//! Direct observation knowledge flow (#364).
//!
//! Emits knowledge events when entities enter or leave the observer's LOS.
//! Runs after compute_observer_snapshot, before process_knowledge_events.
use bevy_ecs::prelude::*;
use std::collections::HashSet;
use crate::bridge::types::*;
use crate::knowledge::{
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
/// Emit knowledge events for entities entering/leaving the observer's LOS.
///
/// Compares the current snapshot's visible entities against the observer's
/// knowledge graph. Emits DirectObservation for newly visible entities and
/// LeftLOS for entities that were Direct but are no longer in the snapshot.
pub fn emit_observation_events(
time: Res<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Option<Res<EntityRegistry>>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
event_queue: Option<ResMut<KnowledgeEventQueue>>,
entity_positions: Query<&TilePosition>,
) {
let (Some(registry), Some(ref mut event_queue)) = (registry, event_queue) else {
return; // Knowledge system not wired up
};
let Some(snapshot) = &buffer.snapshot else {
return;
};
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
return;
};
// Build set of currently visible entity IDs from snapshot (excluding player)
let visible_entity_ids: HashSet<u64> = snapshot
.entities
.iter()
.filter(|e| !matches!(e.kind, EntityKind::Player))
.map(|e| e.entity_id)
.collect();
// Emit DirectObservation for each visible non-player entity
for visible in &snapshot.entities {
if matches!(visible.kind, EntityKind::Player) {
continue;
}
// Look up the bevy Entity from the entity_id (which is Entity::to_bits())
let entity = Entity::from_bits(visible.entity_id);
// Get tile position for knowledge tracking
if let Ok(pos) = entity_positions.get(entity) {
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target: entity,
position: *pos,
},
});
}
}
// Emit LeftLOS for entities that were Direct but are no longer visible
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
if knowledge.confidence != crate::knowledge::KnowledgeConfidence::Direct {
continue;
}
// Check if this entity is still visible in the current snapshot
if let Some(entity) = registry.to_entity(stable_id) {
let entity_bits = entity.to_bits();
if !visible_entity_ids.contains(&entity_bits) {
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
event_type: KnowledgeEventType::LeftLOS { target: entity },
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::Npc;
use crate::perception::observer::compute_observer_snapshot;
use crate::perception::vision_cone::Facing;
use crate::simulation::movement::WalkabilityMap;
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn visible_npc_emits_direct_observation() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
))
.id();
registry.register(player);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
registry.register(npc);
world.insert_resource(registry);
// First: compute snapshot so NPC is visible
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
assert!(
!queue.is_empty(),
"should emit DirectObservation for visible NPC"
);
}
#[test]
fn npc_leaving_los_emits_left_los() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Player with pre-existing Direct knowledge of NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
))
.id();
registry.register(player);
world.insert_resource(registry);
// Now block the NPC behind a wall so it's NOT visible
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 15, 0), false);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
let events = &queue.events;
let has_left_los = events.iter().any(|e| {
matches!(
e.event_type,
KnowledgeEventType::LeftLOS { target } if target == npc
)
});
assert!(has_left_los, "should emit LeftLOS when NPC is no longer visible");
}
#[test]
fn no_events_for_player_entity() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
assert!(
queue.is_empty(),
"should not emit observation events for the player entity itself"
);
}
}