feat(perception): add observation event generator
Implements ticket #239 for Sprint 3: - ObservationTrigger enum: RoutineDeviation (NPC not at expected routine location), Absence (expected location visible but NPC missing), NewEntity (unknown entity in LOS) - ObservationEventQueue resource with push/drain/len/is_empty - generate_observation_events system runs after emit_observation_events but before process_knowledge_events so it can compare current snapshot against previous-tick knowledge state - Populate PerceptionPlugin with ObservationEventQueue resource and system registration with correct ordering constraints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
//! Observation event generator (#239).
|
||||
//!
|
||||
//! Interprets what the observer sees (and doesn't see) against known NPC
|
||||
//! routines and knowledge graph state. Produces high-level observation events
|
||||
//! that drive monologue and investigation triggers.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::{DailyRoutine, Npc};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// What triggered an observation event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ObservationTrigger {
|
||||
/// NPC is visible but not at their expected routine location.
|
||||
RoutineDeviation {
|
||||
npc: StableId,
|
||||
expected: TilePosition,
|
||||
actual: TilePosition,
|
||||
},
|
||||
/// Known NPC's expected routine location is visible, but the NPC is not there.
|
||||
Absence {
|
||||
npc: StableId,
|
||||
expected: TilePosition,
|
||||
},
|
||||
/// An entity visible in LOS that the observer has no prior knowledge of.
|
||||
NewEntity {
|
||||
entity: StableId,
|
||||
location: TilePosition,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single observation event produced by the interpretation system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObservationEvent {
|
||||
pub tick: u64,
|
||||
pub trigger: ObservationTrigger,
|
||||
pub observer: Entity,
|
||||
}
|
||||
|
||||
/// Resource: queue of observation events for downstream systems (monologue, UI).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ObservationEventQueue {
|
||||
events: Vec<ObservationEvent>,
|
||||
}
|
||||
|
||||
impl ObservationEventQueue {
|
||||
pub fn push(&mut self, event: ObservationEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<ObservationEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: interpret visible snapshot against known routines and knowledge.
|
||||
///
|
||||
/// Runs after knowledge events are processed so the knowledge graph is up-to-date.
|
||||
/// Produces observation events for: routine deviations, absences, new entities.
|
||||
pub fn generate_observation_events(
|
||||
time: Res<SimulationTime>,
|
||||
buffer: Res<SnapshotBuffer>,
|
||||
registry: Res<EntityRegistry>,
|
||||
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
npc_query: Query<(&TilePosition, &DailyRoutine), With<Npc>>,
|
||||
mut event_queue: ResMut<ObservationEventQueue>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_phase = time.day_phase();
|
||||
|
||||
// Build set of visible tile positions for absence checks
|
||||
let visible_tile_set: std::collections::HashSet<(i32, i32, i32)> = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.map(|t| (t.x, t.y, t.z))
|
||||
.collect();
|
||||
|
||||
// Collect visible NPC entity bits for absence checks
|
||||
let visible_npc_bits: std::collections::HashSet<u64> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
// --- Routine deviation + New entity detection ---
|
||||
for visible in &snapshot.entities {
|
||||
if matches!(visible.kind, EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entity = Entity::from_bits(visible.entity_id);
|
||||
|
||||
// Check if this is a new entity (not in observer's knowledge graph)
|
||||
if let Some(stable_id) = registry.to_stable(entity) {
|
||||
if !observer_kg.knows_entity(&stable_id) {
|
||||
// Reconstruct tile position from render coords
|
||||
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::NewEntity {
|
||||
entity: stable_id,
|
||||
location: tile_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check routine deviation: visible NPC not at expected location
|
||||
if let Ok((actual_pos, routine)) = npc_query.get(entity) {
|
||||
if let Some(expected_pos) = routine.expected_location(current_phase) {
|
||||
if *actual_pos != expected_pos {
|
||||
if let Some(stable_id) = registry.to_stable(entity) {
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::RoutineDeviation {
|
||||
npc: stable_id,
|
||||
expected: expected_pos,
|
||||
actual: *actual_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Absence detection ---
|
||||
// For each known NPC not in visible set, check if their expected routine
|
||||
// location IS in our visible tiles (meaning we can see the spot but
|
||||
// the NPC isn't there).
|
||||
for (stable_id, _knowledge) in observer_kg.known_entities_iter() {
|
||||
let Some(entity) = registry.to_entity(stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip if currently visible
|
||||
if visible_npc_bits.contains(&entity.to_bits()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this NPC has a routine with an expected location
|
||||
if let Ok((_pos, routine)) = npc_query.get(entity) {
|
||||
if let Some(expected_pos) = routine.expected_location(current_phase) {
|
||||
// If we can see the expected location but the NPC isn't there
|
||||
if visible_tile_set.contains(&(expected_pos.x, expected_pos.y, expected_pos.z)) {
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::Absence {
|
||||
npc: *stable_id,
|
||||
expected: expected_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::npc::RoutineEntry;
|
||||
use crate::perception::observer::compute_observer_snapshot;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::movement::WalkabilityMap;
|
||||
use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
/// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update.
|
||||
/// Interpretation runs BEFORE knowledge updates so it can detect new entities
|
||||
/// and compare against the PREVIOUS tick's knowledge state.
|
||||
fn run_pipeline(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
compute_observer_snapshot,
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(compute_observer_snapshot),
|
||||
generate_observation_events
|
||||
.after(crate::perception::observation::emit_observation_events),
|
||||
crate::knowledge::events::process_knowledge_events
|
||||
.after(generate_observation_events),
|
||||
));
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_deviation_detected() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Set time to Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC at (16,14) but routine says they should be at (20,10) in Afternoon
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let deviations: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::RoutineDeviation { npc, .. } if *npc == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(deviations.len(), 1, "should detect routine deviation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_deviation_at_correct_location() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Time at Morning (tick 0, default)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC at (16,14) and routine says Morning at (16,14)
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 14, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let deviations: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.trigger, ObservationTrigger::RoutineDeviation { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
deviations.is_empty(),
|
||||
"no deviation when NPC is at expected location"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absence_when_location_visible() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC behind player (blind spot, not visible) but routine says
|
||||
// Morning at (16,15) which IS in the player's forward view
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 30, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 15, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows about the NPC (has observed before)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 0);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 1);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let absences: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::Absence { npc, .. } if *npc == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(absences.len(), 1, "should detect absence at visible location");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_entity_detected() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(), // Empty — never seen anyone
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let new_entities: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::NewEntity { entity, .. } if *entity == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(new_entities.len(), 1, "should detect new entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_entity_no_new_event() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player already knows about the NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let new_entities: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.trigger, ObservationTrigger::NewEntity { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
new_entities.is_empty(),
|
||||
"should not emit NewEntity for known entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// Generates ObserverSnapshot for client rendering
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod interpretation;
|
||||
pub mod observation;
|
||||
pub mod observer;
|
||||
pub mod shadowcast;
|
||||
@@ -14,8 +16,14 @@ pub mod vision_cone;
|
||||
pub struct PerceptionPlugin;
|
||||
|
||||
impl Plugin for PerceptionPlugin {
|
||||
fn build(&self, _app: &mut App) {
|
||||
// Stub implementation - will be populated in phase 2
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<interpretation::ObservationEventQueue>()
|
||||
.add_systems(
|
||||
Update,
|
||||
interpretation::generate_observation_events
|
||||
.after(observation::emit_observation_events)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
);
|
||||
tracing::debug!("PerceptionPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user