Three fixes to make the gameplay loop functional end-to-end: - Add Interactable component to NPC spawn so E-prompt detection works - Build monologue trigger system (enter_location + time_idle) with MonologueBuffer/MonologueState components, wire through ObserverSnapshot as current_monologue field, decode on client and display via HUD - Change PlayerAction::Interact from unit to struct variant carrying optional target_entity_id and verb fields Bumps protocol version from 4 to 5. Regenerates MessagePack fixtures. All 200 tests pass (170 unit + 30 integration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
8.2 KiB
Rust
234 lines
8.2 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::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: Res<EntityRegistry>,
|
|
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
|
mut event_queue: ResMut<KnowledgeEventQueue>,
|
|
entity_positions: Query<&TilePosition>,
|
|
) {
|
|
let Some(snapshot) = &buffer.snapshot else {
|
|
return;
|
|
};
|
|
|
|
let Ok((observer_entity, observer_kg)) = observer_query.single() 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
|
|
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;
|
|
};
|
|
|
|
// 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'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 },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|