diff --git a/decisions/content.md b/decisions/content.md index 9b7d33561..68d4cc786 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -159,6 +159,27 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio - **Raised by:** Inigo (insert-tech/organic split), Paula (cognitive architecture framing and trust model connection) - **Dissent:** None +### D-075: Dialogue filtering — layered confidence gate on trust tier (OQ-18 resolution) +- **Date:** 2026-02-19 +- **Decision:** KnowledgeConfidence ([D-041](architecture.md#d-041-knowledge-graph-data-model)) acts as a co-gate on TrustTier ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) Layer 3), not on AccessTier (Layer 1). Access tier and trust tier remain two separate filtering dimensions with different drivers: + 1. **AccessTier (Layer 1):** Gated by `RelationshipState` only. Social position determines what *categories* of dialogue are available. No change from current implementation. Character archetype effects are emergent — the detective's investigation naturally creates PersonOfInterest relationships (Authority access), the smuggler's social arc naturally creates Known/Friendly relationships (Peer/Insider access). No archetype tag on the pipeline. + 2. **TrustTier (Layer 3):** Gated by both `RelationshipState` AND `KnowledgeConfidence`: + - **Surface:** any relationship + any confidence — baseline, always available. + - **Real:** (Friendly or Known) + KnowsOf+ — requires both rapport and substantive knowledge. + - **Secret:** Friendly + KnowsDetails+ — requires both deep rapport and actionable knowledge. + 3. **KnowledgeConfidence does NOT gate AccessTier.** Access is a social/positional concept ("who are you to me?"), not an information concept ("what do you know about me?"). A stranger can have Authority access (detective flashes badge) with zero knowledge. An insider can have Peer access before they know anything specific about the target. +- **Key design choice — no archetype dimension.** The dialogue pipeline does not add a character-archetype tag (detective/smuggler) as a filtering axis. Instead, archetype effects on dialogue emerge from: (a) different starting RelationshipStates driven by gameplay (detective institutions → Authority access, smuggler social network → Insider access), (b) different knowledge accumulation rates (detective's analytical lattice gains KnowsOf faster → Real trust earlier), (c) D-028 Layer 1 access tags on lines already encode "this line is for authority figures" vs "this line is for insiders." This is architecturally consistent with D-010 principle 3 (no baking player identity into the game loop). +- **Rationale:** Three reasons for layered-but-not-archetype: + 1. *Separation of concerns.* Access (social position) and trust (relationship depth x knowledge depth) answer different questions. Collapsing them into one axis would require rewriting D-028's four-layer model and D-035's tag taxonomy — both confirmed and implemented. + 2. *Minimal code change.* The only implementation change is adding a `KnowledgeConfidence` parameter to `relationship_to_trust()` in `server/src/simulation/dialogue.rs`. The caller already has access to the observer's KnowledgeGraph. No new components, no new tags, no content format changes. + 3. *Emergent archetype distinction.* Hardcoding archetype tags creates a maintenance burden (new character = new tag = new content variant) and reduces the "two keyholes on the same world" experience. When the detective and smuggler experience different dialogue from the same NPC, it should be because they have different *relationships* and *knowledge*, not because a tag excluded them. +- **Implementation change to #305:** `relationship_to_trust()` gains a `confidence` parameter. Mapping: `(Friendly, KnowsDetails+) → Secret`, `(Friendly|Known, KnowsOf+) → Real`, `(_ , _) → Surface`. Caller in `process_talk_interaction` passes `observer_kg.confidence_of(&target_sid)` to the updated function. +- **Resolves:** OQ-18 +- **Amends:** [D-041](architecture.md#d-041-knowledge-graph-data-model) (confirms confidence-to-trust mapping; supersedes the preliminary 1:1 sketch in D-041 "Key design choices" bullet 3 with the layered model above), [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) (Layer 3 trust now requires both relationship AND confidence) +- **Cross-reference:** [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers), [D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-062](#d-062-invisible-locked-dialogue-options) (confidence progression naturally unlocks new trust tiers, creating the "new options appearing" reward) +- **Raised by:** Tyre (technical analysis, architecture synthesis) +- **Dissent:** Pending review. Sprint briefing flags Gestalt and Nigel for archetype dimension input. + --- -*15 decisions. Last updated: 2026-02-16* +*16 decisions. Last updated: 2026-02-19* diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 7e0d2b650..79d746029 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -161,8 +161,18 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String { // Blocked entities (debug, #514) if !snapshot.blocked_entities.is_empty() { - let ids: Vec = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect(); - writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok(); + let ids: Vec = snapshot + .blocked_entities + .iter() + .map(|id| id.to_string()) + .collect(); + writeln!( + out, + "Blocked (LOS): {} [{}]", + snapshot.blocked_entities.len(), + ids.join(", ") + ) + .ok(); } writeln!(out, "===").ok(); @@ -288,6 +298,7 @@ mod tests { pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], + scan_events: vec![], } } @@ -410,6 +421,7 @@ mod tests { pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], + scan_events: vec![], }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 83da7832e..dee8ed37d 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -69,6 +69,11 @@ pub struct ObserverSnapshot { /// Client shows speaker name + dialogue text in a dialogue box. #[serde(default)] pub dialogue_response: Option, + /// Scan events from NPCs with ScanAuthority this tick (#425, D-065). + /// Present when an NPC scanned the player's inventory. Client renders + /// scan indicator on the scanning NPC. Empty when no scans occurred. + #[serde(default)] + pub scan_events: Vec, /// Debug field: entity IDs on the same z-level that are not visible due to /// LOS obstruction or being outside the vision cone (#514). /// Sorted ascending for deterministic output. Client can safely ignore. diff --git a/server/src/knowledge/registry.rs b/server/src/knowledge/registry.rs index 1c8ade658..915d308d2 100644 --- a/server/src/knowledge/registry.rs +++ b/server/src/knowledge/registry.rs @@ -77,7 +77,8 @@ impl EntityRegistry { assert!( target >= self.next_id, "cannot reserve backwards: next_id={}, target={}", - self.next_id, target + self.next_id, + target ); self.next_id = target; } diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 08a1430ad..bb71fe3b8 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -140,8 +140,8 @@ impl RelationshipState { Self::Friendly => Self::Known, Self::Known => Self::PersonOfInterest, Self::PersonOfInterest => Self::Hostile, - Self::Unknown => Self::Unknown, // no-op: can't confront a stranger - Self::Hostile => Self::Hostile, // floor: already worst state + Self::Unknown => Self::Unknown, // no-op: can't confront a stranger + Self::Hostile => Self::Hostile, // floor: already worst state } } } diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index abbac1bc6..6a204e83c 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -24,8 +24,15 @@ impl Plugin for NpcPlugin { .init_resource::() .add_systems( Update, - routine::check_phase_transition - .before(crate::simulation::pathfinding::compute_paths), + ( + routine::check_phase_transition + .before(crate::simulation::pathfinding::compute_paths), + crate::simulation::contraband::check_contraband_scan + .after(crate::simulation::movement::validate_movement) + .before( + crate::perception::observer::compute_observer_snapshot, + ), + ), ); tracing::debug!("NpcPlugin initialized"); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 842710c50..aa7887317 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -15,6 +15,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; +use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; @@ -68,6 +69,7 @@ pub fn compute_observer_snapshot( Option<&mut SprintAnomalyQueue>, Option<&CognitiveDelay>, Option<&mut DialogueResponseBuffer>, + Option<&mut ScanEventBuffer>, ), With, >, @@ -92,6 +94,7 @@ pub fn compute_observer_snapshot( mut anomaly_queue_opt, cognitive_delay_opt, mut dialogue_response_opt, + mut scan_event_buffer_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); @@ -168,6 +171,10 @@ pub fn compute_observer_snapshot( let current_monologue = monologue_buffer.take(); let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take()); + let scan_events = scan_event_buffer_opt + .as_mut() + .map(|buf| buf.take()) + .unwrap_or_default(); // Build pending recognitions from CognitiveDelay (#423, D-060) let pending_recognitions = cognitive_delay_opt @@ -207,6 +214,7 @@ pub fn compute_observer_snapshot( pending_recognitions, dialogue_response, blocked_entities, + scan_events, }); } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 73316530f..be4b3560c 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -2106,10 +2106,7 @@ fn npc_behind_wall_appears_in_blocked_entities() { "NPC behind wall should appear in blocked_entities" ); // Not in visible entities - let npc_visible = snapshot - .entities - .iter() - .any(|e| e.entity_id == npc_sid.0); + let npc_visible = snapshot.entities.iter().any(|e| e.entity_id == npc_sid.0); assert!(!npc_visible, "NPC should not be in visible entities"); } diff --git a/server/src/simulation/contraband.rs b/server/src/simulation/contraband.rs new file mode 100644 index 000000000..38319d229 --- /dev/null +++ b/server/src/simulation/contraband.rs @@ -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, +} + +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 +/// +/// 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 { + // 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::(); + 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, + }, + ); + + 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"); + } + + #[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"); + } +} diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index ff0f129cb..5dfcad57f 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -171,18 +171,27 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec TrustTier { +/// D-075 layered gate: trust requires BOTH relationship depth AND knowledge depth. +/// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge) +/// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge) +/// - Surface: everything else (baseline, always available) +pub fn relationship_to_trust( + relationship: RelationshipState, + confidence: crate::knowledge::types::KnowledgeConfidence, +) -> TrustTier { + use crate::knowledge::types::KnowledgeConfidence; + match relationship { - RelationshipState::Friendly => TrustTier::Real, + RelationshipState::Friendly if confidence >= KnowledgeConfidence::KnowsDetails => { + TrustTier::Secret + } + RelationshipState::Friendly | RelationshipState::Known + if confidence >= KnowledgeConfidence::KnowsOf => + { + TrustTier::Real + } _ => TrustTier::Surface, } } @@ -373,8 +382,11 @@ pub fn process_talk_interaction( // Layer 2: Derive active situations from game state let situations = derive_situations(time.day_phase(), relationship); - // Layer 3: Trust tier from relationship - let trust = relationship_to_trust(relationship); + // Layer 3: Trust tier from relationship + confidence (D-075) + let confidence = target_stable + .and_then(|sid| observer_kg.confidence_of(&sid)) + .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); + let trust = relationship_to_trust(relationship, confidence); // Query Layers 1-3: collect candidates across all available access tiers let mut candidates: Vec<&IndexedDialogueLine> = Vec::new(); @@ -565,10 +577,7 @@ const CONFRONTATION_LINES: &[(&str, &str)] = &[ "confront_01", "That changed everything between us. No going back.", ), - ( - "confront_02", - "The look on their face... they know I know.", - ), + ("confront_02", "The look on their face... they know I know."), ( "confront_03", "Cards on the table. Let's see what happens next.", @@ -600,13 +609,8 @@ pub fn process_confrontation_response( With, >, ) { - let Ok(( - player_entity, - confrontation, - mut observer_kg, - mut monologue_buf, - mut monologue_state, - )) = query.single_mut() + let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) = + query.single_mut() else { return; }; @@ -714,26 +718,83 @@ mod tests { assert_eq!(tiers, vec![AccessTier::Hostile]); } + // -- Trust tier tests (D-075: layered confidence gate) -------------------- + #[test] - fn trust_friendly_is_real() { + fn trust_friendly_knows_details_is_secret() { + use crate::knowledge::types::KnowledgeConfidence; assert_eq!( - relationship_to_trust(RelationshipState::Friendly), + relationship_to_trust( + RelationshipState::Friendly, + KnowledgeConfidence::KnowsDetails + ), + TrustTier::Secret + ); + } + + #[test] + fn trust_friendly_direct_is_secret() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct), + TrustTier::Secret + ); + } + + #[test] + fn trust_friendly_knows_of_is_real() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf), TrustTier::Real ); } #[test] - fn trust_others_are_surface() { + fn trust_friendly_suspects_is_surface() { + use crate::knowledge::types::KnowledgeConfidence; assert_eq!( - relationship_to_trust(RelationshipState::Unknown), + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects), TrustTier::Surface ); + } + + #[test] + fn trust_known_knows_of_is_real() { + use crate::knowledge::types::KnowledgeConfidence; assert_eq!( - relationship_to_trust(RelationshipState::Known), + relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf), + TrustTier::Real + ); + } + + #[test] + fn trust_known_suspects_is_surface() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects), TrustTier::Surface ); + } + + #[test] + fn trust_unknown_is_always_surface() { + use crate::knowledge::types::KnowledgeConfidence; assert_eq!( - relationship_to_trust(RelationshipState::PersonOfInterest), + relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct), + TrustTier::Surface + ); + } + + #[test] + fn trust_poi_is_always_surface() { + use crate::knowledge::types::KnowledgeConfidence; + // PersonOfInterest uses Authority access, not trust depth + assert_eq!( + relationship_to_trust( + RelationshipState::PersonOfInterest, + KnowledgeConfidence::KnowsDetails + ), TrustTier::Surface ); } @@ -1576,7 +1637,10 @@ mod tests { DeviationTrigger::WalkAway, "Deviation trigger should be WalkAway" ); - assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick"); + assert_eq!( + deviation.tick, 42, + "Deviation should record the walk-away tick" + ); } #[test] @@ -1659,7 +1723,10 @@ mod tests { // RoutineDeviation should be recorded (symmetric with walk-away) let deviation = world.get::(npc); - assert!(deviation.is_some(), "NPC should get RoutineDeviation after confrontation"); + assert!( + deviation.is_some(), + "NPC should get RoutineDeviation after confrontation" + ); assert_eq!( deviation.unwrap().trigger, crate::npc::DeviationTrigger::Confrontation, diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 9c8e2ab2b..324afdc82 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -474,7 +474,10 @@ fn handle_confront( target: target_entity, }); - tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player"); + tracing::debug!( + target_id, + "Confront: ConfrontationDelivered marker set on player" + ); } /// Handle Place verb: remove an item from inventory and place it on the ground @@ -1850,7 +1853,9 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - let pos = world.get::(player).expect("player has position"); + let pos = world + .get::(player) + .expect("player has position"); let hub_spawn = crate::test_world::constants::HUB.spawn; assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn"); assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn"); @@ -1979,7 +1984,9 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - let pos = world.get::(player).expect("player has position"); + let pos = world + .get::(player) + .expect("player has position"); let hub_spawn = crate::test_world::constants::HUB.spawn; assert_eq!(pos.x, hub_spawn.x, "teleport works while paused"); } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index d83bc4881..f711348ae 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -4,6 +4,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod contraband; pub mod dialogue; pub mod input; pub mod interaction;