//! 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, mode: Res, observer_query: Query<(&TilePosition, Option<&Facing>), With>, mut geometry: ResMut, ) { 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, geometry: Res, registry: Res, sound_queue: Option>, 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, >, all_entities: Query<( Entity, &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, Option<&AccessRule>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, sim_rng: Option>, ) { 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, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); let mut blocked_ids: BTreeSet = 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 = 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, visible_positions: &BTreeSet<(i32, i32)>, observer_z: i32, current_tick: u64, entities: &mut Vec, ) { 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, 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;