Files
settled-reach/server/src/perception/observer/mod.rs
T
jpmschweitzerandClaude Opus 4.6 dd3ead7a1b feat(simulation): protocol v14 — POI list, examine result, player knowledge wire types
Add three new ObserverSnapshot fields for client Sprint 18 tickets:
poi_list (Vec<PoiWire>) for minimap #151, examine_result
(Option<ExamineResultWire>) for #174, player_knowledge
(Option<PlayerKnowledgeWire>) for journal #264. POI list populated
live from KG-discovered PointOfInterest components. KG dump
serializes entity/fact knowledge with confidence, source, and state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:29:29 +01:00

671 lines
24 KiB
Rust

//! Observer visibility query system (#112)
//!
//! Two-stage pipeline:
//! 1. compute_visibility_geometry — FOV + vision cone → VisibilityGeometry resource
//! 2. compute_observer_snapshot — entity filtering + knowledge overlay → ObserverSnapshot
//!
//! D-017 perception modes swap the geometry producer via PerceptionQuery trait.
use bevy_ecs::prelude::*;
use std::collections::BTreeSet;
use crate::bridge::types::*;
use crate::knowledge::graph::filter_by_access;
use crate::knowledge::types::{AccessRule, KnowledgeState};
use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
use crate::perception::cognitive_delay::CognitiveDelay;
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::poi::PointOfInterest;
use crate::simulation::rng::SimRng;
use crate::simulation::sound::SoundEventQueue;
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
use crate::simulation::zone::ZoneMap;
/// Compute visibility geometry using the active perception mode.
/// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry.
///
/// System ordering: after validate_movement, before compute_observer_snapshot.
#[tracing::instrument(level = "debug", skip_all)]
pub fn compute_visibility_geometry(
walkability: Res<WalkabilityMap>,
mode: Res<ActivePerceptionMode>,
observer_query: Query<(&TilePosition, Option<&Facing>), With<PlayerCharacter>>,
mut geometry: ResMut<VisibilityGeometry>,
) {
let Ok((observer_pos, facing_opt)) = observer_query.single() else {
tracing::error!("compute_visibility_geometry: PlayerCharacter query failed");
return;
};
let facing = facing_opt
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
*geometry = mode.0.compute_geometry(observer_pos, facing, &walkability);
}
/// Assemble observer snapshot from precomputed geometry and entity state.
/// Stage 2 of the observer pipeline: entity filtering + knowledge overlay → snapshot.
///
/// System ordering: after compute_visibility_geometry + compute_nearby_interactions,
/// before advance_tick.
#[tracing::instrument(level = "debug", skip_all)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn compute_observer_snapshot(
time: Res<SimulationTime>,
geometry: Res<VisibilityGeometry>,
registry: Res<EntityRegistry>,
zone_map: Option<Res<ZoneMap>>,
sound_queue: Option<Res<SoundEventQueue>>,
mut observer_query: Query<
(
Entity,
&TilePosition,
Option<&Facing>,
&KnowledgeGraph,
&mut NearbyInteractionBuffer,
&mut MonologueBuffer,
Option<&Stance>,
Option<&CharacterArchetype>,
Option<&mut SprintAnomalyQueue>,
Option<&CognitiveDelay>,
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
),
With<PlayerCharacter>,
>,
all_entities: Query<(
Entity,
&TilePosition,
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
) {
let Ok((
observer_entity,
observer_pos,
facing_opt,
observer_kg,
mut interaction_buffer,
mut monologue_buffer,
stance_opt,
archetype_opt,
mut anomaly_queue_opt,
cognitive_delay_opt,
mut dialogue_response_opt,
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
return;
};
let facing = facing_opt
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
let archetype = archetype_opt.copied().unwrap_or_default();
// Collect player inventory (D-065 info boundary: only own items)
let player_inventory = registry
.to_stable(observer_entity)
.map(|player_sid| {
crate::simulation::inventory::collect_inventory_for(
player_sid,
&inventory_items,
&registry,
)
})
.unwrap_or_default();
// Resolve observer's StableId for component-level access control (#139, D-010)
let observer_stable_id = registry
.to_stable(observer_entity)
.unwrap_or(StableId(0));
let (mut entities, visible_ids, blocked_entities) =
filter_visible_entities(&geometry, &registry, observer_kg, observer_stable_id, &all_entities);
collect_remembered_entities(
observer_kg,
&visible_ids,
&geometry.visible_positions,
geometry.observer_z,
time.tick,
&mut entities,
);
// Sprint anomaly detection (#428, D-055)
// When sprinting, scan visible entities for Contradicted KG state.
// Queue the first match for delayed "double-take" monologue.
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
if let Some(anomaly_queue) = anomaly_queue_opt.as_mut() {
if !anomaly_queue.has_pending() {
for &wire_id in &visible_ids {
let stable_id = StableId(wire_id);
if let Some(knowledge) = observer_kg.entity_knowledge(&stable_id) {
if knowledge.state == KnowledgeState::Contradicted {
anomaly_queue.push_anomaly(wire_id, time.tick);
break; // First-in wins
}
}
}
}
}
}
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
day_phase: time.day_phase(),
tick_rate: time.tick_rate,
};
// Take interactions and apply Phase 2 verb filter (D-057, #422)
let mut nearby_interactions = interaction_buffer.take();
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
tracing::trace!(
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
time.tick,
visible_ids.len(),
entities.len() - visible_ids.len(),
geometry.visible_tiles.len(),
);
let current_monologue = monologue_buffer.take();
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
let scan_events = scan_event_buffer_opt
.as_mut()
.map(|buf| buf.take())
.unwrap_or_default();
// Drain NPC-to-NPC conversation events (#247, D-078)
let (conversation_events, conversation_ended) = conversation_buffer_opt
.as_mut()
.map(|buf| (buf.take_events(), buf.take_ended()))
.unwrap_or_default();
// Collect sound events audible to the observer (D-038, #124).
// Filter by D-018 range: only events the player can hear based on distance.
let sound_events = if let Some(ref queue) = sound_queue {
queue.audible_at(observer_pos).cloned().collect()
} else {
Vec::new()
};
// Build pending recognitions from CognitiveDelay (#423, D-060)
let pending_recognitions = cognitive_delay_opt
.map(|delay| {
delay
.pending()
.iter()
.map(|p| {
let (rx, ry, rz) = p.position.to_render_coords();
PendingRecognitionWire {
entity_id: p.stable_id.0,
x: rx,
y: ry,
z: rz,
remaining_ticks: p.delay_until_tick.saturating_sub(time.tick),
total_delay_ticks: p.trigger.delay_ticks(),
}
})
.collect()
})
.unwrap_or_default();
// Sort entities by entity_id for deterministic snapshot ordering (#457)
entities.sort_by_key(|e| e.entity_id);
// Enrich tiles with zone_id from ZoneMap (D-077, D-073)
let visible_tiles = match zone_map.as_deref() {
Some(zm) => geometry
.visible_tiles
.iter()
.map(|t| VisibleTile {
zone_id: zm.zone_at(t.x, t.y, t.z),
..t.clone()
})
.collect(),
None => geometry.visible_tiles.clone(),
};
// Build follow-mode state for client HUD (#241)
let follow_state = follow_target_opt.and_then(|ft| {
let target_wire_id = registry.to_stable(ft.target)?.0;
// Distance computed from current positions
let all_query_iter = all_entities.iter();
let target_pos = all_query_iter
.filter_map(|(e, pos, _, _, _, _)| (e == ft.target).then_some(pos))
.next()?;
let distance = observer_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
let has_los = target_pos.z == geometry.observer_z
&& geometry
.visible_positions
.contains(&(target_pos.x, target_pos.y));
Some(crate::simulation::follow::FollowStateWire {
target_entity_id: target_wire_id,
distance,
has_los,
proximity_ticks: ft.proximity_ticks,
})
});
// Collect discovered POIs for minimap (#151, D-013)
// A POI is "discovered" if the observer's KG contains fact "poi.{poi_id}".
let poi_list: Vec<PoiWire> = poi_query
.iter()
.filter(|poi| observer_kg.knows_fact(&poi.fact_id()))
.map(|poi| PoiWire {
poi_id: poi.poi_id.clone(),
name: poi.name.clone(),
x: poi.position.x,
y: poi.position.y,
z: poi.position.z,
category: poi.category,
})
.collect();
// Build player knowledge dump for journal panel (#264, D-041).
// Sends full KG state — client-side filtering for display grouping.
let player_knowledge = {
let kg_entities: Vec<KnownEntityWire> = observer_kg
.known_entities_iter()
.map(|(sid, ek)| {
let name = ek
.known_attributes
.get("name")
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
let source = match &ek.source {
crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => {
"DirectObservation".to_string()
}
crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(),
crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => {
format!("ToldBy({})", source_id.0)
}
crate::knowledge::types::KnowledgeSource::Inferred { .. } => {
"Inferred".to_string()
}
crate::knowledge::types::KnowledgeSource::Background => {
"Background".to_string()
}
};
KnownEntityWire {
entity_id: sid.0,
name,
confidence: ek.confidence,
source,
state: ek.state,
relationship: ek.relationship,
last_observed_tick: ek.last_observed_tick,
}
})
.collect();
let kg_facts: Vec<KnownFactWire> = observer_kg
.known_facts_iter()
.map(|(fid, fk)| {
let source = match &fk.source {
crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => {
"DirectObservation".to_string()
}
crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(),
crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => {
format!("ToldBy({})", source_id.0)
}
crate::knowledge::types::KnowledgeSource::Inferred { .. } => {
"Inferred".to_string()
}
crate::knowledge::types::KnowledgeSource::Background => {
"Background".to_string()
}
};
KnownFactWire {
fact_id: fid.0.clone(),
confidence: fk.confidence,
source,
state: fk.state,
acquired_tick: fk.acquired_tick,
}
})
.collect();
if kg_entities.is_empty() && kg_facts.is_empty() {
None
} else {
Some(PlayerKnowledgeWire {
entities: kg_entities,
facts: kg_facts,
})
}
};
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
game_time,
player_facing: facing,
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
player_inventory,
entities,
visible_tiles,
nearby_interactions,
current_monologue,
pending_recognitions,
dialogue_response,
blocked_entities,
scan_events,
conversation_events,
conversation_ended,
follow_state,
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
poi_list,
examine_result: None, // Populated by examine system when #242 lands
player_knowledge,
});
}
/// Filter entities by visibility using precomputed geometry.
/// Returns (visible entities, set of visible wire IDs, blocked entity IDs).
/// Blocked entities are on the same z-level but not in visible_positions (#514).
#[allow(clippy::type_complexity)]
fn filter_visible_entities(
geometry: &VisibilityGeometry,
registry: &EntityRegistry,
observer_kg: &KnowledgeGraph,
observer_stable_id: StableId,
all_entities: &Query<(
Entity,
&TilePosition,
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
for (entity, pos, is_player, is_npc, access_rule, tell_opt) in all_entities.iter() {
if pos.z != geometry.observer_z {
continue;
}
// Resolve wire ID early — needed for both visible and blocked paths
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity not in EntityRegistry");
entity.to_bits()
});
if !geometry.visible_positions.contains(&(pos.x, pos.y)) {
// Same z-level but not visible — blocked by LOS or outside vision cone.
// Exclude the player entity (always at origin, always visible).
if is_player.is_none() {
blocked_ids.insert(wire_id);
}
continue;
}
let (rx, ry, rz) = pos.to_render_coords();
let kind = if is_player.is_some() {
EntityKind::Player
} else if is_npc.is_some() {
EntityKind::Npc
} else {
EntityKind::Object
};
let sector = geometry
.sector_lookup
.get(&(pos.x, pos.y))
.copied()
.unwrap_or(VisibilitySector::Peripheral);
let relationship = if is_player.is_some() {
RelationshipState::Known // Self
} else if let Some(stable_id) = registry.to_stable(entity) {
// D-010 principle 2: check access control before exposing relationship (#139)
let access_granted = match access_rule {
Some(rule) => filter_by_access(observer_stable_id, stable_id, &rule.0, observer_kg),
None => true, // No AccessRule → Public (default)
};
if access_granted {
observer_kg.relationship_with(&stable_id)
} else {
RelationshipState::Unknown // Access denied — redact relationship data
}
} else {
RelationshipState::Unknown
};
let tell_state = tell_opt.and_then(|t| t.category);
visible_ids.insert(wire_id);
entities.push(VisibleEntity {
entity_id: wire_id,
x: rx,
y: ry,
z: rz,
kind,
visibility: sector,
relationship,
observation: EntityVisibility::Visible,
tell_state,
});
}
// BTreeSet iteration is sorted — deterministic output guaranteed
let blocked_vec: Vec<u64> = blocked_ids.into_iter().collect();
(entities, visible_ids, blocked_vec)
}
/// Collect remembered entities from the knowledge graph — entities the observer
/// knows about but can't currently see. Filters out: already-visible entities,
/// entities without known positions, wrong z-level, visible-tile ghosts, and
/// transient Direct-confidence inconsistencies.
fn collect_remembered_entities(
observer_kg: &KnowledgeGraph,
visible_ids: &BTreeSet<u64>,
visible_positions: &BTreeSet<(i32, i32)>,
observer_z: i32,
current_tick: u64,
entities: &mut Vec<VisibleEntity>,
) {
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
if visible_ids.contains(&stable_id.0) {
continue;
}
let Some(position) = knowledge.last_known_position else {
continue;
};
if position.z != observer_z {
continue;
}
// Tile is visible but entity isn't there — player knows it moved
if visible_positions.contains(&(position.x, position.y)) {
continue;
}
// Direct confidence = should be in LOS; skip transient inconsistency
if knowledge.confidence == KnowledgeConfidence::Direct {
continue;
}
let (rx, ry, rz) = position.to_render_coords();
debug_assert!(
knowledge.last_observed_tick <= current_tick,
"last_observed_tick {} > current tick {}",
knowledge.last_observed_tick,
current_tick,
);
let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick);
entities.push(VisibleEntity {
entity_id: stable_id.0,
x: rx,
y: ry,
z: rz,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: knowledge.relationship,
tell_state: None, // Remembered entities have no live tell state
observation: EntityVisibility::Remembered {
confidence: knowledge.confidence,
age_ticks,
},
});
}
}
/// Phase 2 verb filter: KG-gated observer-side verb processing (#422, D-057).
///
/// Runs after Phase 1 (simulation-level verb computation) and applies:
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
///
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
/// to filter, augment, and relabel. This separation keeps D-010 principle 1
/// (info boundary) clean — simulation doesn't know what the observer knows.
fn apply_phase2_verb_filter(
interactions: &mut [NearbyInteraction],
observer_kg: &KnowledgeGraph,
archetype: CharacterArchetype,
) {
for interaction in interactions.iter_mut() {
let stable_id = StableId(interaction.entity_id);
let knowledge = observer_kg.entity_knowledge(&stable_id);
// --- Contradiction marking ---
// If observer's knowledge of this entity is Contradicted, mark the
// interaction. Client renders a visual indicator (D-041).
if let Some(k) = knowledge {
if k.state == KnowledgeState::Contradicted {
interaction.contradicted = true;
}
}
// --- NPC-specific Phase 2 ---
if interaction.entity_type == EntityKind::Npc {
let relationship = observer_kg.relationship_with(&stable_id);
// POI priority flip (D-060): Observe first, Talk second
if relationship == RelationshipState::PersonOfInterest {
for verb in &mut interaction.verbs {
match verb.kind {
VerbKind::ExamineNpc => verb.priority = 1,
VerbKind::Talk => verb.priority = 2,
_ => {}
}
}
}
// Confront injection: available when observer has KnowsDetails+
// on this NPC and is at close range (distance ≤ 2).
if interaction.distance <= 2 {
let has_details = knowledge
.map(|k| k.confidence >= KnowledgeConfidence::KnowsDetails)
.unwrap_or(false);
if has_details {
// Priority 3 = after Talk/ExamineNpc in normal case,
// after ExamineNpc/Talk in POI case. Always the escalation option.
interaction.verbs.push(VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
});
}
}
}
// --- Archetype label relabeling ---
// Phase 2 swaps verb labels based on character archetype.
// The VerbKind stays the same (same handler), only the display label changes.
// This implements D-057: "Character differentiation via Phase 2 observer
// filter, not separate verb systems."
for verb in &mut interaction.verbs {
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind)
{
verb.label = label.into();
}
}
// Re-sort after priority changes and verb additions
interaction
.verbs
.sort_by_key(|v| (v.priority, v.kind as u8));
}
}
/// Archetype-specific verb label overrides (#422, D-057).
///
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
/// combination, or None to keep the Phase 1 default label.
///
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
/// Add match arms here for future archetype-specific labels.
fn archetype_verb_label(
archetype: CharacterArchetype,
object_type: Option<ObjectType>,
kind: VerbKind,
) -> Option<&'static str> {
match (archetype, object_type, kind) {
// Smuggler: Container verbs — physical manipulation vocabulary
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => {
Some("Stash")
}
// Detective: Container verbs — investigation vocabulary
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => {
Some("Scan")
}
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => {
Some("Flag")
}
// All other combinations: keep Phase 1 default label
_ => None,
}
}
#[cfg(test)]
mod tests;