KnowledgeGrant untagged enum (Fact + Entity variants), ContentEntityRegistry resource, KnowledgeGranted event processing, ContradictionClaim struct with 600-tick window detection in observe_entity. Wires knowledge_grant field in dialogue line selection. Implements D-079, D-083. Closes Q-026. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
582 lines
19 KiB
Rust
582 lines
19 KiB
Rust
// 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<ScanEvent>,
|
|
}
|
|
|
|
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<ScanEvent> {
|
|
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<SimulationTime>,
|
|
registry: Res<EntityRegistry>,
|
|
mut npc_query: Query<
|
|
(Entity, &TilePosition, &mut KnowledgeGraph),
|
|
(With<Npc>, With<ScanAuthority>),
|
|
>,
|
|
mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With<PlayerCharacter>>,
|
|
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::<SimulationTime>();
|
|
world.insert_resource(SimRng::new(42));
|
|
world.init_resource::<EntityRegistry>();
|
|
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::<EntityRegistry>().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::<EntityRegistry>().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::<KnowledgeGraph>(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::<EntityRegistry>().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::<EntityRegistry>().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::<ScanEventBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<KnowledgeGraph>(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::<ScanEventBuffer>(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::<EntityRegistry>().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::<ScanEventBuffer>(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::<EntityRegistry>().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::<ScanEventBuffer>(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::<EntityRegistry>().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::<ScanEventBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<KnowledgeGraph>(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::<ScanEventBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().register(npc1);
|
|
|
|
let npc2 = world
|
|
.spawn((
|
|
Npc,
|
|
TilePosition::new(6, 5, 0),
|
|
KnowledgeGraph::new(),
|
|
ScanAuthority,
|
|
))
|
|
.id();
|
|
world.resource_mut::<EntityRegistry>().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::<KnowledgeGraph>(npc1).unwrap();
|
|
assert!(npc1_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
|
|
let npc2_kg = world.get::<KnowledgeGraph>(npc2).unwrap();
|
|
assert!(npc2_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
|
|
|
|
// Both should emit separate scan events
|
|
let mut buffer = world.get_mut::<ScanEventBuffer>(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");
|
|
}
|
|
}
|