Formatting pass across simulation, perception, knowledge, NPC, and test modules. Includes two clippy fixes in monologue.rs (.values() instead of for (_, v) pattern). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
464 lines
16 KiB
Rust
464 lines
16 KiB
Rust
//! 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 BEFORE knowledge events are processed so it can detect new entities
|
|
/// by comparing visible NPCs against the previous tick's knowledge state.
|
|
/// 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;
|
|
}
|
|
|
|
// Convert wire StableId back to bevy Entity via registry
|
|
let stable_id = StableId(visible.entity_id);
|
|
let Some(entity) = registry.to_entity(&stable_id) else {
|
|
continue;
|
|
};
|
|
|
|
// Check if this is a new entity (not in observer's knowledge graph)
|
|
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 {
|
|
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 (visible_npc_bits contains wire StableId values)
|
|
if visible_npc_bits.contains(&stable_id.0) {
|
|
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, compute_visibility_geometry};
|
|
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
|
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.init_resource::<VisibilityGeometry>();
|
|
world.init_resource::<ActivePerceptionMode>();
|
|
world
|
|
}
|
|
|
|
/// Run the observation pipeline: geometry -> snapshot -> emit -> interpret -> knowledge.
|
|
fn run_pipeline(world: &mut World) {
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems((
|
|
compute_visibility_geometry,
|
|
compute_observer_snapshot.after(compute_visibility_geometry),
|
|
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(),
|
|
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
|
crate::simulation::monologue::MonologueBuffer::default(),
|
|
))
|
|
.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(),
|
|
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
|
crate::simulation::monologue::MonologueBuffer::default(),
|
|
))
|
|
.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,
|
|
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
|
crate::simulation::monologue::MonologueBuffer::default(),
|
|
))
|
|
.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
|
|
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
|
crate::simulation::monologue::MonologueBuffer::default(),
|
|
))
|
|
.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,
|
|
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
|
crate::simulation::monologue::MonologueBuffer::default(),
|
|
))
|
|
.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"
|
|
);
|
|
}
|
|
}
|