Files
settled-reach/server/src/perception/observation.rs
T
jpmschweitzerandClaude Sonnet 4.6 ce0df2f320 refactor(simulation): remove v0.1 content loading system (#655)
Delete the hand-authored YAML content pipeline (server/src/content/) superseded
by the v0.2 generator-first approach (D-122, D-128). Runtime ECS types that were
co-located with content loading have been extracted to dedicated simulation modules:

- simulation/triangle.rs: TriangleState, TriangleCrisisEventQueue, tick/resolve systems
- simulation/line_pool.rs: LinePoolIndex, AccessTier, TrustTier, Mood, LinePoolIndexResource
- simulation/knowledge_grant.rs: KnowledgeGrant, Prerequisites

Monologue systems (trigger_monologue, trigger_recognition_monologue,
trigger_event_monologue) now use hardcoded fallback lines only; the
ContentStoreResource branch and select_pool_line function are removed.

Deleted: content/{loader,types,line_pool,hot_reload,spawn,instantiation,entanglement,mod}.rs
Deleted: tests/{content_loading,content_runtime,content_scaling,template_instantiation,template_schema}.rs
Deleted: bin/line_preview.rs (v0.1 tool)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:07:57 +01:00

305 lines
11 KiB
Rust

//! 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 crate::bridge::types::*;
use crate::knowledge::{
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// 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: Res<EntityRegistry>,
mut observer_query: Query<
(Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>),
With<PlayerCharacter>,
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
mut engagement_query: Query<&mut EngagementRecord>,
) {
let Some(snapshot) = &buffer.snapshot else {
return;
};
let Ok((observer_entity, observer_kg, mut cognitive_delay)) = observer_query.single_mut()
else {
return;
};
// Collect visible stable IDs (wire entity_id is now StableId, not Entity::to_bits())
let visible_stable_ids: Vec<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.
// NEW entities (not in observer's KG) are routed through cognitive delay
// when the CognitiveDelay component is present (#423, D-060).
for visible in &snapshot.entities {
if matches!(visible.kind, EntityKind::Player) {
continue;
}
// Convert wire StableId back to bevy Entity via registry
let stable_id = crate::knowledge::types::StableId(visible.entity_id);
let Some(entity) = registry.to_entity(&stable_id) else {
continue;
};
// Increment observation time for engagement tracking (#570)
if let Ok(mut record) = engagement_query.get_mut(entity) {
record.observation_time_ticks += 1;
}
// Get tile position for knowledge tracking
let Ok(pos) = entity_positions.get(entity) else {
continue;
};
if observer_kg.knows_entity(&stable_id) {
// Known entity: immediate DirectObservation (position update)
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target: entity,
position: *pos,
},
});
} else if let Some(ref mut delay) = cognitive_delay {
// New entity + cognitive delay available: buffer recognition
if !delay.is_pending(&stable_id) {
// #450: Urgent trigger for anomalous entities (D-060)
let trigger = if anomaly_markers.get(entity).is_ok() {
RecognitionTrigger::Urgent
} else {
RecognitionTrigger::Normal
};
let delay_until = time.tick + trigger.delay_ticks();
delay.push(PendingRecognition {
target: entity,
stable_id,
position: *pos,
delay_until_tick: delay_until,
trigger,
monologue_fired: false,
});
tracing::debug!(
"Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}",
stable_id.0,
pos.x,
pos.y,
pos.z,
trigger,
delay_until,
);
}
} else {
// No CognitiveDelay component: immediate (backward compat)
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's StableId is still visible in the current snapshot
if !visible_stable_ids.contains(&stable_id.0) {
if let Some(entity) = registry.to_entity(stable_id) {
event_queue.push(KnowledgeEvent {
observer: observer_entity,
tick: time.tick,
event_type: KnowledgeEventType::LeftLOS { target: entity },
});
}
}
}
// Cancel pending cognitive delays for entities that left perception range.
// Without this, a delay could expire and emit a DirectObservation for an
// entity that is no longer visible (stale position, 1-tick flash).
if let Some(ref mut delay) = cognitive_delay {
let pending_ids: Vec<crate::knowledge::types::StableId> =
delay.pending().iter().map(|p| p.stable_id).collect();
for sid in pending_ids {
if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::Npc;
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;
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.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
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(),
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();
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_visibility_geometry,
compute_observer_snapshot.after(compute_visibility_geometry),
emit_observation_events.after(compute_observer_snapshot),
));
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,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.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_visibility_geometry,
compute_observer_snapshot.after(compute_visibility_geometry),
emit_observation_events.after(compute_observer_snapshot),
));
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(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
compute_visibility_geometry,
compute_observer_snapshot.after(compute_visibility_geometry),
emit_observation_events.after(compute_observer_snapshot),
));
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
assert!(
queue.is_empty(),
"should not emit observation events for the player entity itself"
);
}
}