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:
@@ -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),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
pub mod observation;
|
||||
pub mod observer;
|
||||
pub mod shadowcast;
|
||||
pub mod vision_cone;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user