//! NPC vision system (#115, D-011). //! //! Active-tier NPCs use the same symmetric shadowcasting as the player. //! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known //! positions and zone inferences ("saw you enter building → knows you're //! inside"). //! //! ## System ordering //! //! 1. `compute_npc_vision` — after movement/tier updates, before snapshot //! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events //! 3. `degrade_npc_inferences` — once per game-minute (D-031) //! //! ## Performance //! //! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within //! D-026 Active tier budget by architecture review. use std::collections::{BTreeMap, BTreeSet}; use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use crate::knowledge::types::StableId; use crate::knowledge::{ EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, }; use crate::npc::Npc; 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::spatial::{NaiveSpatialIndex, SpatialIndex}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; /// NPC vision range in tiles (matches player forward range from VisionConeConfig). pub const NPC_VISION_RANGE: i32 = 20; /// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour. pub const INFERENCE_DEGRADE_TICKS: u64 = 600; // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Current field-of-view results for an NPC. /// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010). #[derive(Component, Debug, Clone, Default)] pub struct NpcVisionState { /// StableIds of entities currently in this NPC's LOS. pub visible_entities: BTreeSet, /// Whether the player character is currently visible. pub player_visible: bool, } /// Persistent memory of entities this NPC has seen. /// Survives after entities leave LOS (D-011: "saw you enter building → /// knows you're inside"). #[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] pub struct NpcMemory { /// Last known position + tick for entities this NPC has seen. /// Key: StableId of the observed entity. pub last_known: BTreeMap, } /// Record of last-known position for a single entity. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastKnownEntry { /// Position where entity was last seen. pub position: TilePosition, /// Tick when the entity was last observed. pub observed_tick: u64, /// Zone inference: if entity was last seen before leaving LOS, /// the NPC infers they are still nearby. pub zone_inference: Option, } /// Inference that an entity is still near a position based on last observation. /// Degrades after `degrades_at_tick`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZoneInference { /// The position where the entity was last seen. pub last_seen_position: TilePosition, /// Tick when the entity was seen at this position. pub observed_tick: u64, /// Tick at which this inference degrades (NPC stops assuming entity is here). pub degrades_at_tick: u64, } // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- /// Compute NPC field-of-view for all Active-tier NPCs. /// /// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011), /// apply vision cone if the NPC has a `Facing` component, then check which entities /// from the spatial index are at visible tiles. pub fn compute_npc_vision( walkability: Option>, registry: Res, spatial_index: Res, player_query: Query>, mut npc_query: Query< (Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState), (With, With), >, entity_positions: Query<&TilePosition>, ) { let Some(walkability) = walkability else { return; }; let player_entity = player_query.iter().next(); let player_stable_id = player_entity.and_then(|e| registry.to_stable(e)); let config = VisionConeConfig::default(); for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() { let z = npc_pos.z; // Run symmetric shadowcasting — same algorithm as player (D-011, D-035) let fov = compute_fov( |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), npc_pos.x, npc_pos.y, NPC_VISION_RANGE, z, ); // Collect visible tile positions — apply vision cone if NPC has facing let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing { let cone_tiles = apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config); cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect() } else { // No facing → omnidirectional vision (full FOV) fov.visible_tiles().collect() }; // Find entities at visible positions via spatial index let mut new_visible = BTreeSet::new(); let mut player_vis = false; // Single-pass query: all entities within vision range including own tile let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32); for entity in candidates { if entity == npc_entity { continue; } let Ok(entity_pos) = entity_positions.get(entity) else { continue; }; if entity_pos.z != z { continue; } if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) { continue; } let Some(stable_id) = registry.to_stable(entity) else { continue; }; new_visible.insert(stable_id); if Some(stable_id) == player_stable_id { player_vis = true; } } vision_state.visible_entities = new_visible; vision_state.player_visible = player_vis; } } /// Emit knowledge events when NPCs gain or lose sight of the player. /// /// Mirrors the player's `emit_observation_events` pattern but scoped to /// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState` /// for direct query by downstream systems (#244 awareness) without flooding /// the knowledge event queue. /// /// Also updates `NpcMemory` with last-known positions and zone inferences. pub fn emit_npc_vision_events( time: Res, registry: Res, mut event_queue: ResMut, player_query: Query<(Entity, &TilePosition), With>, mut npc_query: Query< ( Entity, &NpcVisionState, &mut NpcMemory, Option<&KnowledgeGraph>, ), (With, With), >, ) { let Ok((player_entity, player_pos)) = player_query.single() else { return; }; let Some(player_sid) = registry.to_stable(player_entity) else { return; }; for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() { if vision_state.player_visible { // Player is in LOS — update memory and emit DirectObservation memory.last_known.insert( player_sid, LastKnownEntry { position: *player_pos, observed_tick: time.tick, zone_inference: None, // Active observation clears inference }, ); event_queue.push(KnowledgeEvent { observer: npc_entity, tick: time.tick, event_type: KnowledgeEventType::DirectObservation { target: player_entity, position: *player_pos, }, }); } else { // Player NOT in LOS — check if they WERE Direct (just left) let was_direct = knowledge_graph .and_then(|kg| { kg.entity_knowledge(&player_sid) .map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct) }) .unwrap_or(false); if was_direct { // Player just left this NPC's LOS — emit LeftLOS event_queue.push(KnowledgeEvent { observer: npc_entity, tick: time.tick, event_type: KnowledgeEventType::LeftLOS { target: player_entity, }, }); // Create zone inference — NPC remembers where they last saw the player if let Some(entry) = memory.last_known.get_mut(&player_sid) { entry.zone_inference = Some(ZoneInference { last_seen_position: entry.position, observed_tick: entry.observed_tick, degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS, }); } } } } } /// Degrade stale zone inferences in NPC memory. /// /// Runs once per game-minute (every 10 ticks per D-031). When a zone /// inference passes its degradation tick, the inference is removed. pub fn degrade_npc_inferences( time: Res, mut npc_query: Query<&mut NpcMemory, (With, With)>, ) { if !time.tick.is_multiple_of(TICKS_PER_GAME_MINUTE) { return; } for mut memory in npc_query.iter_mut() { for entry in memory.last_known.values_mut() { if let Some(ref inference) = entry.zone_inference { if time.tick >= inference.degrades_at_tick { entry.zone_inference = None; } } } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use bevy_ecs::world::World; 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.init_resource::(); world.init_resource::(); world.init_resource::(); world } fn pos(x: i32, y: i32) -> TilePosition { TilePosition::new(x, y, 0) } // --- compute_npc_vision tests --- #[test] fn npc_sees_nearby_entity_in_open_field() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); // NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); let npc_sid = registry.register(npc); spatial.update(npc, pos(16, 16)); let target = world.spawn(pos(16, 14)).id(); let target_sid = registry.register(target); spatial.update(target, pos(16, 14)); // Player entity (required for player_stable_id lookup) let player = world.spawn((PlayerCharacter, pos(0, 0))).id(); registry.register(player); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( vision.visible_entities.contains(&target_sid), "NPC should see nearby entity in open field" ); assert!(!vision.player_visible, "player is far away"); let _ = npc_sid; // registered for completeness } #[test] fn npc_cannot_see_through_wall() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); // Target behind a wall let target = world.spawn(pos(16, 14)).id(); let target_sid = registry.register(target); spatial.update(target, pos(16, 14)); // Wall between NPC and target — scope the mutable resource borrow so it // is released before the world.insert_resource calls below. { let mut walkability = world.resource_mut::(); walkability.set_walkable(&pos(16, 15), false); } world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( !vision.visible_entities.contains(&target_sid), "NPC should not see entity behind wall" ); } #[test] fn npc_detects_player_visible() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); registry.register(player); spatial.update(player, pos(16, 14)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!(vision.player_visible, "NPC should detect player in LOS"); } #[test] fn npc_does_not_see_entity_on_different_z_level() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); // Target at same x/y but different z let target = world.spawn(TilePosition::new(16, 14, 1)).id(); let target_sid = registry.register(target); spatial.update(target, TilePosition::new(16, 14, 1)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( !vision.visible_entities.contains(&target_sid), "NPC should not see entity on different z-level" ); } #[test] fn npc_does_not_see_entity_beyond_range() { let mut world = setup_world(64, 64); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); // Target beyond NPC_VISION_RANGE (20 tiles) let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id(); let target_sid = registry.register(target); spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( !vision.visible_entities.contains(&target_sid), "NPC should not see entity beyond vision range" ); } #[test] fn npc_does_not_see_self() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); let npc_sid = registry.register(npc); spatial.update(npc, pos(16, 16)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( !vision.visible_entities.contains(&npc_sid), "NPC should not include itself in visible entities" ); } #[test] fn npc_sees_non_npc_entity_on_same_tile() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); // NPC at (16, 16) let npc = world .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); // Non-NPC entity on the same tile (e.g. dropped item) let item = world.spawn(pos(16, 16)).id(); let item_sid = registry.register(item); spatial.update(item, pos(16, 16)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( vision.visible_entities.contains(&item_sid), "NPC should see non-NPC entity sharing its tile" ); } #[test] fn background_npc_not_processed() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let mut spatial = NaiveSpatialIndex::new(); // Background NPC — should NOT have its vision computed let npc = world .spawn(( Npc, crate::simulation::tier::BackgroundSim, pos(16, 16), NpcVisionState::default(), )) .id(); registry.register(npc); spatial.update(npc, pos(16, 16)); let target = world.spawn(pos(16, 14)).id(); let target_sid = registry.register(target); spatial.update(target, pos(16, 14)); world.insert_resource(registry); world.insert_resource(spatial); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_npc_vision); schedule.run(&mut world); let vision = world.get::(npc).unwrap(); assert!( vision.visible_entities.is_empty(), "Background NPC should not have vision computed" ); let _ = target_sid; } // --- emit_npc_vision_events tests --- #[test] fn npc_seeing_player_emits_direct_observation() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); let player_sid = registry.register(player); let mut vision = NpcVisionState::default(); vision.visible_entities.insert(player_sid); vision.player_visible = true; let npc = world .spawn(( Npc, ActiveSim, pos(16, 16), vision, NpcMemory::default(), KnowledgeGraph::new(), )) .id(); registry.register(npc); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(emit_npc_vision_events); schedule.run(&mut world); let queue = world.resource::(); assert!( !queue.is_empty(), "should emit DirectObservation for player" ); let event = &queue.events[0]; assert_eq!(event.observer, npc); assert!(matches!( event.event_type, KnowledgeEventType::DirectObservation { target, .. } if target == player )); } #[test] fn npc_losing_player_emits_left_los() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); let player_sid = registry.register(player); // NPC that had Direct knowledge of player (player was just visible) let mut kg = KnowledgeGraph::new(); kg.observe_entity(player_sid, pos(16, 14), 50); // Memory with last known position let mut memory = NpcMemory::default(); memory.last_known.insert( player_sid, LastKnownEntry { position: pos(16, 14), observed_tick: 50, zone_inference: None, }, ); // Vision state: player NOT visible now let vision = NpcVisionState::default(); let npc = world .spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg)) .id(); registry.register(npc); world.insert_resource(registry); let mut time = SimulationTime::default(); time.tick = 60; world.insert_resource(time); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(emit_npc_vision_events); schedule.run(&mut world); let queue = world.resource::(); let has_left_los = queue.events.iter().any(|e| { matches!( e.event_type, KnowledgeEventType::LeftLOS { target } if target == player ) }); assert!( has_left_los, "should emit LeftLOS when player leaves NPC LOS" ); // Check zone inference was created let npc_memory = world.get::(npc).unwrap(); let entry = npc_memory.last_known.get(&player_sid).unwrap(); assert!( entry.zone_inference.is_some(), "zone inference should be created when player leaves LOS" ); assert_eq!( entry.zone_inference.as_ref().unwrap().degrades_at_tick, 60 + INFERENCE_DEGRADE_TICKS ); } #[test] fn npc_updates_memory_on_player_observation() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world.spawn((PlayerCharacter, pos(10, 10))).id(); let player_sid = registry.register(player); let mut vision = NpcVisionState::default(); vision.visible_entities.insert(player_sid); vision.player_visible = true; let npc = world .spawn(( Npc, ActiveSim, pos(16, 16), vision, NpcMemory::default(), KnowledgeGraph::new(), )) .id(); registry.register(npc); let mut time = SimulationTime::default(); time.tick = 100; world.insert_resource(time); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(emit_npc_vision_events); schedule.run(&mut world); let memory = world.get::(npc).unwrap(); let entry = memory.last_known.get(&player_sid).unwrap(); assert_eq!(entry.position, pos(10, 10)); assert_eq!(entry.observed_tick, 100); assert!( entry.zone_inference.is_none(), "active observation = no inference" ); } // --- degrade_npc_inferences tests --- #[test] fn inference_degrades_after_threshold() { let mut world = setup_world(32, 32); let player_sid = StableId(1); let mut memory = NpcMemory::default(); memory.last_known.insert( player_sid, LastKnownEntry { position: pos(10, 10), observed_tick: 50, zone_inference: Some(ZoneInference { last_seen_position: pos(10, 10), observed_tick: 50, degrades_at_tick: 100, }), }, ); let npc = world.spawn((Npc, ActiveSim, memory)).id(); // Tick 90 (before degradation, on minute boundary) let mut time = SimulationTime::default(); time.tick = 90; world.insert_resource(time); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(degrade_npc_inferences); schedule.run(&mut world); let mem = world.get::(npc).unwrap(); assert!( mem.last_known[&player_sid].zone_inference.is_some(), "inference should survive before degradation tick" ); // Tick 100 (degradation tick, on minute boundary) let mut time = SimulationTime::default(); time.tick = 100; world.insert_resource(time); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(degrade_npc_inferences); schedule2.run(&mut world); let mem = world.get::(npc).unwrap(); assert!( mem.last_known[&player_sid].zone_inference.is_none(), "inference should be removed at degradation tick" ); } #[test] fn inference_degradation_skips_non_minute_ticks() { let mut world = setup_world(32, 32); let player_sid = StableId(1); let mut memory = NpcMemory::default(); memory.last_known.insert( player_sid, LastKnownEntry { position: pos(10, 10), observed_tick: 50, zone_inference: Some(ZoneInference { last_seen_position: pos(10, 10), observed_tick: 50, degrades_at_tick: 55, // already past }), }, ); let npc = world.spawn((Npc, ActiveSim, memory)).id(); // Tick 57 — past degradation but NOT a minute boundary let mut time = SimulationTime::default(); time.tick = 57; world.insert_resource(time); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(degrade_npc_inferences); schedule.run(&mut world); let mem = world.get::(npc).unwrap(); assert!( mem.last_known[&player_sid].zone_inference.is_some(), "degradation should not run on non-minute ticks" ); } #[test] fn no_events_when_no_player_entity() { let mut world = setup_world(32, 32); let registry = EntityRegistry::new(0); let vision = NpcVisionState::default(); world.spawn(( Npc, ActiveSim, pos(16, 16), vision, NpcMemory::default(), KnowledgeGraph::new(), )); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(emit_npc_vision_events); schedule.run(&mut world); // should not panic let queue = world.resource::(); assert!(queue.is_empty(), "no events when no player entity exists"); } }