Files
settled-reach/server/src/simulation/contraband.rs
T
jpmschweitzerandClaude Opus 4.6 3d636fd4bb feat(simulation): knowledge grant schema, events, and contradiction detection (#545, #546, #547)
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>
2026-02-24 12:14:53 +01:00

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");
}
}