Files
settled-reach/server/src/perception/interpretation.rs
T
jpmschweitzerandClaude Opus 4.6 d0663c4193 refactor(server): apply rustfmt and clippy suggestions
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>
2026-02-16 00:40:43 +01:00

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"
);
}
}