# Conflicts: # client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack # client/tests/fixtures/msgpack/snapshot_empty.msgpack # client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack # client/tests/fixtures/msgpack/snapshot_one_npc.msgpack # client/tests/fixtures/msgpack/snapshot_player.msgpack # client/tests/fixtures/msgpack/snapshot_v2_full.msgpack # server/Cargo.toml # server/src/bridge/text_renderer.rs # server/src/bridge/types.rs # server/src/perception/observer/mod.rs # server/src/simulation/path_follow.rs # server/tests/bridge_ipc.rs # server/tests/bridge_tcp.rs # server/tests/gen_fixtures.rs # server/tests/serialization.rs
516 lines
19 KiB
Rust
516 lines
19 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::dialogue::DialogueResponseBuffer;
|
|
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::rng::SimRng;
|
|
use crate::simulation::sound::SoundEventQueue;
|
|
use crate::simulation::stance::Stance;
|
|
use crate::simulation::time::SimulationTime;
|
|
|
|
/// 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>,
|
|
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>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
all_entities: Query<(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&PlayerCharacter>,
|
|
Option<&crate::npc::Npc>,
|
|
Option<&AccessRule>,
|
|
)>,
|
|
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
|
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,
|
|
)) = 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,
|
|
®istry,
|
|
)
|
|
})
|
|
.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, ®istry, 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();
|
|
|
|
// 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);
|
|
|
|
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: geometry.visible_tiles.clone(),
|
|
nearby_interactions,
|
|
current_monologue,
|
|
pending_recognitions,
|
|
dialogue_response,
|
|
blocked_entities,
|
|
scan_events,
|
|
sound_events,
|
|
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
|
});
|
|
}
|
|
|
|
/// 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>,
|
|
)>,
|
|
) -> (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) 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
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
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;
|