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>
305 lines
11 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|