Contraband detection (Task #425, D-065): - New module: simulation/contraband.rs — NPC scan checks carried items against the KnowledgeGraph confidence gate. NPCs with Authority access can initiate a scan; scan outcome depends on item CarriedBy + KG entry. - Adds ContrabandScanResult event type and ContrabanEntry component. - Wired into simulation/mod.rs module list. Dialogue confidence gate (Task #524, D-075 — OQ-18 resolution): - relationship_to_trust() gains confidence parameter (KnowledgeConfidence). - Trust tier mapping: (Friendly, KnowsDetails+)→Secret, (Friendly|Known, KnowsOf+)→Real, otherwise Surface. Access tier (Layer 1) unchanged. - Caller process_talk_interaction passes observer KG confidence_of target. - Resolves OQ-18: confidence co-gates TrustTier, not AccessTier. Supporting changes: - decisions/content.md: add D-075 (16 decisions, dated 2026-02-19) - knowledge/types.rs: expose KnowledgeConfidence comparison helpers - knowledge/registry.rs: minor API polish - bridge/types.rs: ContrabandScanResult wire type - bridge/text_renderer.rs: render contraband scan status - perception/observer: include carried item count in snapshot - npc/mod.rs: NPC scan range constant, authority flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
// 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
|
||||
///
|
||||
/// 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 {
|
||||
// Skip if NPC already knows about this player's contraband
|
||||
if npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update NPC's KG: record HasContraband fact
|
||||
npc_kg.facts.insert(
|
||||
fact_id,
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: time.tick,
|
||||
},
|
||||
);
|
||||
|
||||
// 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 (client renders the scan 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,
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user