diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 6a204e83c..abbac1bc6 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -24,15 +24,8 @@ impl Plugin for NpcPlugin { .init_resource::() .add_systems( Update, - ( - 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, - ), - ), + routine::check_phase_transition + .before(crate::simulation::pathfinding::compute_paths), ); tracing::debug!("NpcPlugin initialized"); diff --git a/server/src/simulation/contraband.rs b/server/src/simulation/contraband.rs index 38319d229..aa3fc9799 100644 --- a/server/src/simulation/contraband.rs +++ b/server/src/simulation/contraband.rs @@ -68,7 +68,12 @@ impl ScanEventBuffer { /// 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 +/// 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)] @@ -110,34 +115,32 @@ pub fn check_contraband_scan( 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; + // 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, + }, + ); + + // 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" + ); } - - // 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) + // 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, @@ -475,6 +478,72 @@ mod tests { 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] diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 5dfcad57f..de7919052 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -15,6 +15,8 @@ //! - Writes DialogueResponseBuffer for snapshot inclusion //! - Uses SimRng for deterministic weighted random selection +use std::collections::BTreeSet; + use bevy_ecs::prelude::*; use rand::Rng; @@ -177,6 +179,19 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec= comparison +/// relies on the derive(PartialOrd) order: Suspects < KnowsOf < KnowsDetails < Direct. +/// +/// Unknown NPCs (no KG entry) default to Suspects, yielding Surface tier. +/// This is correct: you can't have deep dialogue with someone you know nothing about. pub fn relationship_to_trust( relationship: RelationshipState, confidence: crate::knowledge::types::KnowledgeConfidence, @@ -383,6 +398,8 @@ pub fn process_talk_interaction( let situations = derive_situations(time.day_phase(), relationship); // Layer 3: Trust tier from relationship + confidence (D-075) + // Default to Suspects for unknown NPCs — no KG entry means no basis for + // deeper dialogue, which correctly yields Surface trust tier. let confidence = target_stable .and_then(|sid| observer_kg.confidence_of(&sid)) .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); @@ -390,7 +407,7 @@ pub fn process_talk_interaction( // Query Layers 1-3: collect candidates across all available access tiers let mut candidates: Vec<&IndexedDialogueLine> = Vec::new(); - let mut seen_ids: Vec<&str> = Vec::new(); + let mut seen_ids: BTreeSet<&str> = BTreeSet::new(); for access in &access_tiers { let results = line_pool.0.query_dialogue( @@ -401,9 +418,8 @@ pub fn process_talk_interaction( trust, ); for line in results { - // Deduplicate across access tiers - if !seen_ids.contains(&line.id.as_str()) { - seen_ids.push(&line.id); + // Deduplicate across access tiers (BTreeSet for deterministic iteration) + if seen_ids.insert(&line.id) { candidates.push(line); } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index f711348ae..30ac14d67 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -39,6 +39,9 @@ impl Plugin for SimulationPlugin { movement::validate_movement.after(path_follow::follow_paths), path_follow::cleanup_path_blocked.after(movement::validate_movement), listening::update_listening_focus.after(movement::validate_movement), + contraband::check_contraband_scan + .after(movement::validate_movement) + .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index f40c90203..6cebdc9ba 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -58,6 +58,7 @@ use crate::simulation::inventory::ItemName; #[cfg(feature = "gauntlet")] use crate::simulation::listening::ListeningFocus; #[cfg(feature = "gauntlet")] +use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; #[cfg(feature = "gauntlet")] use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; @@ -148,6 +149,7 @@ pub fn setup_gauntlet(app: &mut App) { MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), + ScanEventBuffer::default(), CognitiveDelay::default(), ListeningFocus::new(player_pos), profile, diff --git a/server/src/test_world/rooms/sprint_gauntlet.rs b/server/src/test_world/rooms/sprint_gauntlet.rs index 3bb16268c..bd49958a1 100644 --- a/server/src/test_world/rooms/sprint_gauntlet.rs +++ b/server/src/test_world/rooms/sprint_gauntlet.rs @@ -58,7 +58,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { intensity: 4, description: "Sprint Gauntlet: visible NPC during sprint".to_string(), }, - Contentment { level: 0 }, + Contentment { level: 0 }, // Neutral — test NPC, contentment not load-bearing here ToleranceThreshold { current_stress: 10, threshold: 50, diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs index 9eae0fb75..e1acf2b9e 100644 --- a/server/tests/content_scaling.rs +++ b/server/tests/content_scaling.rs @@ -47,6 +47,9 @@ const STRESS_TICKS: usize = 100; /// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1, /// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2, /// Confrontation Stage: 2 = 34 total. +/// +/// Manually maintained — update when rooms are added/changed. Future: derive +/// from StableId ranges in constants.rs to avoid manual sync. const GAUNTLET_NPC_COUNT: usize = 34; /// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling. diff --git a/server/tests/cross_room_transitions.rs b/server/tests/cross_room_transitions.rs index 3ce803013..983462d75 100644 --- a/server/tests/cross_room_transitions.rs +++ b/server/tests/cross_room_transitions.rs @@ -63,8 +63,9 @@ fn run_listening_system(world: &mut World) { /// T1 — Sprint Exit. /// -/// Player at Sprint Gauntlet observer position (4, 12 absolute) with Walk -/// stance. A Readable sign at (6, 12) is within CLOSE_RANGE (distance 2). +/// Player at (4, 12 absolute) — standalone scenario position south of the +/// Sprint Gauntlet observer (4, 10 per constants.rs). A Readable sign at +/// (6, 12) is within CLOSE_RANGE (distance 2). /// /// During Walk: sign appears in interaction buffer. /// During Sprint: buffer is empty (D-055 suppression). @@ -365,13 +366,17 @@ fn t4_knowledge_graph_survives_room_transition() { /// T5 — Fog Carry-Over. /// /// Player observes NPC in Fog Theater at Direct confidence. -/// Player moves to Hub (NPC now out of LOS). Observation system simulates -/// the confidence downgrade: Direct → KnowsDetails. +/// Player moves to Hub (NPC now out of LOS). Confidence downgrades +/// from Direct to KnowsDetails. /// /// Asserts: KG entry persists (entity is remembered, not erased). /// Server-side "fog carry-over" means previously-seen entities remain in /// the KG at reduced confidence so the client can render a "last seen" /// fog state rather than a clean erasure. +/// +/// NOTE: Uses direct KG API calls (observe_entity, observe_entity_leaving_los) +/// rather than running the full perception system. This isolates the KG +/// persistence contract from perception scheduling. #[test] fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() { use settled_reach_server::knowledge::types::KnowledgeConfidence;