//! Observer visibility query system (#112) //! //! Replaces the unfiltered `generate_snapshot` with a visibility-aware version. //! Combines shadowcasting + vision cone to determine what the observer can see, //! then populates ObserverSnapshot v2 with only visible entities and tiles. use bevy_ecs::prelude::*; use std::collections::HashSet; use crate::bridge::types::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::time::SimulationTime; /// Compute observer snapshot with LOS filtering and vision cone. /// /// System ordering: after validate_movement, before advance_tick. /// Replaces bridge::generate_snapshot. pub fn compute_observer_snapshot( time: Res, walkability: Res, registry: Res, observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With>, all_entities: Query<( Entity, &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, )>, mut buffer: ResMut, ) { let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else { return; }; let facing = facing_opt .map(|f| f.0) .unwrap_or(FacingDirection::default()); let config = VisionConeConfig::default(); let z = observer_pos.z; // Step 1: Compute raw FOV using symmetric shadowcasting let fov = compute_fov( |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), observer_pos.x, observer_pos.y, config.forward_range, z, ); // Step 2: Apply vision cone to get sector-tagged tiles let cone_tiles = apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); // Step 3: Build visible_tiles for the snapshot let visible_tiles: Vec = cone_tiles .iter() .map(|&(x, y, sector)| VisibleTile { x, y, z, visibility: sector, }) .collect(); // Step 4: Build lookup set for fast entity visibility check let visible_positions: HashSet<(i32, i32)> = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); // Build sector lookup (position -> sector) let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles .iter() .map(|&(x, y, sector)| ((x, y), sector)) .collect(); // Step 5: Filter entities by visibility, overlay knowledge let mut entities = Vec::new(); let mut visible_entity_bits: HashSet = HashSet::new(); for (entity, pos, is_player, is_npc) in all_entities.iter() { // Different z-level: not visible if pos.z != z { continue; } // Not in visible tile set: not visible if !visible_positions.contains(&(pos.x, pos.y)) { 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 = sector_lookup .get(&(pos.x, pos.y)) .copied() .unwrap_or(VisibilitySector::Peripheral); // Look up relationship from knowledge graph (D-033 entity color) let relationship = if is_player.is_some() { RelationshipState::Known // Self } else if let Some(stable_id) = registry.to_stable(entity) { observer_kg.relationship_with(&stable_id) } else { RelationshipState::Unknown }; visible_entity_bits.insert(entity.to_bits()); entities.push(VisibleEntity { entity_id: entity.to_bits(), x: rx, y: ry, z: rz, kind, visibility: sector, relationship, observation: EntityVisibility::Visible, }); } // Step 6: Add remembered entities from knowledge graph (#366) // Entities the observer knows about but can't currently see. for (stable_id, knowledge) in observer_kg.known_entities_iter() { // Skip if currently visible (already in the entity list) if let Some(entity) = registry.to_entity(stable_id) { if visible_entity_bits.contains(&entity.to_bits()) { continue; } } // Skip if no known position (never directly observed) let Some(position) = knowledge.last_known_position else { continue; }; // Skip if remembered position is on a different z-level than the observer if position.z != z { continue; } // Skip if the remembered tile is currently visible — if the player // can see the tile and the entity isn't there, don't show a ghost. if visible_positions.contains(&(position.x, position.y)) { continue; } // Direct confidence means the entity should be in LOS — if it isn't, // that's a transient data inconsistency. Skip rather than show a ghost. if knowledge.confidence == KnowledgeConfidence::Direct { continue; } let (rx, ry, rz) = position.to_render_coords(); let age_ticks = time.tick.saturating_sub(knowledge.last_observed_tick); let entity_id = registry .to_entity(stable_id) .map(|e| e.to_bits()) .unwrap_or(stable_id.0); entities.push(VisibleEntity { entity_id, x: rx, y: ry, z: rz, kind: EntityKind::Npc, // Remembered entities are NPCs (only NPCs are tracked) visibility: VisibilitySector::Forward, // Not meaningful for remembered entities relationship: knowledge.relationship, observation: EntityVisibility::Remembered { confidence: knowledge.confidence, age_ticks, }, }); } // Step 7: Build GameTime from SimulationTime let game_time = GameTime { day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), paused: time.paused, }; tracing::trace!( "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", time.tick, visible_entity_bits.len(), entities.len() - visible_entity_bits.len(), visible_tiles.len(), ); // Step 8: Assemble snapshot (v3: added relationship + observation fields) buffer.snapshot = Some(ObserverSnapshot { version: 3, tick: time.tick, game_time, player_facing: facing, entities, visible_tiles, }); } #[cfg(test)] mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::vision_cone::Facing; use bevy_ecs::world::World; /// Helper: set up a test world with player, walkability map, and knowledge resources 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::(); world.init_resource::(); world } #[test] fn player_always_visible_in_snapshot() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); assert_eq!(snapshot.version, 3); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); } #[test] fn npc_in_los_visible() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), )); // NPC directly north of player (in forward cone) world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.entities.len(), 2); let npc = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible"); assert_eq!(npc.visibility, VisibilitySector::Forward); assert_eq!(npc.observation, EntityVisibility::Visible); } #[test] fn npc_behind_wall_not_visible() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), )); // Wall between player and NPC let mut walkability = world.resource_mut::(); walkability.set_walkable(&TilePosition::new(16, 14, 0), false); // NPC behind the wall world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // Only player should be visible, not the NPC behind the wall let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert!(npcs.is_empty(), "NPC behind wall should not be visible"); } #[test] fn npc_behind_player_not_visible() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), )); // NPC far behind player (south, in blind spot) world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert!(npcs.is_empty(), "NPC in blind spot should not be visible"); } #[test] fn different_z_level_not_visible() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), )); // NPC on different z-level world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert!(npcs.is_empty(), "NPC on different z should not be visible"); } #[test] fn game_time_populated() { let mut world = setup_world(32, 32); world.insert_resource(SimulationTime { tick: 7200, // 720 minutes = Evening paused: true, }); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.game_time.time_of_day, 720); assert_eq!( snapshot.game_time.day_phase, crate::simulation::time::DayPhase::Evening ); assert!(snapshot.game_time.paused); } #[test] fn visible_tiles_populated() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert!( !snapshot.visible_tiles.is_empty(), "should have visible tiles" ); // Observer's tile should be in the list let has_observer_tile = snapshot .visible_tiles .iter() .any(|t| t.x == 16 && t.y == 16 && t.z == 0); assert!(has_observer_tile, "observer tile should be visible"); } #[test] fn visible_npc_has_relationship_from_knowledge() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) .id(); let npc_sid = registry.register(npc); // Player knows NPC is hostile let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::Hostile); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let npc_entity = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible"); assert_eq!(npc_entity.relationship, RelationshipState::Hostile); assert_eq!(npc_entity.observation, EntityVisibility::Visible); } #[test] fn remembered_entity_appears_as_ghost() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC exists far behind the player (not visible) let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) .id(); let npc_sid = registry.register(npc); // Player previously saw NPC at (16, 28) — behind the player (south), // well beyond peripheral range. The tile is NOT in the player's FOV. let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); kg.observe_entity_leaving_los(&npc_sid, 60); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )) .id(); registry.register(player); world.insert_resource(registry); world.insert_resource(SimulationTime { tick: 100, paused: false }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // Should have player (visible) + NPC (remembered) let remembered: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert_eq!(remembered.len(), 1, "should have one remembered entity"); assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); // Remembered entity at last_known_position (16, 28), not actual (16, 30) assert_eq!(remembered[0].x, 16.5); assert_eq!(remembered[0].y, 28.5); if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 } } #[test] fn direct_confidence_not_shown_as_remembered() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC exists but not in LOS let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) .id(); let npc_sid = registry.register(npc); // Knowledge still shows Direct (transient inconsistency) let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); // Still Direct — don't show as ghost let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )) .id(); registry.register(player); // Wall blocks actual NPC position let mut walkability = world.resource_mut::(); walkability.set_walkable(&TilePosition::new(16, 12, 0), false); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let remembered: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert!( remembered.is_empty(), "Direct-confidence entities should not appear as remembered ghosts" ); } #[test] fn remembered_entity_on_visible_tile_not_shown() { // If the player can see a tile and the entity isn't there, // don't show a ghost — the player knows it moved. let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) .id(); let npc_sid = registry.register(npc); // Player remembers NPC at (16, 15) — a tile the player can currently see let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); kg.observe_entity_leaving_los(&npc_sid, 60); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )) .id(); registry.register(player); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let remembered: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert!( remembered.is_empty(), "ghost should not appear on a tile the player can currently see" ); } #[test] fn remembered_entity_different_z_not_shown() { // Remembered entity on a different z-level should not appear let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) .id(); let npc_sid = registry.register(npc); // Player remembers NPC at z=1, but player is at z=0 let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); kg.observe_entity_leaving_los(&npc_sid, 60); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )) .id(); registry.register(player); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let remembered: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert!( remembered.is_empty(), "remembered entity on different z-level should not appear in snapshot" ); } #[test] fn knowledge_without_position_not_shown() { // Entity known via gossip (no last_known_position) should not appear let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) .id(); let npc_sid = registry.register(npc); // Player knows about NPC but has never seen it (no position) let mut kg = KnowledgeGraph::new(); // Insert knowledge manually without a position kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { last_known_position: None, last_observed_tick: 0, last_updated_tick: 50, confidence: KnowledgeConfidence::KnowsOf, source: crate::knowledge::KnowledgeSource::Background, state: crate::knowledge::KnowledgeState::Active, relationship: RelationshipState::PersonOfInterest, known_attributes: std::collections::BTreeMap::new(), }); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, )) .id(); registry.register(player); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let remembered: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert!( remembered.is_empty(), "entity without last_known_position should not appear as ghost" ); } #[test] fn multiple_npcs_in_los_all_visible() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), )); // Three NPCs in front of player, no walls world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // Player + 3 NPCs = 4 entities assert_eq!(snapshot.entities.len(), 4); let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 3); assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); } #[test] fn npc_behind_wall_excluded_from_multi_entity_snapshot() { let mut world = setup_world(32, 32); // Wall at (16,14) world .resource_mut::() .set_walkable(&TilePosition::new(16, 14, 0), false); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), )); // NPC 1: behind wall (should be hidden) world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0))); // NPC 2: to the side, no wall (should be visible) world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); // NPC 3: also visible world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); schedule.run(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // Player + 2 visible NPCs = 3 (NPC behind wall excluded) let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); } }