// Contraband detection system — NPC scan checks carried items + KG (#425, D-065) // // NPCs with ScanAuthority check the player's inventory for Contraband items // when within interaction range. On detection, the NPC's KnowledgeGraph is // updated with HasContraband fact at KnowsDetails confidence (DirectObservation // source). A ScanEvent is emitted to the player's ScanEventBuffer for // client-side rendering via ObserverSnapshot. // // System ordering: after perception phase, before snapshot phase. // Uses StableId references throughout — no raw bevy Entity handles in KG entries. use bevy_ecs::prelude::*; use crate::knowledge::types::{ FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, }; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::Npc; use crate::simulation::inventory::CarriedBy; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; /// Scan range for contraband detection (Manhattan distance, same z-level). /// Matches CLOSE_RANGE from interaction system — NPC must be adjacent. pub const SCAN_RANGE: u32 = 2; /// Marker component on item entities that are contraband (D-065). /// Unlicensed lattice components, medical-grade replacements, Severance tech. #[derive(Component, Debug, Clone, Copy)] pub struct Contraband; /// Component on NPC entities with scan permissions. /// Only NPCs with this component perform contraband checks. #[derive(Component, Debug, Clone)] pub struct ScanAuthority; /// Wire-format scan event for ObserverSnapshot inclusion. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ScanEvent { /// StableId of the NPC that performed the scan. pub scanner_entity_id: u64, /// Whether contraband was detected. pub detected_contraband: bool, } /// Per-player buffer holding scan events for snapshot inclusion. /// Cleared each tick by the snapshot builder via `take()`. #[derive(Component, Debug, Default)] pub struct ScanEventBuffer { events: Vec, } impl ScanEventBuffer { /// Push a scan event. pub fn push(&mut self, event: ScanEvent) { self.events.push(event); } /// Drain and return events, leaving the buffer empty. pub fn take(&mut self) -> Vec { std::mem::take(&mut self.events) } } /// Check for contraband in the player's inventory when scanned by NPC. /// /// For each NPC with ScanAuthority within SCAN_RANGE of the player: /// 1. Query player's carried items for Contraband marker /// 2. If found and NPC doesn't already know: update NPC's KnowledgeGraph /// with HasContraband fact (KnowsDetails, DirectObservation source) /// 3. Emit ScanEvent to the player's ScanEventBuffer (always, regardless of /// detection result or prior knowledge — client renders the scan animation) /// /// Registered in SimulationPlugin (not NpcPlugin) because it operates on /// player inventory and writes to the snapshot pipeline. Consistent with /// process_talk_interaction and other cross-entity systems. /// /// System ordering: after validate_movement, before compute_observer_snapshot. #[allow(clippy::type_complexity)] pub fn check_contraband_scan( time: Res, registry: Res, mut npc_query: Query< (Entity, &TilePosition, &mut KnowledgeGraph), (With, With), >, mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With>, items_query: Query<(&CarriedBy, Option<&Contraband>)>, ) { let Ok((player_entity, player_pos, mut scan_buffer)) = player_query.single_mut() else { return; }; let player_pos = *player_pos; let Some(player_sid) = registry.to_stable(player_entity) else { return; }; // Check if player carries any contraband let has_contraband = items_query .iter() .any(|(carried_by, contraband_opt)| carried_by.0 == player_sid && contraband_opt.is_some()); for (npc_entity, npc_pos, mut npc_kg) in npc_query.iter_mut() { // Range check: same z-level + within scan range let Some(distance) = npc_pos.manhattan_distance(&player_pos) else { continue; // Different z-level }; if distance > SCAN_RANGE { continue; } let Some(npc_sid) = registry.to_stable(npc_entity) else { continue; }; // Build the fact ID for this specific player let fact_id = FactId(format!("contraband.detected_{}", player_sid.0)); if has_contraband { // Only update KG on first detection (idempotent — don't overwrite existing fact) if !npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails) { npc_kg.facts.insert( fact_id, FactKnowledge { confidence: KnowledgeConfidence::KnowsDetails, source: KnowledgeSource::DirectObservation { tick: time.tick }, state: KnowledgeState::Active, acquired_tick: time.tick, disclosure_blocked: false, }, ); // Also ensure the NPC has entity knowledge of the player npc_kg.observe_entity(player_sid, player_pos, time.tick); tracing::info!( npc_id = npc_sid.0, player_id = player_sid.0, tick = time.tick, "Contraband detected: NPC scanned player and found contraband" ); } } // Emit scan event regardless of detection or prior knowledge // (client renders the scan animation itself) scan_buffer.push(ScanEvent { scanner_entity_id: npc_sid.0, detected_contraband: has_contraband, }); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::EntityRegistry; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use bevy_ecs::world::World; fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); world } #[test] fn scan_detects_contraband_item() { let mut world = setup_world(); // Spawn player let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); // Spawn contraband item carried by player world.spawn(( CarriedBy(player_sid), ItemName("Unlicensed Lattice Module".into()), InventorySlot(0), Contraband, )); // Spawn NPC with ScanAuthority adjacent to player let npc = world .spawn(( Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ScanAuthority, )) .id(); let npc_sid = world.resource_mut::().register(npc); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); // NPC's KG should now contain HasContraband fact let npc_kg = world.get::(npc).unwrap(); let fact_id = FactId(format!("contraband.detected_{}", player_sid.0)); assert!( npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails), "NPC should know about player's contraband" ); // NPC should also have entity knowledge of the player assert!( npc_kg.knows_entity(&player_sid), "NPC should have entity knowledge of the player after scan" ); let _ = npc_sid; // used indirectly } #[test] fn scan_emits_event_to_buffer() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); // Contraband item world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); let npc = world .spawn(( Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ScanAuthority, )) .id(); world.resource_mut::().register(npc); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert_eq!(events.len(), 1); assert!(events[0].detected_contraband); } #[test] fn no_contraband_no_kg_update() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); // Non-contraband item world.spawn(( CarriedBy(player_sid), ItemName("Comm Log".into()), InventorySlot(0), )); let npc = world .spawn(( Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ScanAuthority, )) .id(); world.resource_mut::().register(npc); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); // NPC's KG should NOT have HasContraband fact let npc_kg = world.get::(npc).unwrap(); let fact_id = FactId(format!("contraband.detected_{}", player_sid.0)); assert!( !npc_kg.knows_fact(&fact_id), "NPC should not know about contraband when player has none" ); // But scan event should still fire (NPC still scanned) let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert_eq!(events.len(), 1); assert!(!events[0].detected_contraband); } #[test] fn out_of_range_no_scan() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); // NPC far away (distance 5, beyond SCAN_RANGE=2) world.spawn(( Npc, TilePosition::new(5, 10, 0), KnowledgeGraph::new(), ScanAuthority, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert!(events.is_empty(), "out-of-range NPC should not scan"); } #[test] fn different_z_level_no_scan() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); // NPC on different z-level world.spawn(( Npc, TilePosition::new(5, 6, 1), KnowledgeGraph::new(), ScanAuthority, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert!(events.is_empty(), "different z-level should prevent scan"); } #[test] fn npc_without_scan_authority_does_not_scan() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); // NPC without ScanAuthority world.spawn((Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new())); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert!( events.is_empty(), "NPC without ScanAuthority should not scan" ); } #[test] fn duplicate_scan_skipped_when_already_known() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); // Pre-populate NPC's KG with contraband knowledge let mut npc_kg = KnowledgeGraph::new(); let fact_id = FactId(format!("contraband.detected_{}", player_sid.0)); npc_kg.facts.insert( fact_id.clone(), FactKnowledge { confidence: KnowledgeConfidence::KnowsDetails, source: KnowledgeSource::DirectObservation { tick: 0 }, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, ); let npc = world .spawn((Npc, TilePosition::new(5, 6, 0), npc_kg, ScanAuthority)) .id(); world.resource_mut::().register(npc); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); // NPC already knew — KG should not be re-written (fact tick stays 0) let npc_kg = world.get::(npc).unwrap(); let fact = npc_kg.facts.get(&fact_id).unwrap(); assert_eq!( fact.acquired_tick, 0, "should not overwrite existing knowledge" ); // Scan event should still fire even though NPC already knew let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert_eq!( events.len(), 1, "scan event should emit even for already-known contraband" ); assert!(events[0].detected_contraband); } #[test] fn multiple_scan_authority_npcs_each_emit_event() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), ScanEventBuffer::default(), )) .id(); let player_sid = world.resource_mut::().register(player); world.spawn(( CarriedBy(player_sid), ItemName("Lattice Component".into()), InventorySlot(0), Contraband, )); // Two NPCs with ScanAuthority, both in range let npc1 = world .spawn(( Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ScanAuthority, )) .id(); world.resource_mut::().register(npc1); let npc2 = world .spawn(( Npc, TilePosition::new(6, 5, 0), KnowledgeGraph::new(), ScanAuthority, )) .id(); world.resource_mut::().register(npc2); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_contraband_scan); schedule.run(&mut world); // Both NPCs should have KG entries let fact_id = FactId(format!("contraband.detected_{}", player_sid.0)); let npc1_kg = world.get::(npc1).unwrap(); assert!(npc1_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails)); let npc2_kg = world.get::(npc2).unwrap(); assert!(npc2_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails)); // Both should emit separate scan events let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); assert_eq!( events.len(), 2, "each ScanAuthority NPC should emit a scan event" ); assert!(events.iter().all(|e| e.detected_contraband)); } #[test] fn scan_event_buffer_take_drains() { let mut buffer = ScanEventBuffer::default(); buffer.push(ScanEvent { scanner_entity_id: 1, detected_contraband: true, }); buffer.push(ScanEvent { scanner_entity_id: 2, detected_contraband: false, }); let events = buffer.take(); assert_eq!(events.len(), 2); let events2 = buffer.take(); assert!(events2.is_empty(), "take should drain the buffer"); } }