diff --git a/.claude/rules/team-patterns.md b/.claude/rules/team-patterns.md index a614eea5e..aebbd6d6a 100644 --- a/.claude/rules/team-patterns.md +++ b/.claude/rules/team-patterns.md @@ -17,3 +17,17 @@ When producing many files (wiki pages, content batches, bulk docs): 3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume. + +## Team monitoring (stuck agent detection) + +When leading a team (sprint, workshop, or any multi-agent session): + +**Agent heartbeat rule** — include in every agent spawn prompt: +> If you have been working on a single task for more than 15 minutes +> without making progress, message the team lead with what is blocking +> you. Do not keep retrying the same approach silently. + +**Team lead proactive checks:** +- If an agent has not sent a message in ~20 minutes, ping them for a status update. +- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message. +- When checking on a stuck agent, offer to reassign the task or pull in another agent to help. diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 08cb8d438..8a9241b63 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review. ## Workflow -### 1. Determine the branch +### 0. Branch guard — MUST be on `main` -If the user provided a branch name as argument, use it. Otherwise use the -current branch (`git branch --show-current`). If on `main`, ask the user -which branch to review. +```bash +git branch --show-current +``` + +If the current branch is **not `main`**, stop immediately and tell the user: +"PR reviews must be run from the `main` worktree. Switch to `main` first." +Do NOT proceed with the review from a team branch. + +### 1. Determine the branch to review + +If the user provided a branch name as argument, use it. Otherwise list open +PRs and ask the user which branch to review. To list open PRs on Gitea: ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc06c37f..db62460d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services) +- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545) +- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546) +- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547) +- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548) +- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549) +- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550) +- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551) +- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173) +- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149) +- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232) +- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern - `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies - Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states - Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 76783ea3d..a09fcaf8f 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -178,6 +178,10 @@ impl Plugin for BridgePlugin { .after(crate::simulation::sound::collect_sound_events) .after(crate::simulation::conversation::run_npc_conversations) .after(crate::simulation::dialogue::process_walk_away), + // Contradiction monologue fires from queue populated by prior tick's + // process_knowledge_events (which runs after the snapshot). + crate::simulation::monologue::process_contradiction_monologue + .after(crate::simulation::monologue::trigger_event_monologue), crate::simulation::follow::update_follow_state .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::movement::validate_movement) @@ -185,7 +189,7 @@ impl Plugin for BridgePlugin { crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) - .after(crate::simulation::monologue::trigger_event_monologue) + .after(crate::simulation::monologue::process_contradiction_monologue) .after(crate::simulation::dialogue::process_talk_interaction) .after(crate::simulation::dialogue::process_confrontation_response) .after(crate::simulation::dialogue::process_dialogue_response) diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 179fe4823..dd150c73e 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -20,6 +20,8 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use std::path::PathBuf; +use crate::knowledge::ContentEntityRegistry; + /// Configuration for the content loader. /// Set the content root path before adding ContentPlugin. #[derive(Resource, Debug, Clone)] @@ -52,6 +54,10 @@ impl Plugin for ContentPlugin { app.insert_resource(ContentConfig::default()); } + // ContentEntityRegistry is required by spawn_npc (D-079). + // Init here so ContentPlugin works standalone without KnowledgePlugin. + app.init_resource::(); + app.add_systems(Startup, load_and_spawn_content); app.add_systems(PostUpdate, hot_reload::hot_reload_content); diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 9e0c5e75e..43d6fe09f 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -22,6 +22,7 @@ use std::collections::BTreeMap; use crate::content::loader::{ContentStore, DistrictContent}; use crate::content::types; +use crate::knowledge::content_registry::ContentEntityRegistry; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::{EntityRegistry, StableEntityId}; use crate::knowledge::types::{ @@ -213,6 +214,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR ContentSlug(profile.canonical_id.clone()), )); + // Register in ContentEntityRegistry so KnowledgeGrant::Entity can resolve entity_ref strings + // (D-079: ContentEntityRegistry populated at NPC spawn time) + world + .resource_mut::() + .register(profile.canonical_id.clone(), stable_id); + result .npc_ids .insert(profile.canonical_id.clone(), stable_id); @@ -296,6 +303,7 @@ fn resolve_information( source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ) }) @@ -648,6 +656,7 @@ mod tests { fn create_test_world() -> World { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 0536e358c..50b9e6c7a 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -491,10 +491,30 @@ pub struct DialogueLine { pub knowledge_grant: Option, } +/// Knowledge grant attached to a dialogue line (D-079). +/// +/// Untagged enum — serde tries each variant in order: +/// `Fact` matches YAML with `fact_id` field. +/// `Entity` matches YAML with `entity_ref` field. +/// `Compound` variant deferred to Sprint 18. #[derive(Debug, Clone, Deserialize)] -pub struct KnowledgeGrant { - pub fact_id: String, - pub confidence: String, +#[serde(untagged)] +pub enum KnowledgeGrant { + /// Grant knowledge of a non-entity fact. + /// Format: fact_id "category.topic", confidence string. + Fact { + fact_id: String, + confidence: String, + }, + /// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG). + /// Required for contradiction detection: testimony must create ToldBy EntityKnowledge + /// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083). + Entity { + entity_ref: String, + #[serde(default)] + attributes: BTreeMap, + confidence: String, + }, } // --------------------------------------------------------------------------- diff --git a/server/src/knowledge/content_registry.rs b/server/src/knowledge/content_registry.rs new file mode 100644 index 000000000..03017414c --- /dev/null +++ b/server/src/knowledge/content_registry.rs @@ -0,0 +1,86 @@ +//! ContentEntityRegistry resource (D-079). +//! +//! Maps NPC canonical_id strings (e.g., "kael-davan") to their runtime StableIds. +//! Populated at NPC spawn time; queried at KnowledgeGrant processing time to +//! resolve `entity_ref` strings in `KnowledgeGrant::Entity` variants. +//! +//! BTreeMap for deterministic iteration (D-010 principle 4). + +use bevy_ecs::prelude::*; +use std::collections::BTreeMap; + +use super::types::StableId; + +/// Content ID → StableId registry. +/// +/// Populated by `spawn_npc` for every authored NPC. Read by the +/// `KnowledgeGranted` event handler to resolve `entity_ref` strings at +/// grant processing time (D-079). +#[derive(Resource, Debug, Default)] +pub struct ContentEntityRegistry { + entries: BTreeMap, +} + +impl ContentEntityRegistry { + /// Register a content_id → StableId mapping. + /// + /// Idempotent for the same (content_id, stable_id) pair. + /// If the same content_id is registered twice with different StableIds, + /// the latest call wins (last-write semantics; warn in caller if this is unexpected). + pub fn register(&mut self, content_id: impl Into, stable_id: StableId) { + self.entries.insert(content_id.into(), stable_id); + } + + /// Resolve a content_id string to a StableId. + /// + /// Returns `None` if the entity_ref is not registered. Callers should + /// emit `tracing::warn!` and drop the grant when `None` is returned. + pub fn resolve(&self, entity_ref: &str) -> Option { + self.entries.get(entity_ref).copied() + } + + /// Number of registered entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_and_resolve() { + let mut registry = ContentEntityRegistry::default(); + let sid = StableId(42); + registry.register("kael-davan", sid); + + assert_eq!(registry.resolve("kael-davan"), Some(sid)); + assert_eq!(registry.resolve("unknown-npc"), None); + assert_eq!(registry.len(), 1); + } + + #[test] + fn register_overwrites() { + let mut registry = ContentEntityRegistry::default(); + let sid_a = StableId(1); + let sid_b = StableId(2); + registry.register("npc-x", sid_a); + registry.register("npc-x", sid_b); + + assert_eq!(registry.resolve("npc-x"), Some(sid_b)); + assert_eq!(registry.len(), 1); + } + + #[test] + fn empty_registry() { + let registry = ContentEntityRegistry::default(); + assert!(registry.is_empty()); + assert_eq!(registry.resolve("any"), None); + } +} diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index 48615e68f..45b8a8982 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -4,6 +4,7 @@ //! KnowledgeEvents; the processing system drains them per tick. use bevy_ecs::prelude::*; +use std::collections::BTreeMap; use crate::simulation::movement::TilePosition; @@ -11,6 +12,41 @@ use super::graph::KnowledgeGraph; use super::registry::EntityRegistry; use super::types::*; +// --------------------------------------------------------------------------- +// Processed knowledge grant types (D-079) +// --------------------------------------------------------------------------- + +/// Processed Fact grant — confidence string parsed to typed enum at creation time. +/// Used in `KnowledgeEventType::KnowledgeGranted`. +#[derive(Debug, Clone)] +pub struct ProcessedFactGrant { + pub fact_id: FactId, + pub confidence: KnowledgeConfidence, +} + +/// Processed Entity grant — entity_ref resolved to StableId at creation time. +/// Used in `KnowledgeEventType::KnowledgeGranted`. +/// +/// Creates an `EntityKnowledge` entry in the observer's KG with `ToldBy` source, +/// enabling contradiction detection when a subsequent `DirectObservation` disagrees. +#[derive(Debug, Clone)] +pub struct ProcessedEntityGrant { + pub target_id: StableId, + pub attributes: BTreeMap, + pub confidence: KnowledgeConfidence, +} + +/// Typed knowledge grant payload — all string fields resolved at event creation. +#[derive(Debug, Clone)] +pub enum ProcessedKnowledgeGrant { + Fact(ProcessedFactGrant), + Entity(ProcessedEntityGrant), +} + +// --------------------------------------------------------------------------- +// Knowledge event types +// --------------------------------------------------------------------------- + /// Events that modify knowledge graphs. Produced by perception and /// other systems. Consumed by the knowledge update system. #[derive(Debug, Clone)] @@ -36,6 +72,16 @@ pub enum KnowledgeEventType { target: Entity, interaction_type: InteractionType, }, + /// Knowledge granted to observer via dialogue line selection (D-079). + /// + /// Fires at line selection time in `process_talk_interaction`. + /// Source is `ToldBy { source_id, tick }` for NPC testimony. + /// For Fact grants, the granting NPC's KG must contain the fact (guardrail enforced + /// at event creation time — event is only pushed if guardrail passes). + KnowledgeGranted { + grant: ProcessedKnowledgeGrant, + source: KnowledgeSource, + }, } /// Type of interaction for walk-away recording (D-064). @@ -57,6 +103,55 @@ pub struct KnowledgeEventQueue { pub(crate) events: Vec, } +// --------------------------------------------------------------------------- +// Contradiction detection output (D-083) +// --------------------------------------------------------------------------- + +/// Event emitted when `observe_entity()` detects a position contradiction +/// between a `ToldBy` source and a `DirectObservation`. +/// +/// Consumed by the monologue system (D-083 → monologue trigger) and +/// potentially the storyteller. One event per detected contradiction per tick. +/// +/// Display names are pre-resolved by `process_knowledge_events` via EntityRegistry +/// and NpcName, so downstream consumers (monologue) are pure string consumers. +#[derive(Debug, Clone)] +pub struct ContradictionDetectedEvent { + /// The observer who detected the contradiction. + pub observer: Entity, + /// The entity whose position was contradicted. + pub target: StableId, + /// Full contradiction details (who told what, where observed, when). + pub claim: ContradictionClaim, + /// Pre-resolved display name of the NPC who told the false position (told_by source). + pub source_display_name: String, + /// Pre-resolved display name of the entity whose position was contradicted (target). + pub subject_display_name: String, +} + +/// Resource: queue of contradictions detected this tick. +/// +/// Populated by `process_knowledge_events` when `observe_entity()` returns +/// a `ContradictionClaim`. Drained by downstream systems (monologue, storyteller). +#[derive(Resource, Default)] +pub struct ContradictionDetectedQueue { + events: Vec, +} + +impl ContradictionDetectedQueue { + pub fn push(&mut self, event: ContradictionDetectedEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + impl KnowledgeEventQueue { /// Push a knowledge event into the queue. pub fn push(&mut self, event: KnowledgeEvent) { @@ -82,9 +177,15 @@ impl KnowledgeEventQueue { /// System: process pending knowledge events. /// Runs once per tick, drains KnowledgeEventQueue and applies updates /// to the relevant KnowledgeGraph components. +/// +/// On contradiction detection (D-083): +/// - Shifts the ToldBy source entity to PersonOfInterest in the observer's KG. +/// - Pre-resolves display names for downstream monologue consumer. pub fn process_knowledge_events( mut queue: ResMut, + mut contradiction_queue: ResMut, registry: Res, + npc_names: Query<&crate::simulation::conversation::NpcName>, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { let events = queue.drain(); @@ -96,7 +197,59 @@ pub fn process_knowledge_events( match event.event_type { KnowledgeEventType::DirectObservation { target, position } => { if let Some(stable_id) = registry.to_stable(target) { - observer_kg.observe_entity(stable_id, position, event.tick); + if let Some(claim) = + observer_kg.observe_entity(stable_id, position, event.tick) + { + // StableId is Copy — capture before moving claim into event. + let told_by = claim.told_by; + + // Relationship shift (D-083): NPC who provided false info + // becomes PersonOfInterest in the observer's knowledge graph. + // Upsert: create a minimal entry if the source isn't yet known. + observer_kg + .entities + .entry(told_by) + .and_modify(|e| e.relationship = RelationshipState::PersonOfInterest) + .or_insert_with(|| EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: event.tick, + confidence: KnowledgeConfidence::Suspects, + source: KnowledgeSource::Inferred { basis: vec![] }, + state: KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }); + + // Pre-resolve display names for the monologue consumer. + let source_entity = registry.to_entity(&told_by); + let source_display_name = source_entity + .and_then(|e| npc_names.get(e).ok()) + .map(|n| n.0.clone()) + .unwrap_or_else(|| format!("#{}", told_by.0)); + let subject_display_name = npc_names + .get(target) + .ok() + .map(|n| n.0.clone()) + .unwrap_or_else(|| format!("#{}", stable_id.0)); + + tracing::info!( + observer = ?event.observer, + target = stable_id.0, + told_by = told_by.0, + source = source_display_name, + subject = subject_display_name, + "Contradiction detected: ToldBy position differs from direct observation (D-083)" + ); + contradiction_queue.push(ContradictionDetectedEvent { + observer: event.observer, + target: stable_id, + claim, + source_display_name, + subject_display_name, + }); + } } else { debug_assert!( false, @@ -135,6 +288,68 @@ pub fn process_knowledge_events( ); } } + KnowledgeEventType::KnowledgeGranted { grant, source } => { + match grant { + ProcessedKnowledgeGrant::Fact(fg) => { + let should_insert = observer_kg + .facts + .get(&fg.fact_id) + .map(|existing| fg.confidence > existing.confidence) + .unwrap_or(true); + if should_insert { + observer_kg.facts.insert( + fg.fact_id.clone(), + FactKnowledge { + confidence: fg.confidence, + source, + state: KnowledgeState::Active, + acquired_tick: event.tick, + disclosure_blocked: false, + }, + ); + tracing::debug!( + "KnowledgeGranted(Fact): {:?} at confidence {:?}, tick {}", + fg.fact_id, + fg.confidence, + event.tick, + ); + } + } + ProcessedKnowledgeGrant::Entity(eg) => { + // Insert or upgrade entity knowledge entry. + // Always use ToldBy source — entity grants come from NPC testimony. + let entry = observer_kg.entities.entry(eg.target_id).or_insert_with(|| { + EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: event.tick, + confidence: eg.confidence, + source: source.clone(), + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + } + }); + // Upgrade confidence and source if new grant is higher. + if eg.confidence > entry.confidence { + entry.confidence = eg.confidence; + entry.source = source; + entry.last_updated_tick = event.tick; + } + // Merge attributes (grant may supply partial attribute set). + for (k, v) in eg.attributes { + entry.known_attributes.insert(k, v); + } + tracing::debug!( + "KnowledgeGranted(Entity): StableId {:?} at confidence {:?}, tick {}", + eg.target_id, + eg.confidence, + event.tick, + ); + } + } + } } } } @@ -196,6 +411,7 @@ mod tests { let _ = observer_sid; // registered for completeness world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { @@ -236,6 +452,7 @@ mod tests { registry.register(observer); world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { @@ -261,6 +478,7 @@ mod tests { let mut world = World::new(); let registry = EntityRegistry::new(0); world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph let fake_target = world.spawn_empty().id(); @@ -339,4 +557,175 @@ mod tests { "decay should run on tick 10 and downgrade confidence" ); } + + #[test] + fn process_direct_observation_detects_contradiction() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target_ecs = world.spawn_empty().id(); + let target_sid = registry.register(target_ecs); + let informant_sid = StableId(999); + + // Observer has ToldBy knowledge: target at (10, 10) at tick 100 + let mut kg = KnowledgeGraph::new(); + kg.entities.insert( + target_sid, + EntityKnowledge { + last_known_position: Some(TilePosition::new(10, 10, 0)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant_sid, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let observer = world.spawn(kg).id(); + registry.register(observer); + world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); + + // Push DirectObservation at DIFFERENT position, within window + let mut queue = KnowledgeEventQueue::default(); + queue.push(KnowledgeEvent { + observer, + tick: 200, + event_type: KnowledgeEventType::DirectObservation { + target: target_ecs, + position: TilePosition::new(15, 10, 0), + }, + }); + world.insert_resource(queue); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + // Verify: KG entry is Contradicted + let kg = world.entity(observer).get::().unwrap(); + let entry = kg.entity_knowledge(&target_sid).unwrap(); + assert_eq!(entry.state, KnowledgeState::Contradicted); + assert!(entry.contradicted_claim.is_some()); + let claim = entry.contradicted_claim.as_ref().unwrap(); + assert_eq!(claim.told_by, informant_sid); + assert_eq!(claim.claimed_position, TilePosition::new(10, 10, 0)); + assert_eq!(claim.observed_position, TilePosition::new(15, 10, 0)); + + // Verify: ContradictionDetectedQueue has the event + let cq = world.resource::(); + assert_eq!(cq.events.len(), 1); + assert_eq!(cq.events[0].target, target_sid); + assert_eq!(cq.events[0].claim.told_by, informant_sid); + } + + #[test] + fn no_contradiction_event_when_position_matches() { + // DirectObservation at the SAME position as ToldBy: + // ContradictionDetectedQueue must stay empty. + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target_ecs = world.spawn_empty().id(); + let target_sid = registry.register(target_ecs); + let informant_sid = StableId(77); + + let mut kg = KnowledgeGraph::new(); + kg.entities.insert( + target_sid, + EntityKnowledge { + last_known_position: Some(TilePosition::new(10, 10, 0)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant_sid, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let observer = world.spawn(kg).id(); + registry.register(observer); + world.insert_resource(registry); + world.insert_resource(ContradictionDetectedQueue::default()); + + // DirectObservation at the SAME position + let mut queue = KnowledgeEventQueue::default(); + queue.push(KnowledgeEvent { + observer, + tick: 200, + event_type: KnowledgeEventType::DirectObservation { + target: target_ecs, + position: TilePosition::new(10, 10, 0), // same position + }, + }); + world.insert_resource(queue); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + let cq = world.resource::(); + assert!( + cq.is_empty(), + "Matching position should not produce a contradiction event" + ); + let kg = world.entity(observer).get::().unwrap(); + let entry = kg.entity_knowledge(&target_sid).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + } + + #[test] + fn contradiction_detected_queue_drains_correctly() { + // ContradictionDetectedQueue.drain() should empty the queue + // and return all accumulated events. + let mut queue = ContradictionDetectedQueue::default(); + assert!(queue.is_empty()); + + let mut world = World::new(); + let e = world.spawn_empty().id(); + + queue.push(ContradictionDetectedEvent { + observer: e, + target: StableId(1), + claim: ContradictionClaim { + told_by: StableId(99), + told_tick: 50, + claimed_position: TilePosition::new(1, 1, 0), + observed_position: TilePosition::new(5, 5, 0), + detected_tick: 100, + }, + source_display_name: "Sera".to_string(), + subject_display_name: "Kael".to_string(), + }); + queue.push(ContradictionDetectedEvent { + observer: e, + target: StableId(2), + claim: ContradictionClaim { + told_by: StableId(88), + told_tick: 60, + claimed_position: TilePosition::new(2, 2, 0), + observed_position: TilePosition::new(6, 6, 0), + detected_tick: 100, + }, + source_display_name: "NPC_88".to_string(), + subject_display_name: "NPC_2".to_string(), + }); + + assert!(!queue.is_empty()); + let drained = queue.drain(); + assert_eq!(drained.len(), 2); + assert!(queue.is_empty(), "Queue should be empty after drain"); + } } diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index 77e725155..728b2f9a1 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -108,7 +108,46 @@ impl KnowledgeGraph { // --- Write Operations --- /// Record a direct observation of another entity (entity is in LOS). - pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) { + /// + /// Returns `Some(ContradictionClaim)` if a position contradiction was + /// detected against a recent `ToldBy` source (D-083). The caller should + /// push a `ContradictionDetected` event when this returns `Some`. + pub fn observe_entity( + &mut self, + target: StableId, + position: TilePosition, + tick: u64, + ) -> Option { + // --- Pre-overwrite contradiction check (D-083) --- + // + // If the existing entry has a ToldBy source with a different position, + // and the told-tick is within CONTRADICTION_WINDOW_TICKS of now, + // this is a contradiction: someone lied or was wrong about where + // this entity would be. + let contradiction = self.entities.get(&target).and_then(|existing| { + if let KnowledgeSource::ToldBy { + source_id, + tick: told_tick, + } = &existing.source + { + let age = tick.saturating_sub(*told_tick); + let claimed_pos = existing.last_known_position?; + if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position { + Some(ContradictionClaim { + told_by: *source_id, + told_tick: *told_tick, + claimed_position: claimed_pos, + observed_position: position, + detected_tick: tick, + }) + } else { + None + } + } else { + None + } + }); + let entry = self .entities .entry(target) @@ -121,18 +160,31 @@ impl KnowledgeGraph { state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), + contradicted_claim: None, }); entry.last_known_position = Some(position); entry.last_observed_tick = tick; entry.last_updated_tick = tick; entry.confidence = KnowledgeConfidence::Direct; entry.source = KnowledgeSource::DirectObservation { tick }; - // Stale entries become Active again on fresh observation. - // Contradicted entries stay Contradicted even if you're looking - // at the entity right now — the contradiction is still unresolved. - if entry.state == KnowledgeState::Stale { + + if contradiction.is_some() { + entry.state = KnowledgeState::Contradicted; + entry.contradicted_claim = contradiction.clone(); + } else if entry.state == KnowledgeState::Stale { + // Stale entries become Active again on fresh observation. entry.state = KnowledgeState::Active; } + // Contradicted entries without a new contradiction stay Contradicted — + // the previous contradiction is still unresolved. + // + // After this write, entry.source is DirectObservation, so subsequent + // observations will NOT re-trigger contradiction detection (the + // pre-overwrite check only fires when existing.source is ToldBy). + // This is intentional: once contradicted, the entry reflects the + // observer's own eyes and cannot be "contradicted" again by looking. + + contradiction } /// Entity has left the observer's LOS. Downgrade from Direct. @@ -164,10 +216,18 @@ impl KnowledgeGraph { last_observed_tick: 0, last_updated_tick: 0, confidence: KnowledgeConfidence::Suspects, - source: KnowledgeSource::DirectObservation { tick }, + // Heard/Close — not DirectObservation, because walk-away is + // not a confirmed sighting. Using DirectObservation here + // would falsely inoculate the entry against contradiction + // detection (pre-overwrite check only fires on ToldBy source). + source: KnowledgeSource::Heard { + tick, + range: super::types::SoundRange::Close, + }, state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), + contradicted_claim: None, }); let type_str = match interaction_type { @@ -387,6 +447,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }; let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]); @@ -513,6 +574,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ), ( @@ -522,6 +584,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ), ]; @@ -626,6 +689,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, )]); @@ -648,6 +712,7 @@ mod tests { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, )]); @@ -746,4 +811,362 @@ mod tests { "FactionOnly must pass when observer knows the matching faction_id" ); } + + // --- Contradiction detection tests (D-083, #547) --- + + #[test] + fn contradiction_detected_when_told_by_position_differs() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Someone told us the target is at (10, 10) at tick 100 + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Direct observation at (15, 10) at tick 200 — within window (600 ticks) + let result = g.observe_entity(target, make_position(15, 10), 200); + + // Contradiction should be detected + assert!(result.is_some(), "Should detect contradiction"); + let claim = result.unwrap(); + assert_eq!(claim.told_by, informant); + assert_eq!(claim.told_tick, 100); + assert_eq!(claim.claimed_position, make_position(10, 10)); + assert_eq!(claim.observed_position, make_position(15, 10)); + assert_eq!(claim.detected_tick, 200); + + // Entry should be Contradicted + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Contradicted); + assert!(entry.contradicted_claim.is_some()); + // But confidence is upgraded to Direct (we're looking at them) + assert_eq!(entry.confidence, KnowledgeConfidence::Direct); + } + + #[test] + fn no_contradiction_when_told_by_position_matches() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Told target is at (10, 10) + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Observe at SAME position — no contradiction + let result = g.observe_entity(target, make_position(10, 10), 200); + assert!(result.is_none(), "Same position should not be a contradiction"); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + assert!(entry.contradicted_claim.is_none()); + } + + #[test] + fn no_contradiction_outside_time_window() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // Told at tick 100, position (10, 10) + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Observe at different position BUT outside window (100 + 601 = 701) + let result = g.observe_entity(target, make_position(15, 10), 701); + assert!( + result.is_none(), + "Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction" + ); + } + + #[test] + fn contradiction_at_exact_window_boundary() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS) + let result = g.observe_entity(target, make_position(15, 10), 700); + assert!( + result.is_some(), + "Exactly at window boundary (age == 600) should still detect contradiction" + ); + } + + #[test] + fn no_contradiction_for_direct_observation_source() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + + // Previous knowledge from DirectObservation (not ToldBy) + g.observe_entity(target, make_position(10, 10), 100); + + // New observation at different position — NOT a contradiction + // (we just moved, or they moved; no one lied) + let result = g.observe_entity(target, make_position(15, 10), 200); + assert!( + result.is_none(), + "DirectObservation source should never trigger contradiction" + ); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + } + + #[test] + fn no_contradiction_for_background_source() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + + // Background knowledge with a position + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(10, 10)), + last_observed_tick: 0, + last_updated_tick: 0, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let result = g.observe_entity(target, make_position(15, 10), 100); + assert!( + result.is_none(), + "Background source should not trigger contradiction" + ); + } + + #[test] + fn no_contradiction_when_told_by_has_no_position() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(99); + + // ToldBy but no position was claimed + g.entities.insert( + target, + EntityKnowledge { + last_known_position: None, // no position claimed + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let result = g.observe_entity(target, make_position(15, 10), 200); + assert!( + result.is_none(), + "ToldBy without position should not trigger contradiction" + ); + } + + #[test] + fn contradicted_claim_entry_field_matches_returned_claim() { + // Verify that entry.contradicted_claim is populated with identical + // data to the ContradictionClaim returned by observe_entity (D-083). + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + let informant = StableId(42); + + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(5, 5)), + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 50, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let returned = g + .observe_entity(target, make_position(12, 5), 300) + .expect("contradiction should be detected"); + + let entry = g.entity_knowledge(&target).unwrap(); + let stored = entry.contradicted_claim.as_ref().expect("field should be populated"); + + assert_eq!(stored.told_by, returned.told_by); + assert_eq!(stored.told_tick, returned.told_tick); + assert_eq!(stored.claimed_position, returned.claimed_position); + assert_eq!(stored.observed_position, returned.observed_position); + assert_eq!(stored.detected_tick, returned.detected_tick); + } + + #[test] + fn second_observation_keeps_contradicted_state_when_no_new_told_by() { + // After a contradiction is detected, subsequent DirectObservation + // does NOT clear the Contradicted state (D-083: "unresolved"). + let mut g = KnowledgeGraph::new(); + let target = StableId(7); + let informant = StableId(8); + + // Set up ToldBy knowledge + g.entities.insert( + target, + EntityKnowledge { + last_known_position: Some(make_position(3, 3)), + last_observed_tick: 0, + last_updated_tick: 10, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 10, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // First observation: contradiction detected + let claim = g.observe_entity(target, make_position(9, 3), 200); + assert!(claim.is_some(), "contradiction should fire"); + assert_eq!( + g.entity_knowledge(&target).unwrap().state, + KnowledgeState::Contradicted + ); + + // Second observation (now source is DirectObservation, different position): + // state must stay Contradicted — contradiction is still unresolved. + let claim2 = g.observe_entity(target, make_position(11, 3), 300); + assert!(claim2.is_none(), "no new contradiction: DirectObservation source"); + assert_eq!( + g.entity_knowledge(&target).unwrap().state, + KnowledgeState::Contradicted, + "Contradicted state must persist until explicitly resolved" + ); + } + + #[test] + fn multiple_entities_only_told_by_one_contradicts() { + // Edge case: observer knows two entities. + // Entity A has ToldBy source, Entity B has DirectObservation. + // Only Entity A should produce a contradiction. + let mut g = KnowledgeGraph::new(); + let entity_a = StableId(10); + let entity_b = StableId(20); + let informant = StableId(99); + + // Entity A: ToldBy with position + g.entities.insert( + entity_a, + EntityKnowledge { + last_known_position: Some(make_position(1, 1)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: informant, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + // Entity B: DirectObservation (no informant to lie) + g.observe_entity(entity_b, make_position(5, 5), 100); + + // Observe both at different positions at tick 200 + let a_result = g.observe_entity(entity_a, make_position(8, 1), 200); + let b_result = g.observe_entity(entity_b, make_position(9, 5), 200); + + assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict"); + assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict"); + assert_eq!( + g.entity_knowledge(&entity_a).unwrap().state, + KnowledgeState::Contradicted + ); + assert_eq!( + g.entity_knowledge(&entity_b).unwrap().state, + KnowledgeState::Active + ); + } } diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 2f9f8ffba..711eb8f8c 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -7,12 +7,18 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; +pub mod content_registry; pub mod events; pub mod graph; pub mod registry; pub mod types; -pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +pub use content_registry::ContentEntityRegistry; +pub use events::{ + ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent, + KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant, + ProcessedKnowledgeGrant, +}; pub use graph::KnowledgeGraph; pub use registry::{EntityRegistry, StableEntityId}; pub use types::*; @@ -24,11 +30,17 @@ pub struct KnowledgePlugin; impl Plugin for KnowledgePlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_systems( Update, ( + // Runs after snapshot: contradiction monologue and relationship + // shifts from KnowledgeGranted events lag by one tick (~0.1s). + // Acceptable — the player perceives the contradiction on the + // next snapshot, which reads as a natural reaction delay. events::process_knowledge_events .after(crate::perception::observer::compute_observer_snapshot), events::decay_knowledge.after(events::process_knowledge_events), diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 77315be3d..76bed1018 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -54,6 +54,27 @@ const _: () = { assert!(KnowledgeConfidence::Direct as u8 == 3); }; +impl TryFrom<&str> for KnowledgeConfidence { + type Error = String; + + /// Parse a confidence string from YAML content into the typed enum. + /// + /// Case-insensitive. Accepts both camelCase and underscore/hyphen variants. + /// Used by KnowledgeGrant processing (D-079). + fn try_from(s: &str) -> Result { + match s.to_lowercase().as_str() { + "suspects" => Ok(Self::Suspects), + "knowsof" | "knows_of" | "knows-of" => Ok(Self::KnowsOf), + "knowsdetails" | "knows_details" | "knows-details" => Ok(Self::KnowsDetails), + "direct" => Ok(Self::Direct), + other => Err(format!( + "unknown confidence level '{}': expected one of suspects, knowsof, knowsdetails, direct", + other + )), + } + } +} + impl KnowledgeConfidence { /// Step down one confidence level (used by decay system). pub fn decayed(self) -> Self { @@ -146,6 +167,33 @@ impl RelationshipState { } } +// --- Contradiction Detection (D-083) --- + +/// Ticks within which a position discrepancy counts as a contradiction. +/// 600 ticks = 1 game-hour (at 10 tps per D-031). +/// Outside this window, stale ToldBy information is simply overwritten. +pub const CONTRADICTION_WINDOW_TICKS: u64 = 600; + +/// Records details of a detected contradiction on an EntityKnowledge entry. +/// +/// Populated when `observe_entity()` finds a position discrepancy with a +/// recent `ToldBy` source. Both the ToldBy entry and the DirectObservation +/// receive `Contradicted` state (epistemic neutrality — the engine does +/// not determine which is wrong). D-083, Q-026 resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContradictionClaim { + /// Who told us the (now-contradicted) information. + pub told_by: StableId, + /// Tick when the ToldBy information was received. + pub told_tick: u64, + /// Position the source claimed the entity was at. + pub claimed_position: TilePosition, + /// Position we directly observed the entity at. + pub observed_position: TilePosition, + /// Tick when the contradiction was detected. + pub detected_tick: u64, +} + // --- Entity Knowledge --- /// What entity A knows about entity B. @@ -169,6 +217,10 @@ pub struct EntityKnowledge { /// Known attributes of the target entity. /// Keys are structured (name, role, faction, etc.) pub known_attributes: BTreeMap, + /// Populated when a contradiction is detected between ToldBy and + /// DirectObservation sources (D-083). None when no contradiction exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contradicted_claim: Option, } /// Non-entity fact knowledge (locations, events, abstract knowledge). @@ -183,6 +235,10 @@ pub struct FactKnowledge { pub state: KnowledgeState, /// Tick when this fact was learned. pub acquired_tick: u64, + /// When true, this fact must not be transferred to other entities via NPC-to-NPC propagation. + /// D-080: models secrets whose sharing is existentially dangerous regardless of trust tier. + #[serde(default)] + pub disclosure_blocked: bool, } // --- Decay Configuration --- diff --git a/server/src/npc/disclosure.rs b/server/src/npc/disclosure.rs new file mode 100644 index 000000000..d68ae79bc --- /dev/null +++ b/server/src/npc/disclosure.rs @@ -0,0 +1,696 @@ +//! Unprompted disclosure system (D-081). +//! +//! Two-system pipeline: +//! - `derive_disclosure_candidates`: per-NPC KG filter, recomputed every 30 ticks +//! - `process_unprompted_disclosure`: trigger gates, StableId-ordered firing +//! +//! NPCs check only their own KG (D-010 principle 2 — no cross-entity KG reads). +//! Disclosure grants the fact to the player's KG via `KnowledgeGranted` event +//! and emits a placeholder `MonologueEvent` (Layer 4 line selection in #172). + +use std::collections::BTreeSet; + +use bevy_ecs::prelude::*; + +use crate::bridge::types::MonologueEvent; +use crate::knowledge::events::{ + KnowledgeEvent, KnowledgeEventType, ProcessedFactGrant, ProcessedKnowledgeGrant, +}; +use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, KnowledgeState, RelationshipState, StableId}; +use crate::knowledge::{KnowledgeEventQueue, KnowledgeGraph, StableEntityId}; +use crate::npc::mood::{MoodState, NpcMood}; +use crate::npc::relationships::RelationshipGraph; +use crate::npc::trait_modifiers::{traits_to_keys, TraitModifierConfig}; +use crate::npc::{Contentment, Npc, PersonalityTraits}; +use crate::simulation::monologue::MonologueBuffer; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Recompute `DisclosureCandidates` every N ticks (30 ticks = 3 game-minutes at 10 tps). +const CANDIDATE_REFRESH_TICKS: u64 = 30; + +/// Per-NPC cooldown ticks after a disclosure fires (300 = 30 game-minutes). +const NPC_COOLDOWN_TICKS: u64 = 300; + +/// Minimum ticks between any two disclosures (global rate limit). +const GLOBAL_RATE_LIMIT_TICKS: u64 = 10; + +/// Max candidates retained in `DisclosureCandidates`. +const MAX_CANDIDATES: usize = 10; + +/// Witness inhibition check radius (Manhattan distance, tiles). +const WITNESS_RADIUS: u32 = 5; + +/// Minimum NPC→player trust for Surface-tier disclosure. +const SURFACE_TRUST: i8 = 0; + +/// NPC→player trust at which witness inhibition is waived (Secret tier, D-081). +const SECRET_TRUST: i8 = 7; + +/// Trust level below which a nearby NPC counts as an untrusted witness. +const REAL_TRUST: i8 = 3; + +/// Player proximity range for candidate recompute (tiles). Matches voice range. +const PLAYER_RANGE_TILES: u32 = 8; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Per-NPC computed disclosure candidate pool (D-081). +/// +/// Recomputed every `CANDIDATE_REFRESH_TICKS` ticks by `derive_disclosure_candidates`. +/// Consumed by `process_unprompted_disclosure` when all trigger gates pass. +/// +/// Facts are sorted: confidence desc, then `acquired_tick` desc. Capped at +/// `MAX_CANDIDATES`. +#[derive(Component, Debug, Clone, Default)] +pub struct DisclosureCandidates { + /// Fact IDs eligible for disclosure, ordered by priority. + pub candidates: Vec, + /// Tick when this pool was last computed. 0 = never computed. + pub computed_tick: u64, +} + +/// Per-NPC disclosure cooldown state (D-081). +/// +/// Tracks which facts have been disclosed during the current window +/// (primary narrative quality gate) and the per-NPC silence period. +#[derive(Component, Debug, Clone, Default)] +pub struct DisclosureCooldown { + /// Facts disclosed this window — prevents repeating the same fact. + pub per_fact_history: BTreeSet, + /// Tick when the per-NPC cooldown expires. 0 = no active cooldown. + pub npc_cooldown_until: u64, +} + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/// Global rate limiter: at most 1 unprompted disclosure per `GLOBAL_RATE_LIMIT_TICKS`. +/// +/// When multiple NPCs are eligible simultaneously, the one with the lowest +/// StableId fires first (deterministic, D-010 principle 4). +#[derive(Resource, Debug, Clone, Default)] +pub struct DisclosureGlobalRateLimit { + pub last_disclosure_tick: u64, +} + +// --------------------------------------------------------------------------- +// derive_disclosure_candidates +// --------------------------------------------------------------------------- + +/// Recompute `DisclosureCandidates` for each Active NPC. +/// +/// Runs every `CANDIDATE_REFRESH_TICKS` ticks (checked per-NPC via `computed_tick`). +/// Filters the NPC's own KG through: +/// +/// - `KnowledgeState::Active` only +/// - Confidence >= `KnowsOf` (or trait-lowered threshold via `TraitModifierConfig`) +/// - Not in `per_fact_history` +/// - `disclosure_blocked != true` +/// - ToldBy exclusion if Cautious trait is configured +/// +/// Applies Stage 1 trait filters from `TraitModifierConfig`. Sorted by +/// confidence desc then `acquired_tick` desc. Capped at `MAX_CANDIDATES`. +pub fn derive_disclosure_candidates( + time: Res, + trait_config: Res, + mut npc_query: Query< + ( + &KnowledgeGraph, + &DisclosureCooldown, + Option<&PersonalityTraits>, + &mut DisclosureCandidates, + ), + (With, With), + >, +) { + let current_tick = time.tick; + + for (kg, cooldown, traits_opt, mut candidates) in &mut npc_query { + // Only recompute when the refresh interval has elapsed. + if candidates.computed_tick != 0 + && current_tick.saturating_sub(candidates.computed_tick) < CANDIDATE_REFRESH_TICKS + { + continue; + } + + let trait_keys = traits_opt + .map(|t| traits_to_keys(&t.traits)) + .unwrap_or_default(); + + // Effective confidence floor. + // `lowest_min_confidence` returns the most permissive threshold across + // all traits (additive expansion — e.g., Gossipy sets Suspects, + // which wins over Cautious raising to KnowsDetails). + let min_confidence = trait_config + .lowest_min_confidence(&trait_keys) + .unwrap_or(KnowledgeConfidence::KnowsOf); + + // Cautious trait: exclude facts with ToldBy (rumour) source. + let exclude_told_by = trait_keys.iter().any(|k| { + trait_config + .modifier_for(k) + .is_some_and(|m| m.stage1.exclude_told_by) + }); + + // Loyal trait: exclude facts sourced from high-trust entities. + // "Don't gossip about your friends" — if ToldBy source has Friendly + // relationship in this NPC's KG, suppress the fact. D-081. + let exclude_high_trust = trait_config.any_excludes_high_trust(&trait_keys); + + let mut pool: Vec<(FactId, u64, KnowledgeConfidence)> = kg + .known_facts_iter() + .filter(|(fact_id, fact)| { + // Active state only. + if fact.state != KnowledgeState::Active { + return false; + } + // Not already disclosed this window. + if cooldown.per_fact_history.contains(*fact_id) { + return false; + } + // Existentially dangerous secrets never disclosed (D-080). + if fact.disclosure_blocked { + return false; + } + // Confidence threshold. + if fact.confidence < min_confidence { + return false; + } + // Cautious: skip ToldBy-source facts. + if exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) { + return false; + } + // Loyal: skip facts from high-trust (Friendly) source entities. + if exclude_high_trust { + if let KnowledgeSource::ToldBy { source_id, .. } = &fact.source { + if kg.relationship_with(source_id) == RelationshipState::Friendly { + return false; + } + } + } + true + }) + .map(|(id, fact)| (id.clone(), fact.acquired_tick, fact.confidence)) + .collect(); + + // Sort: confidence desc, then acquired_tick desc (most recent first). + pool.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1))); + pool.truncate(MAX_CANDIDATES); + + candidates.candidates = pool.into_iter().map(|(id, _, _)| id).collect(); + candidates.computed_tick = current_tick; + } +} + +// --------------------------------------------------------------------------- +// process_unprompted_disclosure +// --------------------------------------------------------------------------- + +/// Collected during the read pass; used for sorting and winner selection. +struct EligibleNpc { + entity: Entity, + stable_id: StableId, + pos: TilePosition, + fact_id: FactId, + override_witness: bool, +} + +/// Fire one unprompted disclosure per tick window when all trigger gates pass (D-081). +/// +/// Trigger gates (all must pass for a given NPC): +/// +/// 1. NPC has a `StableEntityId` (required for trust lookup) +/// 2. `DisclosureCandidates` pool is non-empty +/// 3. NPC→player trust >= `SURFACE_TRUST` +/// 4. `MoodState` != `NpcMood::Hostile` +/// 5. `Contentment.level` >= −10 +/// 6. Per-NPC cooldown not active +/// 7. Player within `PLAYER_RANGE_TILES` +/// 8. Witness inhibition: no untrusted NPCs within `WITNESS_RADIUS` tiles +/// (waived if NPC→player trust >= `SECRET_TRUST` or Talkative trait) +/// 9. Location privacy: stubbed as always-pass — full impl in #172 +/// +/// Multiple eligible NPCs sorted by ascending StableId (D-010 principle 4). +/// First in order that also passes witness inhibition fires. +/// Subject to global rate limit (`GLOBAL_RATE_LIMIT_TICKS`). +pub fn process_unprompted_disclosure( + time: Res, + spatial: Res, + relationship_graph: Res, + trait_config: Res, + mut rate_limit: ResMut, + mut event_queue: ResMut, + player_pos_query: Query< + (Entity, &TilePosition, Option<&StableEntityId>), + With, + >, + mut player_mono_query: Query<&mut MonologueBuffer, With>, + mut npc_query: Query< + ( + Entity, + &TilePosition, + Option<&StableEntityId>, + Option<&MoodState>, + Option<&Contentment>, + Option<&PersonalityTraits>, + &DisclosureCandidates, + &mut DisclosureCooldown, + ), + (With, With), + >, + witness_sid_query: Query, With>, +) { + let current_tick = time.tick; + + // Gate: global rate limit — at most 1 disclosure per GLOBAL_RATE_LIMIT_TICKS. + if current_tick.saturating_sub(rate_limit.last_disclosure_tick) < GLOBAL_RATE_LIMIT_TICKS { + return; + } + + // Collect player state. Single-player assumption (D-010). + let Ok((player_entity, player_pos_ref, player_sid_opt)) = player_pos_query.single() else { + return; + }; + let player_pos = *player_pos_ref; + let player_sid: Option = player_sid_opt.map(|s| s.0); + + // --- Read pass: collect all NPCs passing the non-spatial gates --- + let mut eligible: Vec = npc_query + .iter() + .filter_map( + |(entity, pos, sid_opt, mood_opt, content_opt, traits_opt, candidates, cooldown)| { + // Gate 1: must have a StableId for trust lookup. + let npc_sid = sid_opt?.0; + + // Gate 2: candidate pool non-empty. + // Takes highest-priority candidate (sorted by confidence desc, + // then recency desc in derive_disclosure_candidates). Full Layer 4 + // line selection with variety tracking is deferred to #172. + let fact_id = candidates.candidates.first()?.clone(); + + // Gate 3: NPC→player trust >= Surface. + let npc_player_trust = player_sid + .and_then(|psid| relationship_graph.get_relationship(&npc_sid, &psid)) + .map(|e| e.trust) + .unwrap_or(0); + if npc_player_trust < SURFACE_TRUST { + return None; + } + + // Gate 4: mood not Hostile. + if mood_opt.is_some_and(|ms| ms.mood == NpcMood::Hostile) { + return None; + } + + // Gate 5: contentment >= -10. + if content_opt.is_some_and(|c| c.level < -10) { + return None; + } + + // Gate 6: per-NPC cooldown not active. + if current_tick < cooldown.npc_cooldown_until { + return None; + } + + // Gate 7: player within PLAYER_RANGE_TILES (same z-level only). + let in_range = pos + .manhattan_distance(&player_pos) + .is_some_and(|d| d <= PLAYER_RANGE_TILES); + if !in_range { + return None; + } + + // Gate 9: location privacy — stubbed always-pass. + // Full implementation deferred to #172 (Layer 4 disclosure pipeline). + + // Compute witness inhibition override for gate 8. + let trait_keys = traits_opt + .map(|t| traits_to_keys(&t.traits)) + .unwrap_or_default(); + let override_witness = npc_player_trust >= SECRET_TRUST + || trait_config.any_overrides_witness_inhibition(&trait_keys); + + Some(EligibleNpc { + entity, + stable_id: npc_sid, + pos: *pos, + fact_id, + override_witness, + }) + }, + ) + .collect(); + + if eligible.is_empty() { + return; + } + + // Sort by ascending StableId for determinism (D-010 principle 4). + eligible.sort_by_key(|c| c.stable_id); + + // Gate 8: witness inhibition — find first NPC that passes spatial check. + let winner = eligible.into_iter().find(|candidate| { + if candidate.override_witness { + return true; + } + !has_untrusted_witness( + &candidate.pos, + candidate.stable_id, + player_entity, + &spatial, + &witness_sid_query, + &relationship_graph, + ) + }); + + let Some(winner) = winner else { + return; + }; + + // --- Fire the disclosure --- + + // 1. Grant the fact to the player's KG via KnowledgeGranted event (ToldBy source). + // Confidence capped at KnowsOf (same rule as NPC-to-NPC transfer, D-080). + event_queue.push(KnowledgeEvent { + observer: player_entity, + tick: current_tick, + event_type: KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { + fact_id: winner.fact_id.clone(), + confidence: KnowledgeConfidence::KnowsOf, + }), + source: KnowledgeSource::ToldBy { + source_id: winner.stable_id, + tick: current_tick, + }, + }, + }); + + // 2. Placeholder monologue event — actual line selection deferred to #172 + // (Layer 4 unprompted disclosure pipeline reads DisclosureCandidates). + if let Ok(mut mono_buf) = player_mono_query.single_mut() { + mono_buf.set(MonologueEvent { + id: format!("disclosure_{}", winner.fact_id.0), + text: String::from("(Layer 4 line selection — ticket #172)"), + duration_seconds: 4.0, + }); + } + + // 3. Update NPC cooldown state. + // Note: re-queries npc_query mutably after the read pass above. This is + // safe because the read pass only borrows shared refs and completes before + // this point. The two-phase pattern (read → select winner → write) avoids + // holding a mutable borrow during iteration. + if let Ok((_, _, _, _, _, _, _, mut cooldown)) = npc_query.get_mut(winner.entity) { + cooldown.per_fact_history.insert(winner.fact_id.clone()); + cooldown.npc_cooldown_until = current_tick + NPC_COOLDOWN_TICKS; + } + + // 4. Advance global rate limit. + rate_limit.last_disclosure_tick = current_tick; + + tracing::debug!( + tick = current_tick, + npc_sid = ?winner.stable_id, + fact_id = %winner.fact_id.0, + "unprompted disclosure fired" + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns true if any untrusted NPC is within `WITNESS_RADIUS` of `pos`. +/// +/// "Untrusted" = the disclosing NPC's trust toward that witness is below +/// `REAL_TRUST`. The player entity is excluded (they are the target). +fn has_untrusted_witness( + pos: &TilePosition, + npc_sid: StableId, + player_entity: Entity, + spatial: &NaiveSpatialIndex, + witness_sid_query: &Query, With>, + relationship_graph: &RelationshipGraph, +) -> bool { + for witness_entity in spatial.entities_in_range(pos, WITNESS_RADIUS) { + if witness_entity == player_entity { + continue; + } + if let Ok(Some(witness_sid)) = witness_sid_query.get(witness_entity) { + let trust = relationship_graph + .get_relationship(&npc_sid, &witness_sid.0) + .map(|e| e.trust) + .unwrap_or(0); + if trust < REAL_TRUST { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use bevy_app::prelude::*; + + use super::*; + use crate::knowledge::graph::KnowledgeGraph; + use crate::knowledge::types::{KnowledgeConfidence, KnowledgeSource, KnowledgeState}; + use crate::simulation::tier::ActiveSim; + use crate::simulation::time::SimulationTime; + + fn make_fact( + confidence: KnowledgeConfidence, + state: KnowledgeState, + acquired_tick: u64, + disclosure_blocked: bool, + ) -> crate::knowledge::types::FactKnowledge { + crate::knowledge::types::FactKnowledge { + confidence, + source: KnowledgeSource::Background, + state, + acquired_tick, + disclosure_blocked, + } + } + + fn make_kg(facts: Vec<(FactId, crate::knowledge::types::FactKnowledge)>) -> KnowledgeGraph { + KnowledgeGraph::with_background(facts) + } + + /// Build a minimal App for derive_disclosure_candidates tests. + fn build_app() -> App { + let mut app = App::new(); + app.init_resource::(); + app.init_resource::(); + app.add_systems(Update, derive_disclosure_candidates); + app + } + + // ----------------------------------------------------------------------- + // derive_disclosure_candidates tests + // ----------------------------------------------------------------------- + + #[test] + fn active_knowsof_fact_becomes_candidate() { + let mut app = build_app(); + + let fact_id = FactId("investigation.clue".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false), + )]); + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert!( + candidates.candidates.contains(&fact_id), + "Active KnowsOf fact should be in pool" + ); + } + + #[test] + fn disclosure_blocked_fact_excluded() { + let mut app = build_app(); + + let fact_id = FactId("secret.dangerous".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::KnowsDetails, KnowledgeState::Active, 5, true), + )]); + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert!( + !candidates.candidates.contains(&fact_id), + "disclosure_blocked fact must never be a candidate" + ); + } + + #[test] + fn stale_fact_excluded() { + let mut app = build_app(); + + let fact_id = FactId("cargo.manifest".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Stale, 5, false), + )]); + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert!( + !candidates.candidates.contains(&fact_id), + "Stale fact must be excluded" + ); + } + + #[test] + fn already_disclosed_fact_excluded() { + let mut app = build_app(); + + let fact_id = FactId("dock.schedule".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false), + )]); + + let mut cooldown = DisclosureCooldown::default(); + cooldown.per_fact_history.insert(fact_id.clone()); + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, cooldown, DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert!( + !candidates.candidates.contains(&fact_id), + "Fact in per_fact_history must be excluded" + ); + } + + #[test] + fn candidates_capped_at_max() { + let mut app = build_app(); + + // Spawn 15 facts — only MAX_CANDIDATES should survive. + let facts: Vec<_> = (0..15) + .map(|i| { + ( + FactId(format!("fact.{:02}", i)), + make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, i as u64, false), + ) + }) + .collect(); + + let kg = make_kg(facts); + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert_eq!( + candidates.candidates.len(), + MAX_CANDIDATES, + "Candidate pool must be capped at MAX_CANDIDATES" + ); + } + + #[test] + fn refresh_skipped_within_interval() { + let mut app = build_app(); + + let fact_id = FactId("investigation.clue".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false), + )]); + + // Set computed_tick = 1 (non-zero). Tick 5 is within the 30-tick refresh window. + let mut candidates = DisclosureCandidates::default(); + candidates.computed_tick = 1; + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), candidates)) + .id(); + + // Advance tick to 5 (within CANDIDATE_REFRESH_TICKS = 30). + app.world_mut().resource_mut::().tick = 5; + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert_eq!( + candidates.computed_tick, 1, + "Refresh should be skipped within the interval" + ); + assert!( + candidates.candidates.is_empty(), + "Candidates should remain empty (no recompute)" + ); + } + + #[test] + fn suspects_confidence_below_default_floor_excluded() { + let mut app = build_app(); + + let fact_id = FactId("rumour.vague".to_string()); + let kg = make_kg(vec![( + fact_id.clone(), + make_fact(KnowledgeConfidence::Suspects, KnowledgeState::Active, 5, false), + )]); + + let npc = app + .world_mut() + .spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default())) + .id(); + + app.update(); + + let candidates = app.world().get::(npc).unwrap(); + assert!( + !candidates.candidates.contains(&fact_id), + "Suspects-confidence fact must be below the KnowsOf default floor" + ); + } +} diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index f049c45e8..6294ab91b 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,7 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod disclosure; pub mod generate; pub mod interaction; pub mod mood; @@ -9,6 +10,7 @@ pub mod relationships; pub mod routine; pub mod tell_state; pub mod tolerance; +pub mod trait_modifiers; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -31,6 +33,8 @@ impl Plugin for NpcPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -61,6 +65,11 @@ impl Plugin for NpcPlugin { .after(mood::update_mood) .after(routine::detect_routine_deviation) .before(crate::perception::observer::compute_observer_snapshot), + disclosure::derive_disclosure_candidates + .before(crate::perception::observer::compute_observer_snapshot), + disclosure::process_unprompted_disclosure + .after(disclosure::derive_disclosure_candidates) + .before(crate::perception::observer::compute_observer_snapshot), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs index 6e74342df..5ed6fd66f 100644 --- a/server/src/npc/tell_state.rs +++ b/server/src/npc/tell_state.rs @@ -21,6 +21,8 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::types::RelationshipState; use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::{ Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold, @@ -77,6 +79,7 @@ fn derive_category( mood_state: &MoodState, relationships_opt: Option<&Relationships>, deviation_opt: Option<&RoutineDeviation>, + kg_opt: Option<&KnowledgeGraph>, ) -> Option { // Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4) if deviation_opt.is_some() { @@ -102,10 +105,20 @@ fn derive_category( } // Priority 5: Friendly — high contentment with at least one trusted relationship + // D-082: prefer KG relationship state over ground-truth axis data. + // Self-axis components (secret, stress, contentment, mood) remain ground-truth; + // OTHER-entity relationship assessment uses the knowledge graph. if contentment.level > 20 { - let has_positive_relationship = relationships_opt - .map(|rels| rels.entries.iter().any(|r| r.trust_level > 3)) - .unwrap_or(false); + let has_positive_relationship = if let Some(kg) = kg_opt { + // D-082: use knowledge graph for other-entity relationship assessment + kg.known_entities_iter() + .any(|(_, ek)| ek.relationship == RelationshipState::Friendly) + } else { + // No KG: fall through to ground-truth axis data + relationships_opt + .map(|rels| rels.entries.iter().any(|r| r.trust_level > 3)) + .unwrap_or(false) + }; if has_positive_relationship { return Some(TellCategory::Friendly); } @@ -135,12 +148,13 @@ pub fn derive_tell_state( &MoodState, Option<&Relationships>, Option<&RoutineDeviation>, + Option<&KnowledgeGraph>, &mut DerivedTellState, ), (With, With), >, ) { - for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in + for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, kg_opt, mut tell) in npcs.iter_mut() { tell.category = derive_category( @@ -150,6 +164,7 @@ pub fn derive_tell_state( mood_state, relationships_opt, deviation_opt, + kg_opt, ); } } @@ -248,6 +263,7 @@ mod tests { &mood(NpcMood::Neutral), None, Some(&deviation()), + None, ); assert_eq!(result, Some(TellCategory::RoutineDeviation)); } @@ -261,6 +277,7 @@ mod tests { &mood(NpcMood::Hostile), None, Some(&deviation()), + None, ); assert_eq!(result, Some(TellCategory::RoutineDeviation)); } @@ -274,6 +291,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, // No deviation + None, ); assert_ne!(result, Some(TellCategory::RoutineDeviation)); } @@ -292,6 +310,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); assert_eq!(result, Some(TellCategory::Nervous)); } @@ -306,6 +325,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); assert_eq!(result, Some(TellCategory::Guarded)); } @@ -319,6 +339,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); assert_ne!(result, Some(TellCategory::Nervous)); } @@ -333,6 +354,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); // Still Guarded (Major secret, priority 4) assert_eq!(result, Some(TellCategory::Guarded)); @@ -351,6 +373,7 @@ mod tests { &mood(NpcMood::Hostile), None, None, + None, ); assert_eq!(result, Some(TellCategory::Angry)); } @@ -364,6 +387,7 @@ mod tests { &mood(NpcMood::Hostile), None, None, + None, ); assert_ne!(result, Some(TellCategory::Angry)); } @@ -377,6 +401,7 @@ mod tests { &mood(NpcMood::Anxious), // Not Hostile None, None, + None, ); assert_ne!(result, Some(TellCategory::Angry)); } @@ -391,6 +416,7 @@ mod tests { &mood(NpcMood::Hostile), None, None, + None, ); assert_ne!(result, Some(TellCategory::Angry)); } @@ -408,6 +434,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); assert_eq!(result, Some(TellCategory::Guarded)); } @@ -421,6 +448,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); // Moderate secret doesn't trigger Guarded assert_ne!(result, Some(TellCategory::Guarded)); @@ -439,6 +467,7 @@ mod tests { &mood(NpcMood::Neutral), Some(&positive_relationships()), // Trust > 3 None, + None, ); assert_eq!(result, Some(TellCategory::Friendly)); } @@ -452,6 +481,7 @@ mod tests { &mood(NpcMood::Neutral), Some(&neutral_relationships()), // Trust = 0 None, + None, ); assert_ne!(result, Some(TellCategory::Friendly)); } @@ -465,6 +495,7 @@ mod tests { &mood(NpcMood::Neutral), None, // No relationships at all None, + None, ); assert_ne!(result, Some(TellCategory::Friendly)); } @@ -479,6 +510,7 @@ mod tests { &mood(NpcMood::Neutral), Some(&positive_relationships()), None, + None, ); assert_ne!(result, Some(TellCategory::Friendly)); } @@ -496,6 +528,7 @@ mod tests { &mood(NpcMood::Neutral), None, None, + None, ); assert_eq!(result, None); } @@ -509,6 +542,7 @@ mod tests { &mood(NpcMood::Anxious), // Not Hostile None, None, + None, ); assert_eq!(result, None); } @@ -572,4 +606,172 @@ mod tests { let state = world.get::(entity).unwrap(); assert_eq!(state.category, None); } + + // ----------------------------------------------------------------------- + // D-082: KG-aware Friendly tell + // ----------------------------------------------------------------------- + + fn kg_with_friendly_entity() -> KnowledgeGraph { + use crate::simulation::movement::TilePosition; + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10); + kg.set_relationship(&StableId(1), RelationshipState::Friendly); + kg + } + + fn kg_with_known_entity() -> KnowledgeGraph { + use crate::simulation::movement::TilePosition; + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10); + kg.set_relationship(&StableId(1), RelationshipState::Known); + kg + } + + #[test] + fn kg_friendly_relationship_triggers_friendly_tell() { + let kg = kg_with_friendly_entity(); + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + None, // Relationships component doesn't matter when KG exists + None, + Some(&kg), + ); + assert_eq!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn kg_known_relationship_does_not_trigger_friendly_tell() { + // Known != Friendly — only Friendly relationship triggers the tell + let kg = kg_with_known_entity(); + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + None, + None, + Some(&kg), + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn kg_empty_does_not_trigger_friendly_tell() { + let kg = KnowledgeGraph::new(); + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + None, + None, + Some(&kg), + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn no_kg_falls_through_to_relationships_component() { + // Without KG, the old behavior (Relationships component) should work + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + Some(&positive_relationships()), + None, + None, // No KG + ); + assert_eq!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn kg_overrides_relationships_component() { + // KG says no Friendly relationships, even though Relationships + // component has trust > 3 — KG wins (D-082). + let kg = kg_with_known_entity(); // Known, not Friendly + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + Some(&positive_relationships()), // ground-truth says Friendly + None, + Some(&kg), // KG says Known (not Friendly) + ); + assert_ne!( + result, + Some(TellCategory::Friendly), + "KG should override Relationships component for Friendly tell" + ); + } + + // ----------------------------------------------------------------------- + // Priority chain: verify ordering at boundaries (QA gap closure) + // ----------------------------------------------------------------------- + + #[test] + fn nervous_beats_angry_when_both_conditions_met() { + // NPC has: Major secret, stress past midpoint (Nervous), AND + // low contentment + Hostile mood (Angry). + // Priority 2 (Nervous) must win over Priority 3 (Angry). + let result = derive_category( + &major_secret(), + &tolerance(80, 100), // stress*2=160 > 100 → Nervous + &contentment(-50), // < -20 → Angry condition met + &mood(NpcMood::Hostile), // Angry condition met + None, + None, + None, + ); + assert_eq!( + result, + Some(TellCategory::Nervous), + "Nervous (priority 2) must beat Angry (priority 3)" + ); + } + + #[test] + fn angry_beats_guarded_when_both_conditions_met() { + // NPC has: Major secret (Guarded), AND low contentment + Hostile (Angry). + // Priority 3 (Angry) must win over Priority 4 (Guarded). + // Note: stress is LOW so Nervous does not trigger. + let result = derive_category( + &major_secret(), + &tolerance(10, 100), // Low stress — not Nervous + &contentment(-30), // < -20 → Angry + &mood(NpcMood::Hostile), + None, + None, + None, + ); + assert_eq!( + result, + Some(TellCategory::Angry), + "Angry (priority 3) must beat Guarded (priority 4)" + ); + } + + #[test] + fn guarded_beats_friendly_when_both_conditions_met() { + // NPC has: Major secret (Guarded), AND high contentment with positive + // relationship (Friendly). Priority 4 (Guarded) must win over Priority 5. + let result = derive_category( + &major_secret(), + &tolerance(0, 100), // Low stress — not Nervous + &contentment(50), // > +20 → Friendly condition met + &mood(NpcMood::Neutral), + Some(&positive_relationships()), // Friendly condition met + None, + None, + ); + assert_eq!( + result, + Some(TellCategory::Guarded), + "Guarded (priority 4) must beat Friendly (priority 5)" + ); + } } diff --git a/server/src/npc/trait_modifiers.rs b/server/src/npc/trait_modifiers.rs new file mode 100644 index 000000000..677c78174 --- /dev/null +++ b/server/src/npc/trait_modifiers.rs @@ -0,0 +1,556 @@ +//! Trait modifier system for unprompted disclosure (#173, D-081). +//! +//! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool, +//! Stage 2 (HOW) weights line selection via delivery tags. +//! +//! Traits map to filter predicates via content-authorable YAML config — +//! not hard-coded enum dispatch. Content authors define what each trait +//! does to the candidate pool and which delivery tags it prefers. + +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; +use serde::Deserialize; + +use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource}; + +// --------------------------------------------------------------------------- +// YAML-authored trait modifier config +// --------------------------------------------------------------------------- + +/// Full trait modifier configuration resource. Loaded from YAML. +/// +/// Keys are trait names (lowercase, matching `PersonalityTrait` string +/// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc. +/// +/// BTreeMap for deterministic iteration (D-010 principle 4). +#[derive(Resource, Debug, Clone, Default, Deserialize)] +pub struct TraitModifierConfig { + /// Trait name → modifier rules. Trait names are lowercase_snake_case. + #[serde(default)] + pub modifiers: BTreeMap, +} + +/// A single trait's filter and scoring rules. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TraitModifier { + /// Stage 1: candidate pool filter (WHAT gets disclosed). + #[serde(default)] + pub stage1: Stage1Filter, + /// Stage 2: line pool scoring (HOW it's delivered). + #[serde(default)] + pub stage2: Stage2Scoring, +} + +/// Stage 1 filter predicates — modify the disclosure candidate pool. +/// +/// Applied per-fact during candidate selection in `DisclosureCandidates` +/// (#551). Multiple traits compose additively: if any trait includes a +/// candidate that would otherwise be excluded, it's included. +/// +/// Default values (all false/None) produce no modification to the +/// baseline filter, which requires KnowsOf minimum confidence and +/// includes all source types. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Stage1Filter { + /// Minimum confidence to enter the disclosure pool. + /// Parsed at load time: "suspects", "knows_of", "knows_details", "direct". + /// None = use system default (KnowsOf). + #[serde(default)] + pub min_confidence: Option, + /// If true, exclude facts with `ToldBy` source (won't pass on rumors). + /// Cautious trait behavior. + #[serde(default)] + pub exclude_told_by: bool, + /// If true, exclude facts linked to entities with trust_level >= Real + /// in NPC Relationships. Loyal trait behavior. + #[serde(default)] + pub exclude_high_trust_entities: bool, + /// If true, override the witness inhibition gate. Talkative trait behavior. + #[serde(default)] + pub override_witness_inhibition: bool, +} + +/// Stage 2 scoring — influence line selection weighting. +/// +/// Delivery tags in `IndexedDialogueLine.tags` are matched against +/// the NPC's trait-derived preferred tags. Lines with matching tags +/// receive a scoring bonus during Layer 4 selection. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Stage2Scoring { + /// Preferred delivery tags for line selection weighting. + /// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`. + /// Lines with matching tags receive a scoring bonus. + #[serde(default)] + pub delivery_tags: Vec, +} + +// --------------------------------------------------------------------------- +// Filter predicate evaluation +// --------------------------------------------------------------------------- + +impl Stage1Filter { + /// Parse the min_confidence string into a `KnowledgeConfidence` value. + /// Returns `None` (use system default) for unparseable or absent values. + pub fn min_confidence_level(&self) -> Option { + self.min_confidence.as_deref().and_then(parse_confidence) + } + + /// Evaluate whether a fact passes this trait's Stage 1 filter. + /// + /// Returns `false` if the fact should be excluded by this trait. + /// The caller (#551) composes multiple trait filters: a fact is + /// included if it passes the composite filter. + pub fn allows_fact(&self, fact: &FactKnowledge) -> bool { + // Check minimum confidence + if let Some(min) = self.min_confidence_level() { + if fact.confidence < min { + return false; + } + } + + // Exclude ToldBy-source facts (Cautious behavior) + if self.exclude_told_by { + if matches!(fact.source, KnowledgeSource::ToldBy { .. }) { + return false; + } + } + + true + } +} + +impl Stage2Scoring { + /// Check if a line's tags contain any of this trait's preferred delivery tags. + /// Returns the number of matching tags (0 = no bonus). + pub fn tag_match_count(&self, line_tags: &[String]) -> usize { + self.delivery_tags + .iter() + .filter(|dt| line_tags.contains(dt)) + .count() + } + + /// Check if a line has at least one matching delivery tag. + pub fn has_matching_tag(&self, line_tags: &[String]) -> bool { + self.tag_match_count(line_tags) > 0 + } +} + +impl TraitModifierConfig { + /// Look up the modifier for a trait by name. + pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> { + self.modifiers.get(trait_name) + } + + /// Collect all Stage 2 delivery tags for a set of trait names. + /// Returns a deduplicated, sorted list for deterministic matching. + pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec { + let mut tags: Vec = trait_names + .iter() + .filter_map(|name| self.modifiers.get(name.as_str())) + .flat_map(|m| m.stage2.delivery_tags.iter().cloned()) + .collect(); + tags.sort(); + tags.dedup(); + tags + } + + /// Check if any trait in the set overrides witness inhibition. + pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool { + trait_names.iter().any(|name| { + self.modifiers + .get(name.as_str()) + .is_some_and(|m| m.stage1.override_witness_inhibition) + }) + } + + /// Check if any trait in the set excludes high-trust entity facts. + pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool { + trait_names.iter().any(|name| { + self.modifiers + .get(name.as_str()) + .is_some_and(|m| m.stage1.exclude_high_trust_entities) + }) + } + + /// Get the most permissive (lowest) min_confidence across all traits. + /// Returns None if no traits specify a minimum (use system default). + pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option { + trait_names + .iter() + .filter_map(|name| self.modifiers.get(name.as_str())) + .filter_map(|m| m.stage1.min_confidence_level()) + .min() + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a confidence string from YAML config to enum value. +/// Delegates to `KnowledgeConfidence::try_from` (which accepts both +/// camelCase and underscore forms) rather than duplicating the match. +fn parse_confidence(s: &str) -> Option { + KnowledgeConfidence::try_from(s).map_err(|e| { + tracing::warn!("Unknown confidence level in trait config: {}", e); + }).ok() +} + +/// Convert a `PersonalityTrait` to its lowercase YAML key. +/// Used to look up trait modifiers from the config. +pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str { + match trait_val { + super::PersonalityTrait::Cautious => "cautious", + super::PersonalityTrait::Bold => "bold", + super::PersonalityTrait::Honest => "honest", + super::PersonalityTrait::Deceptive => "deceptive", + super::PersonalityTrait::Compassionate => "compassionate", + super::PersonalityTrait::Ruthless => "ruthless", + super::PersonalityTrait::Curious => "curious", + super::PersonalityTrait::Incurious => "incurious", + super::PersonalityTrait::Social => "social", + super::PersonalityTrait::Reclusive => "reclusive", + } +} + +/// Convert an NPC's personality trait list to YAML config keys. +pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec { + traits.iter().map(|t| trait_to_key(t).to_string()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge { + FactKnowledge { + confidence, + source, + state: crate::knowledge::types::KnowledgeState::Active, + acquired_tick: 100, + disclosure_blocked: false, + } + } + + fn cautious_config() -> TraitModifierConfig { + let yaml = r#" +modifiers: + cautious: + stage1: + min_confidence: "knows_details" + exclude_told_by: true + stage2: + delivery_tags: ["cautious_delivery"] + gossipy: + stage1: + min_confidence: "suspects" + stage2: + delivery_tags: ["gossip_delivery", "casual_delivery"] + loyal: + stage1: + exclude_high_trust_entities: true + stage2: + delivery_tags: ["professional_delivery"] + talkative: + stage1: + override_witness_inhibition: true + min_confidence: "suspects" + stage2: + delivery_tags: ["casual_delivery", "gossip_delivery"] +"#; + serde_yaml::from_str(yaml).expect("valid trait config YAML") + } + + #[test] + fn parse_config_from_yaml() { + let config = cautious_config(); + assert_eq!(config.modifiers.len(), 4); + assert!(config.modifiers.contains_key("cautious")); + assert!(config.modifiers.contains_key("gossipy")); + assert!(config.modifiers.contains_key("loyal")); + assert!(config.modifiers.contains_key("talkative")); + } + + #[test] + fn cautious_excludes_low_confidence() { + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let suspects_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + let details_fact = make_fact( + KnowledgeConfidence::KnowsDetails, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + + assert!(!cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects"); + assert!(cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails"); + } + + #[test] + fn cautious_excludes_told_by() { + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let told_fact = make_fact( + KnowledgeConfidence::KnowsDetails, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(42), + tick: 50, + }, + ); + assert!(!cautious.allows_fact(&told_fact), "Cautious excludes ToldBy"); + } + + #[test] + fn gossipy_includes_suspects() { + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + let suspects_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!(gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects"); + } + + #[test] + fn talkative_overrides_witness_inhibition() { + let config = cautious_config(); + let traits = vec!["talkative".to_string()]; + assert!(config.any_overrides_witness_inhibition(&traits)); + + let traits = vec!["cautious".to_string()]; + assert!(!config.any_overrides_witness_inhibition(&traits)); + } + + #[test] + fn loyal_excludes_high_trust() { + let config = cautious_config(); + let traits = vec!["loyal".to_string()]; + assert!(config.any_excludes_high_trust(&traits)); + + let traits = vec!["gossipy".to_string()]; + assert!(!config.any_excludes_high_trust(&traits)); + } + + #[test] + fn lowest_min_confidence_picks_most_permissive() { + let config = cautious_config(); + // Gossipy (suspects) + Cautious (knows_details) → suspects wins + let traits = vec!["gossipy".to_string(), "cautious".to_string()]; + assert_eq!( + config.lowest_min_confidence(&traits), + Some(KnowledgeConfidence::Suspects) + ); + } + + #[test] + fn delivery_tags_deduped_and_sorted() { + let config = cautious_config(); + // Gossipy + Talkative both have "casual_delivery" and "gossip_delivery" + let traits = vec!["gossipy".to_string(), "talkative".to_string()]; + let tags = config.delivery_tags_for(&traits); + assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]); + } + + #[test] + fn stage2_tag_matching() { + let config = cautious_config(); + let scoring = &config.modifiers["cautious"].stage2; + + let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()]; + assert!(scoring.has_matching_tag(&line_tags)); + assert_eq!(scoring.tag_match_count(&line_tags), 1); + + let no_match_tags = vec!["gossip_delivery".to_string()]; + assert!(!scoring.has_matching_tag(&no_match_tags)); + } + + #[test] + fn unknown_trait_returns_none() { + let config = cautious_config(); + assert!(config.modifier_for("unknown_trait").is_none()); + } + + #[test] + fn trait_to_key_roundtrip() { + use super::super::PersonalityTrait; + assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious"); + assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold"); + assert_eq!(trait_to_key(&PersonalityTrait::Social), "social"); + } + + #[test] + fn traits_to_keys_conversion() { + use super::super::PersonalityTrait; + let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social]; + let keys = traits_to_keys(&traits); + assert_eq!(keys, vec!["cautious", "social"]); + } + + #[test] + fn empty_config_is_no_op() { + let config = TraitModifierConfig::default(); + let traits = vec!["cautious".to_string()]; + assert!(!config.any_overrides_witness_inhibition(&traits)); + assert!(!config.any_excludes_high_trust(&traits)); + assert_eq!(config.lowest_min_confidence(&traits), None); + assert!(config.delivery_tags_for(&traits).is_empty()); + } + + #[test] + fn default_filter_allows_everything() { + let filter = Stage1Filter::default(); + let fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(1), + tick: 10, + }, + ); + assert!(filter.allows_fact(&fact), "Default filter allows all facts"); + } + + #[test] + fn parse_confidence_values() { + assert_eq!(parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects)); + assert_eq!(parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf)); + assert_eq!(parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails)); + assert_eq!(parse_confidence("direct"), Some(KnowledgeConfidence::Direct)); + assert_eq!(parse_confidence("invalid"), None); + } + + // --- Coverage gap closure tests --- + + #[test] + fn cautious_excludes_knows_of_below_threshold() { + // Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails, + // so a KnowsOf fact must be excluded (not just Suspects). + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let knows_of_fact = make_fact( + KnowledgeConfidence::KnowsOf, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!( + !cautious.allows_fact(&knows_of_fact), + "Cautious should exclude KnowsOf (below knows_details threshold)" + ); + } + + #[test] + fn cautious_allows_direct_confidence() { + // Direct > KnowsDetails, so Direct passes cautious min_confidence. + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let direct_fact = make_fact( + KnowledgeConfidence::Direct, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!( + cautious.allows_fact(&direct_fact), + "Cautious should allow Direct confidence (above threshold)" + ); + } + + #[test] + fn gossipy_allows_all_confidence_levels() { + // Gossipy min_confidence is "suspects" — all confidence levels pass. + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + for (confidence, label) in [ + (KnowledgeConfidence::Suspects, "Suspects"), + (KnowledgeConfidence::KnowsOf, "KnowsOf"), + (KnowledgeConfidence::KnowsDetails, "KnowsDetails"), + (KnowledgeConfidence::Direct, "Direct"), + ] { + let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 }); + assert!( + gossipy.allows_fact(&fact), + "Gossipy should allow {} confidence", + label + ); + } + } + + #[test] + fn all_personality_traits_map_to_unique_keys() { + use super::super::PersonalityTrait; + + let all_traits = vec![ + PersonalityTrait::Cautious, + PersonalityTrait::Bold, + PersonalityTrait::Honest, + PersonalityTrait::Deceptive, + PersonalityTrait::Compassionate, + PersonalityTrait::Ruthless, + PersonalityTrait::Curious, + PersonalityTrait::Incurious, + PersonalityTrait::Social, + PersonalityTrait::Reclusive, + ]; + + let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect(); + + // All 10 traits produce a non-empty key + for (trait_, key) in all_traits.iter().zip(keys.iter()) { + assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_); + } + + // All keys are unique (no two traits share a key) + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + all_traits.len(), + "All personality traits must map to distinct keys" + ); + } + + #[test] + fn stage2_multiple_tag_matches_counts_correctly() { + // When a line has two matching delivery tags, tag_match_count returns 2. + let config = cautious_config(); + // Talkative has: ["casual_delivery", "gossip_delivery"] + let talkative_scoring = &config.modifiers["talkative"].stage2; + + let line_tags = vec![ + "casual_delivery".to_string(), + "gossip_delivery".to_string(), + "unrelated_tag".to_string(), + ]; + assert_eq!( + talkative_scoring.tag_match_count(&line_tags), + 2, + "Both delivery tags should match" + ); + assert!(talkative_scoring.has_matching_tag(&line_tags)); + } + + #[test] + fn gossipy_does_not_exclude_told_by() { + // Gossipy has no exclude_told_by restriction — it should pass ToldBy facts. + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + let told_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(5), + tick: 10, + }, + ); + assert!( + gossipy.allows_fact(&told_fact), + "Gossipy should not exclude ToldBy-source facts" + ); + } +} diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index b71ce5671..8ea8ef2f0 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -208,6 +208,7 @@ mod tests { world.insert_resource(WalkabilityMap::new(32, 32, 1)); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index c397cd166..af16f5259 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -505,6 +505,7 @@ fn knowledge_without_position_not_shown() { state: crate::knowledge::KnowledgeState::Active, relationship: RelationshipState::PersonOfInterest, known_attributes: std::collections::BTreeMap::new(), + contradicted_claim: None, }, ); @@ -800,6 +801,7 @@ fn phase2_no_confront_without_knows_details() { state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: std::collections::BTreeMap::new(), + contradicted_claim: None, }, ); @@ -2271,6 +2273,7 @@ fn access_rule_knowledge_gated_passes_with_matching_fact() { source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ); diff --git a/server/src/simulation/contraband.rs b/server/src/simulation/contraband.rs index a772b6b45..24fda918c 100644 --- a/server/src/simulation/contraband.rs +++ b/server/src/simulation/contraband.rs @@ -127,6 +127,7 @@ pub fn check_contraband_scan( source: KnowledgeSource::DirectObservation { tick: time.tick }, state: KnowledgeState::Active, acquired_tick: time.tick, + disclosure_blocked: false, }, ); @@ -463,6 +464,7 @@ mod tests { source: KnowledgeSource::DirectObservation { tick: 0 }, state: KnowledgeState::Active, acquired_tick: 0, + disclosure_blocked: false, }, ); diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 0a6b6cd42..0f802f54d 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; +use crate::content::types::KnowledgeGrant; use crate::content::LinePoolIndexResource; +use crate::knowledge::content_registry::ContentEntityRegistry; +use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant}; +use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId}; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; use crate::npc::relationships::{TrustEvent, TrustEventQueue}; @@ -422,6 +426,7 @@ pub fn process_talk_interaction( time: Res, line_pool: Option>, registry: Res, + content_registry: Res, mut rng: ResMut, mut event_queue: ResMut, mut trust_queue: ResMut, @@ -442,6 +447,7 @@ pub fn process_talk_interaction( Option<&mut InteractionMemory>, Option<&NpcName>, Option<&NpcColorIndex>, + Option<&KnowledgeGraph>, )>, ) { let Some(line_pool) = line_pool else { @@ -462,8 +468,8 @@ pub fn process_talk_interaction( let target = talk_request.target; - // Look up NPC dialogue profile, mood, interaction history, name, and color (#325) - let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = + // Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079) + let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) = npc_query.get_mut(target) else { tracing::debug!( @@ -541,6 +547,19 @@ pub fn process_talk_interaction( cooldown.record(&line.id, time.tick); + // Knowledge grant (D-079): fire at line selection time, server-authoritative. + if let Some(grant) = &line.knowledge_grant { + emit_knowledge_grant( + grant, + player_entity, + speaker_stable, + &content_registry, + npc_kg_opt, + time.tick, + &mut event_queue, + ); + } + // Emit IncompleteInteraction if overwriting an existing dialogue session if let Some(prev) = active_dialogue_opt { event_queue.push(crate::knowledge::KnowledgeEvent { @@ -594,6 +613,107 @@ pub fn process_talk_interaction( commands.entity(player_entity).remove::(); } +// --------------------------------------------------------------------------- +// Knowledge grant helper (D-079) +// --------------------------------------------------------------------------- + +/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field. +/// +/// Called at line selection time (server-authoritative, tick-stamped). +/// Source is always `ToldBy { source_id: speaker_stable, tick }`. +/// +/// Fact grants: dropped with tracing::warn! if the granting NPC's KG +/// does not contain the fact (D-079 runtime guardrail). +/// Entity grants: no guardrail — always emitted if entity_ref resolves. +#[allow(clippy::too_many_arguments)] +fn emit_knowledge_grant( + grant: &KnowledgeGrant, + player_entity: Entity, + speaker_stable: StableId, + content_registry: &ContentEntityRegistry, + npc_kg_opt: Option<&KnowledgeGraph>, + tick: u64, + event_queue: &mut crate::knowledge::KnowledgeEventQueue, +) { + let source = KnowledgeSource::ToldBy { + source_id: speaker_stable, + tick, + }; + + match grant { + KnowledgeGrant::Fact { fact_id, confidence } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let fid = FactId(fact_id.clone()); + // Guardrail: NPC must know this fact to grant it (D-079). + let npc_knows = npc_kg_opt + .map(|kg| kg.knows_fact(&fid)) + .unwrap_or(false); + if !npc_knows { + tracing::warn!( + "KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail", + speaker_stable, + fact_id + ); + return; + } + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { + fact_id: fid, + confidence: conf, + }), + source, + }, + }); + } + // Entity grants have no "NPC knows this entity" guardrail (unlike Fact + // grants above). This is intentional per D-079: entity grants introduce + // NEW knowledge about an entity the NPC is talking about — the NPC + // doesn't need to "know" the entity in their own KG to reference it + // in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG. + KnowledgeGrant::Entity { + entity_ref, + attributes, + confidence, + } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let Some(target_id) = content_registry.resolve(entity_ref) else { + tracing::warn!( + "KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry", + entity_ref + ); + return; + }; + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { + target_id, + attributes: attributes.clone(), + confidence: conf, + }), + source, + }, + }); + } + } +} + // --------------------------------------------------------------------------- // System: process_walk_away (D-064) // --------------------------------------------------------------------------- @@ -1330,6 +1450,7 @@ mod tests { world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -1828,6 +1949,7 @@ mod tests { let mut world = setup_dialogue_world(); world.init_resource::(); + world.init_resource::(); let npc = world.spawn_empty().id(); let npc_sid = world.resource_mut::().register(npc); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 4bf692c88..1ca9cc809 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -14,8 +14,11 @@ pub mod inventory; pub mod listening; pub mod monologue; pub mod movement; +pub mod npc_knowledge_transfer; pub mod path_follow; pub mod pathfinding; +pub mod poi; +pub mod poi_discovery; pub mod rng; pub mod sound; pub mod spatial; @@ -42,6 +45,15 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + // discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin). + // Init here so SimulationPlugin works standalone in tests without PerceptionPlugin. + .init_resource::() + // transfer_npc_knowledge reads RelationshipGraph (also init by NpcPlugin) and + // KnowledgeEventQueue (also init by KnowledgePlugin). + // Init here so SimulationPlugin works standalone in tests without those plugins. + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -58,9 +70,14 @@ impl Plugin for SimulationPlugin { conversation::run_npc_conversations .after(movement::validate_movement) .before(sound::collect_sound_events), + npc_knowledge_transfer::transfer_npc_knowledge + .after(conversation::run_npc_conversations), sound::collect_sound_events .after(movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), + poi_discovery::discover_pois + .after(crate::perception::observer::compute_visibility_geometry) + .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index b549567c9..5b0e1ef23 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -15,6 +15,8 @@ use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::content::ContentStoreResource; +use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry}; +use crate::simulation::conversation::NpcName; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; @@ -455,7 +457,10 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri "hear_sound" => HEAR_SOUND_LINES, "witness_interaction" => WITNESS_INTERACTION_LINES, "post_conversation" => POST_CONVERSATION_LINES, - _ => OBSERVE_NPC_LINES, + unknown => { + tracing::warn!("select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc", unknown); + OBSERVE_NPC_LINES + } }; let index = rng.random_range(0..lines.len()); (lines[index].0.to_string(), lines[index].1.to_string()) @@ -738,6 +743,102 @@ pub fn trigger_monologue( ); } +// --------------------------------------------------------------------------- +// Contradiction monologue trigger (#550, D-083) +// --------------------------------------------------------------------------- + +/// Hardcoded v0.1 contradiction monologue template lines. +/// Placeholders: `{source}` = NPC who gave false info, `{subject}` = NPC whose +/// position was contradicted. Hand-authored Sera/Kael lines come from copy team (#552). +const CONTRADICTION_TEMPLATE_LINES: &[&str] = &[ + "{source} told me where {subject} would be. They were wrong.", + "Something's off. {source} sent me the wrong way for {subject}.", + "{subject} wasn't where {source} said. Was that a mistake — or a lie?", +]; + +/// Resolve a display name from a StableId via EntityRegistry + NpcName query. +/// Falls back to `"#"` when the entity is not registered or has no NpcName. +/// Called from tests and available for future triggers needing live name resolution. +#[allow(dead_code)] +pub(crate) fn resolve_name( + stable_id: crate::knowledge::types::StableId, + registry: &EntityRegistry, + names: &Query<&NpcName>, +) -> String { + registry + .to_entity(&stable_id) + .and_then(|e| names.get(e).ok()) + .map(|n| n.0.clone()) + .unwrap_or_else(|| format!("#{}", stable_id.0)) +} + +/// Contradiction monologue trigger (#550, D-083). +/// +/// Drains ContradictionDetectedQueue once per tick. On first contradiction, +/// fires a monologue line with the pre-resolved source and subject names. +/// Bypasses normal cooldown (event-driven), but updates last_fired_tick. +/// +/// Relationship shift (PersonOfInterest) is already done by `process_knowledge_events` +/// before this system runs. This system is a pure consumer of the resolved strings. +/// +/// System ordering: after trigger_event_monologue, before compute_observer_snapshot. +pub fn process_contradiction_monologue( + time: Res, + _registry: Res, + mut rng: ResMut, + mut contradiction_queue: ResMut, + _npc_names: Query<&NpcName>, + mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With>, +) { + // _registry and _npc_names are available for future triggers needing live name resolution + // via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload. + + if contradiction_queue.is_empty() { + return; + } + + let Ok((mut buffer, mut state)) = player_query.single_mut() else { + contradiction_queue.drain(); + return; + }; + + // Don't override a higher-priority monologue that already fired this tick. + if buffer.event.is_some() { + contradiction_queue.drain(); + return; + } + + let events = contradiction_queue.drain(); + // Process only the first contradiction per tick (first-in wins). + let Some(event) = events.into_iter().next() else { + return; + }; + + let template_idx = rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len()); + let text = CONTRADICTION_TEMPLATE_LINES[template_idx] + .replace("{source}", &event.source_display_name) + .replace("{subject}", &event.subject_display_name); + + let id = format!("contradiction_{:02}", template_idx + 1); + + buffer.event = Some(MonologueEvent { + id: id.clone(), + text, + duration_seconds: DISPLAY_DURATION, + }); + + state.shown_ids.insert(id.clone()); + state.last_fired_tick = time.tick; + + tracing::debug!( + "Contradiction monologue fired: id={}, source={}, subject={}, tick={}", + id, + event.source_display_name, + event.subject_display_name, + time.tick, + ); +} + #[cfg(test)] mod tests { use super::*; @@ -2183,4 +2284,313 @@ mod tests { "last_observation_tick should track highest event tick" ); } + + // ----------------------------------------------------------------------- + // process_contradiction_monologue tests (#550, D-083) + // ----------------------------------------------------------------------- + + fn setup_contradiction_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world.insert_resource(ContradictionDetectedQueue::default()); + world.init_resource::(); + world + } + + fn spawn_contradiction_player(world: &mut bevy_ecs::world::World) -> bevy_ecs::entity::Entity { + world.spawn(( + PlayerCharacter, + TilePosition::new(0, 0, 0), + MonologueState::default(), + MonologueBuffer::default(), + )).id() + } + + #[test] + fn contradiction_monologue_fires_with_resolved_names() { + // ContradictionDetectedQueue has an event with pre-resolved names. + // process_contradiction_monologue should fire a monologue line containing both names. + let mut world = setup_contradiction_world(); + let player = spawn_contradiction_player(&mut world); + + // Advance past tick 0 so last_fired_tick=0 cooldown doesn't block + world.resource_mut::().tick = 500; + + // Populate the contradiction queue with pre-resolved names + world.resource_mut::().push( + crate::knowledge::ContradictionDetectedEvent { + observer: player, + target: crate::knowledge::types::StableId(2), + claim: crate::knowledge::types::ContradictionClaim { + told_by: crate::knowledge::types::StableId(1), + told_tick: 100, + claimed_position: TilePosition::new(5, 5, 0), + observed_position: TilePosition::new(10, 10, 0), + detected_tick: 500, + }, + source_display_name: "Sera".to_string(), + subject_display_name: "Kael".to_string(), + }, + ); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_contradiction_monologue); + schedule.run(&mut world); + + let buf = world.get::(player).unwrap(); + assert!(buf.event.is_some(), "contradiction monologue should fire"); + let event = buf.event.as_ref().unwrap(); + assert!( + event.text.contains("Sera"), + "monologue text should mention the source: got '{}'", + event.text + ); + assert!( + event.text.contains("Kael"), + "monologue text should mention the subject: got '{}'", + event.text + ); + // ID should be contradiction_01 / _02 / _03 + assert!( + event.id.starts_with("contradiction_"), + "monologue id should be contradiction_NN: got '{}'", + event.id + ); + } + + #[test] + fn contradiction_monologue_does_not_override_existing_buffer() { + // If MonologueBuffer already has an event, contradiction must not clobber it. + let mut world = setup_contradiction_world(); + let player = spawn_contradiction_player(&mut world); + + // Pre-fill buffer with a higher-priority monologue + world.get_mut::(player).unwrap().set(MonologueEvent { + id: "prior_event".to_string(), + text: "Something already fired.".to_string(), + duration_seconds: 5.0, + }); + + world.resource_mut::().push( + crate::knowledge::ContradictionDetectedEvent { + observer: player, + target: crate::knowledge::types::StableId(2), + claim: crate::knowledge::types::ContradictionClaim { + told_by: crate::knowledge::types::StableId(1), + told_tick: 100, + claimed_position: TilePosition::new(5, 5, 0), + observed_position: TilePosition::new(10, 10, 0), + detected_tick: 100, + }, + source_display_name: "Sera".to_string(), + subject_display_name: "Kael".to_string(), + }, + ); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_contradiction_monologue); + schedule.run(&mut world); + + // Buffer should still have the prior event + let buf = world.get::(player).unwrap(); + let event = buf.event.as_ref().unwrap(); + assert_eq!(event.id, "prior_event", "prior monologue should not be overridden"); + + // Queue should have been drained regardless + assert!( + world.resource::().is_empty(), + "queue should be drained even when buffer is occupied" + ); + } + + #[test] + fn contradiction_monologue_drains_queue_when_no_player() { + // If there's no player entity, the queue must still be drained (no panic). + let mut world = setup_contradiction_world(); + // No player spawned + + let fake_world_entity = world.spawn_empty().id(); + world.resource_mut::().push( + crate::knowledge::ContradictionDetectedEvent { + observer: fake_world_entity, + target: crate::knowledge::types::StableId(2), + claim: crate::knowledge::types::ContradictionClaim { + told_by: crate::knowledge::types::StableId(1), + told_tick: 100, + claimed_position: TilePosition::new(1, 1, 0), + observed_position: TilePosition::new(5, 5, 0), + detected_tick: 100, + }, + source_display_name: "Unknown".to_string(), + subject_display_name: "Unknown".to_string(), + }, + ); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_contradiction_monologue); + schedule.run(&mut world); // should not panic + + assert!( + world.resource::().is_empty(), + "queue should be drained even without a player" + ); + let _ = fake_world_entity; // suppress unused warning + } + + #[test] + fn the_friend_arc_integration_full_sequence() { + // THE FRIEND arc integration test (#550, D-083). + // + // Tick T1: KnowledgeGranted creates ToldBy entry (Kael at (5,5), told by Sera) + // Tick T2: DirectObservation fires ContradictionDetected (Kael at (10,10)) + // → relationship shift: Sera becomes PersonOfInterest in player's KG + // → ContradictionDetectedEvent pushed with resolved names + // Tick T3: process_contradiction_monologue fires monologue with Sera/Kael names + // + // Tests the full D-083 event chain end-to-end. + use crate::knowledge::{ + EntityRegistry, KnowledgeGraph, KnowledgeEventQueue, KnowledgeEventType, + }; + use crate::knowledge::events::{process_knowledge_events, KnowledgeEvent}; + use crate::knowledge::registry::StableEntityId; + use crate::knowledge::types::{ + EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, + RelationshipState, StableId, + }; + use std::collections::BTreeMap; + + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(7)); + world.init_resource::(); + + // Registry + let mut registry = EntityRegistry::new(0); + + // Spawn NPCs with NpcName components + let sera_entity = world.spawn(NpcName("Sera".to_string())).id(); + let kael_entity = world.spawn(( + NpcName("Kael".to_string()), + TilePosition::new(10, 10, 0), + )).id(); + + let sera_sid = registry.register(sera_entity); + let kael_sid = registry.register(kael_entity); + + // Spawn player with KnowledgeGraph + let mut player_kg = KnowledgeGraph::new(); + + // Tick T1: Pre-populate KG with ToldBy entry — Sera told us Kael is at (5,5) + player_kg.entities.insert( + kael_sid, + EntityKnowledge { + last_known_position: Some(TilePosition::new(5, 5, 0)), + last_observed_tick: 0, + last_updated_tick: 100, + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::ToldBy { + source_id: sera_sid, + tick: 100, + }, + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + known_attributes: BTreeMap::new(), + contradicted_claim: None, + }, + ); + + let player = world.spawn(( + PlayerCharacter, + TilePosition::new(0, 0, 0), + MonologueState::default(), + MonologueBuffer::default(), + player_kg, + StableEntityId(StableId(999)), + )).id(); + registry.register(player); + + world.insert_resource(registry); + + // Tick T2: Push DirectObservation of Kael at (10,10) — contradicts (5,5) + let tick_t2 = 200u64; + world.resource_mut::().tick = tick_t2; + + let mut ke_queue = KnowledgeEventQueue::default(); + ke_queue.push(KnowledgeEvent { + observer: player, + tick: tick_t2, + event_type: KnowledgeEventType::DirectObservation { + target: kael_entity, + position: TilePosition::new(10, 10, 0), + }, + }); + world.insert_resource(ke_queue); + + let mut schedule_t2 = bevy_ecs::schedule::Schedule::default(); + schedule_t2.add_systems(process_knowledge_events); + schedule_t2.run(&mut world); + + // Verify contradiction was detected and queued + { + let cq = world.resource::(); + assert!(!cq.is_empty(), "contradiction should be in queue after T2"); + } + + // Verify Sera became PersonOfInterest in player's KG + { + let kg = world.get::(player).unwrap(); + let sera_entry = kg.entity_knowledge(&sera_sid); + assert!( + sera_entry.is_some(), + "Sera should have an entry in player's KG after contradiction" + ); + assert_eq!( + sera_entry.unwrap().relationship, + RelationshipState::PersonOfInterest, + "Sera should be PersonOfInterest after giving false location info" + ); + } + + // Verify ContradictionDetectedEvent has correct pre-resolved names + { + // Drain to inspect event contents, then re-push for the monologue consumer. + let mut events = world.resource_mut::().drain(); + assert_eq!(events.len(), 1, "should have exactly one contradiction event"); + let event = &events[0]; + assert_eq!(event.source_display_name, "Sera"); + assert_eq!(event.subject_display_name, "Kael"); + // Re-push so process_contradiction_monologue can consume it on T3. + let event = events.remove(0); + world.resource_mut::().push(event); + } + + // Tick T3: Run process_contradiction_monologue + world.resource_mut::().tick = 300; + + let mut schedule_t3 = bevy_ecs::schedule::Schedule::default(); + schedule_t3.add_systems(process_contradiction_monologue); + schedule_t3.run(&mut world); + + // Verify monologue fired with both names + let buf = world.get::(player).unwrap(); + assert!(buf.event.is_some(), "monologue should fire on T3"); + let mono_event = buf.event.as_ref().unwrap(); + assert!( + mono_event.text.contains("Sera"), + "monologue text should mention Sera: '{}'", + mono_event.text + ); + assert!( + mono_event.text.contains("Kael"), + "monologue text should mention Kael: '{}'", + mono_event.text + ); + + // Verify queue is drained after monologue fires + assert!( + world.resource::().is_empty(), + "queue should be empty after monologue consumed the event" + ); + } } diff --git a/server/src/simulation/npc_knowledge_transfer.rs b/server/src/simulation/npc_knowledge_transfer.rs new file mode 100644 index 000000000..311593acb --- /dev/null +++ b/server/src/simulation/npc_knowledge_transfer.rs @@ -0,0 +1,842 @@ +//! NPC-to-NPC knowledge transfer system (D-080, ticket #548). +//! +//! When an NPC-to-NPC conversation starts (`Added`), this system +//! transfers a sample of the speaker's KG entries to the listener (ToldBy source). +//! Transfer eligibility and volume are gated by the trust level between the two NPCs +//! from `RelationshipGraph`. The confidence cap (max KnowsOf) ensures information +//! degrades as it propagates through social networks. +//! +//! Player overhear: if the player is within VOICE_RANGE_TILES of the conversation, +//! they gain entity-level knowledge about both NPCs at Suspects confidence +//! (Heard source). This models ambient social information gathering. +//! +//! Closes Q-024: NPC-to-NPC propagation rate. + +use bevy_ecs::prelude::*; +use rand::Rng; +use std::collections::BTreeMap; + +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::{ + EntityKnowledge, FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, + RelationshipState, SoundRange, StableId, +}; +use crate::knowledge::{ + KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, ProcessedEntityGrant, + ProcessedKnowledgeGrant, +}; +use crate::npc::relationships::RelationshipGraph; +use crate::npc::Npc; +use crate::simulation::conversation::NpcConversation; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::rng::SimRng; +use crate::simulation::time::SimulationTime; +use crate::simulation::tier::ActiveSim; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Range within which player can overhear NPC-to-NPC knowledge exchange. +/// Matches VOICE_RANGE_TILES in conversation.rs (D-018 Medium = 8 tiles). +const VOICE_RANGE_TILES: u32 = 8; + +// --------------------------------------------------------------------------- +// Trust tier (NPC-to-NPC, D-080) +// --------------------------------------------------------------------------- + +/// Trust tier for NPC-to-NPC knowledge transfer. +/// Derived from `RelationshipEdge.trust` (i8 in -10..+10). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NpcTransferTier { + /// trust < 0: no knowledge flows. + None, + /// trust 0..=2: Active facts at KnowsOf+ confidence only. + Surface, + /// trust 3..=6: Active facts at any confidence + entity observations. + Real, + /// trust 7..=10: All Active entries (facts at any confidence + all entities). + Secret, +} + +/// Map a raw trust score to the NPC transfer tier (D-080 boundaries). +fn trust_to_tier(trust: i8) -> NpcTransferTier { + if trust < 0 { + NpcTransferTier::None + } else if trust >= 7 { + NpcTransferTier::Secret + } else if trust >= 3 { + NpcTransferTier::Real + } else { + NpcTransferTier::Surface + } +} + +// --------------------------------------------------------------------------- +// Transfer candidate pool +// --------------------------------------------------------------------------- + +/// Tagged entry in the transfer candidate pool. +/// Used to sort facts and entity entries by recency before drawing. +enum TransferCandidate { + Fact { + id: FactId, + confidence: KnowledgeConfidence, + acquired_tick: u64, + }, + Entity { + id: StableId, + ek: EntityKnowledge, + }, +} + +impl TransferCandidate { + /// Sort key: most recently updated entries are preferred. + fn sort_key(&self) -> u64 { + match self { + Self::Fact { acquired_tick, .. } => *acquired_tick, + Self::Entity { ek, .. } => ek.last_updated_tick, + } + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// System: transfer NPC knowledge at conversation start (D-080). +/// +/// Fires once per conversation (triggered by `Added`). +/// Reads the trust level between the two NPCs from `RelationshipGraph` and +/// transfers a sample (1–3 entries) of the speaker's eligible KG entries to +/// the listener's KG with `ToldBy` source and confidence capped at `KnowsOf`. +/// +/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge +/// about both NPCs at `Suspects` confidence (`Heard` source). +/// +/// System ordering: after(run_npc_conversations). +#[allow(clippy::too_many_arguments)] +pub fn transfer_npc_knowledge( + time: Res, + mut rng: ResMut, + relationship_graph: Res, + mut event_queue: ResMut, + // NPCs that just started a conversation — Added fires once per conversation. + new_conv_query: Query< + (Entity, &NpcConversation, &TilePosition, Option<&StableEntityId>), + (With, With, Added), + >, + // Read-only StableEntityId on NPC partner (distinct query, no KG conflict). + partner_sid_query: Query, With>, + // Mutable KG access — get_many_mut for dual-entity borrow safety. + mut kg_query: Query<&mut KnowledgeGraph>, + // Player position for overhear radius check. + player_query: Query<(Entity, &TilePosition), With>, +) { + let tick = time.tick; + + for (speaker_entity, conv, speaker_pos, speaker_sid_opt) in new_conv_query.iter() { + let partner_entity = conv.partner; + + // --- Resolve stable IDs --- + + let speaker_sid = match speaker_sid_opt.map(|s| s.0) { + Some(sid) => sid, + None => { + tracing::debug!( + "NPC transfer: speaker {:?} has no StableEntityId, skipping", + speaker_entity + ); + continue; + } + }; + + let partner_sid = match partner_sid_query + .get(partner_entity) + .ok() + .and_then(|opt| opt.map(|s| s.0)) + { + Some(sid) => sid, + None => { + tracing::debug!( + "NPC transfer: partner {:?} has no StableEntityId, skipping", + partner_entity + ); + continue; + } + }; + + // --- Trust level → transfer tier --- + // + // Use the speaker's trust in the listener: the speaker decides what to share + // based on how much they trust this particular person. Defaults to 0 (Surface) + // when no relationship edge exists — strangers can still overhear ambient facts. + let trust = relationship_graph + .get_relationship(&speaker_sid, &partner_sid) + .map(|e| e.trust) + .unwrap_or(0); + let tier = trust_to_tier(trust); + + if tier == NpcTransferTier::None { + tracing::debug!( + "NPC transfer: trust={} (None tier) between {:?} ↔ {:?}, skipping", + trust, + speaker_entity, + partner_entity + ); + // Still do player overhear (NPCs are audibly talking even if not exchanging info) + emit_player_overhear_grants( + &player_query, + speaker_pos, + tick, + speaker_sid, + partner_sid, + &mut event_queue, + ); + continue; + } + + // --- Dual-mutable KG access --- + // Transfer is one-directional per conversation tick: speaker → partner. + // If both participants are Active NPCs, each fires as "speaker" in + // separate conversation pairs (run_npc_conversations creates symmetric + // pairs), so both directions are covered across two iterations. + + let Ok([speaker_kg, mut partner_kg]) = + kg_query.get_many_mut([speaker_entity, partner_entity]) + else { + tracing::debug!( + "NPC transfer: couldn't get KGs for {:?} / {:?}, skipping", + speaker_entity, + partner_entity + ); + continue; + }; + + // --- Build candidate pool from speaker's KG --- + + let mut candidates: Vec = Vec::new(); + + // Facts: always eligible (filtered by tier and disclosure_blocked) + for (fid, fk) in speaker_kg.facts.iter() { + if fk.disclosure_blocked || fk.state != KnowledgeState::Active { + continue; + } + let eligible = match tier { + NpcTransferTier::Surface => fk.confidence >= KnowledgeConfidence::KnowsOf, + NpcTransferTier::Real | NpcTransferTier::Secret => true, + NpcTransferTier::None => unreachable!("None tier handled above"), + }; + if eligible { + candidates.push(TransferCandidate::Fact { + id: fid.clone(), + confidence: fk.confidence, + acquired_tick: fk.acquired_tick, + }); + } + } + + // Entity observations: Real and Secret tiers only + if tier == NpcTransferTier::Real || tier == NpcTransferTier::Secret { + for (sid, ek) in speaker_kg.entities.iter() { + if ek.state != KnowledgeState::Active { + continue; + } + candidates.push(TransferCandidate::Entity { + id: *sid, + ek: ek.clone(), + }); + } + } + + if candidates.is_empty() { + tracing::debug!( + "NPC transfer: no eligible entries in speaker {:?} KG at {:?} tier", + speaker_entity, + tier + ); + emit_player_overhear_grants( + &player_query, + speaker_pos, + tick, + speaker_sid, + partner_sid, + &mut event_queue, + ); + continue; + } + + // Sort by most recently updated (deterministic: descending tick, stable by BTreeMap key order) + candidates.sort_by(|a, b| b.sort_key().cmp(&a.sort_key())); + + // Take top 1–3 entries by recency (random count, deterministic selection). + // The random element is HOW MANY facts transfer, not WHICH ones. + let count = rng.rng.random_range(1u32..=3u32) as usize; + let count = count.min(candidates.len()); + + tracing::debug!( + "NPC transfer: {:?} → {:?}, tier={:?}, trust={}, drawing {}/{}", + speaker_entity, + partner_entity, + tier, + trust, + count, + candidates.len(), + ); + + // --- Apply transfers to partner's KG --- + + for candidate in candidates.into_iter().take(count) { + match candidate { + TransferCandidate::Fact { id, confidence, .. } => { + // Confidence cap: speaker's knowledge degrades to at most KnowsOf. + let capped = confidence.min(KnowledgeConfidence::KnowsOf); + + // Upgrade-only: never downgrade existing knowledge. + let should_write = partner_kg + .facts + .get(&id) + .map(|existing| existing.confidence < capped) + .unwrap_or(true); + + if should_write { + partner_kg.facts.insert( + id.clone(), + FactKnowledge { + confidence: capped, + source: KnowledgeSource::ToldBy { + source_id: speaker_sid, + tick, + }, + state: KnowledgeState::Active, + acquired_tick: tick, + disclosure_blocked: false, + }, + ); + tracing::debug!( + "NPC transfer: fact {:?} at {:?} → {:?}", + id, + capped, + partner_entity + ); + } + } + + TransferCandidate::Entity { id, ek } => { + // Confidence cap: at most KnowsOf. + let capped = ek.confidence.min(KnowledgeConfidence::KnowsOf); + + // Preserve existing relationship state if the partner already knows this entity. + let (should_write, existing_relationship) = + match partner_kg.entities.get(&id) { + None => (true, RelationshipState::Unknown), + Some(existing) => { + (existing.confidence < capped, existing.relationship) + } + }; + + if should_write { + partner_kg.entities.insert( + id, + EntityKnowledge { + last_known_position: ek.last_known_position, + last_observed_tick: ek.last_observed_tick, + last_updated_tick: tick, + confidence: capped, + source: KnowledgeSource::ToldBy { + source_id: speaker_sid, + tick, + }, + state: KnowledgeState::Active, + relationship: existing_relationship, + known_attributes: ek.known_attributes.clone(), + contradicted_claim: None, + }, + ); + tracing::debug!( + "NPC transfer: entity {:?} at {:?} → {:?}", + id, + capped, + partner_entity + ); + } + } + } + } + + // --- Player overhear grants --- + + emit_player_overhear_grants( + &player_query, + speaker_pos, + tick, + speaker_sid, + partner_sid, + &mut event_queue, + ); + } +} + +/// Emit `Heard` entity grants to all players within VOICE_RANGE_TILES of a conversation. +/// +/// Called regardless of transfer tier — even if the NPCs aren't sharing information, +/// the player can still learn that these two entities exist from overhearing them talk. +fn emit_player_overhear_grants( + player_query: &Query<(Entity, &TilePosition), With>, + speaker_pos: &TilePosition, + tick: u64, + speaker_sid: StableId, + partner_sid: StableId, + event_queue: &mut KnowledgeEventQueue, +) { + for (player_entity, player_pos) in player_query.iter() { + let distance = speaker_pos + .manhattan_distance(player_pos) + .unwrap_or(u32::MAX); + + if distance <= VOICE_RANGE_TILES { + // Player overhears both participants — learns they exist at Suspects level. + for npc_sid in [speaker_sid, partner_sid] { + event_queue.push(KnowledgeEvent { + observer: player_entity, + tick, + event_type: KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { + target_id: npc_sid, + attributes: BTreeMap::new(), + confidence: KnowledgeConfidence::Suspects, + }), + source: KnowledgeSource::Heard { + tick, + range: SoundRange::Medium, + }, + }, + }); + } + tracing::debug!( + "Player {:?} overhears conversation at distance {} tiles (D-080)", + player_entity, + distance + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::prelude::*; + + use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeState, StableId}; + use crate::knowledge::{EntityRegistry, KnowledgeGraph}; + use crate::npc::relationships::{RelationshipEdge, RelationshipGraph}; + use crate::npc::RelationshipKind; + use crate::simulation::conversation::NpcConversation; + use crate::simulation::movement::TilePosition; + use crate::simulation::rng::SimRng; + use crate::simulation::time::SimulationTime; + use crate::simulation::tier::ActiveSim; + + fn build_test_world() -> App { + let mut app = App::new(); + app.init_resource::(); + app.insert_resource(SimRng::new(42)); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.add_systems(Update, transfer_npc_knowledge); + app + } + + /// Spawn a minimal NPC entity with the required components. + /// The StableEntityId component is what the transfer system reads — no registry needed. + fn spawn_npc(world: &mut World, sid: StableId, kg: KnowledgeGraph) -> Entity { + world + .spawn(( + crate::npc::Npc, + ActiveSim, + StableEntityId(sid), + TilePosition { x: 0, y: 0, z: 0 }, + kg, + )) + .id() + } + + fn make_fact_kg(fact_id: &str, confidence: KnowledgeConfidence) -> KnowledgeGraph { + let mut kg = KnowledgeGraph::new(); + kg.facts.insert( + FactId(fact_id.to_string()), + FactKnowledge { + confidence, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 1, + disclosure_blocked: false, + }, + ); + kg + } + + // --- Trust tier mapping --- + + #[test] + fn trust_none_below_zero() { + assert_eq!(trust_to_tier(-1), NpcTransferTier::None); + assert_eq!(trust_to_tier(-10), NpcTransferTier::None); + } + + #[test] + fn trust_surface_zero_to_two() { + assert_eq!(trust_to_tier(0), NpcTransferTier::Surface); + assert_eq!(trust_to_tier(2), NpcTransferTier::Surface); + } + + #[test] + fn trust_real_three_to_six() { + assert_eq!(trust_to_tier(3), NpcTransferTier::Real); + assert_eq!(trust_to_tier(6), NpcTransferTier::Real); + } + + #[test] + fn trust_secret_seven_plus() { + assert_eq!(trust_to_tier(7), NpcTransferTier::Secret); + assert_eq!(trust_to_tier(10), NpcTransferTier::Secret); + } + + // --- Confidence cap --- + + #[test] + fn confidence_cap_downgrades_details_to_knows_of() { + let capped = KnowledgeConfidence::KnowsDetails.min(KnowledgeConfidence::KnowsOf); + assert_eq!(capped, KnowledgeConfidence::KnowsOf); + } + + #[test] + fn confidence_cap_preserves_lower_confidence() { + let capped = KnowledgeConfidence::Suspects.min(KnowledgeConfidence::KnowsOf); + assert_eq!(capped, KnowledgeConfidence::Suspects); + } + + // --- Surface tier: KnowsOf+ facts only --- + + #[test] + fn surface_tier_transfers_knows_of_fact() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf); + let kg_b = KnowledgeGraph::new(); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + // Trust = 1 → Surface tier + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 1, + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + // Start conversation — tick 0, so started_tick == 0 + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + // Partner should now know the fact + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + assert!( + partner_kg.knows_fact(&FactId("test.fact".to_string())), + "partner should know test.fact after surface-tier transfer" + ); + } + + #[test] + fn surface_tier_blocks_suspects_fact() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + // Suspects confidence — below KnowsOf threshold for Surface tier + let kg_a = make_fact_kg("test.secret", KnowledgeConfidence::Suspects); + let kg_b = KnowledgeGraph::new(); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 1, + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + assert!( + !partner_kg.knows_fact(&FactId("test.secret".to_string())), + "Suspects fact should not transfer at Surface tier" + ); + } + + #[test] + fn none_tier_does_not_transfer() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf); + let kg_b = KnowledgeGraph::new(); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: -1, // None tier + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + assert!( + !partner_kg.knows_fact(&FactId("test.fact".to_string())), + "No transfer should occur at None tier" + ); + } + + #[test] + fn disclosure_blocked_fact_never_transfers() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + + // KnowledgeGraph with disclosure_blocked fact + let mut kg_a = KnowledgeGraph::new(); + kg_a.facts.insert( + FactId("secret.blocked".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsDetails, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 1, + disclosure_blocked: true, // blocks NPC transfer + }, + ); + let kg_b = KnowledgeGraph::new(); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 10, // Max trust — still blocked + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + assert!( + !partner_kg.knows_fact(&FactId("secret.blocked".to_string())), + "disclosure_blocked fact must never transfer regardless of trust" + ); + } + + #[test] + fn confidence_cap_applied_on_transfer() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + // KnowsDetails — should be capped to KnowsOf after transfer + let kg_a = make_fact_kg("test.detail", KnowledgeConfidence::KnowsDetails); + let kg_b = KnowledgeGraph::new(); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 8, // Secret tier + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + let fact = partner_kg + .facts + .get(&FactId("test.detail".to_string())) + .expect("fact should be transferred"); + assert_eq!( + fact.confidence, + KnowledgeConfidence::KnowsOf, + "transferred confidence must be capped at KnowsOf" + ); + } + + #[test] + fn upgrade_only_never_downgrades_existing_knowledge() { + let mut app = build_test_world(); + + let sid_a = StableId(1); + let sid_b = StableId(2); + + // Speaker knows fact at KnowsOf + let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf); + + // Partner already knows fact at KnowsDetails (higher than speaker) + let mut kg_b = KnowledgeGraph::new(); + kg_b.facts.insert( + FactId("test.fact".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsDetails, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + ); + + let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a); + let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b); + + { + let mut rel = app.world_mut().resource_mut::(); + rel.set_relationship( + sid_a, + sid_b, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 5, + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.world_mut().entity_mut(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }); + + app.update(); + + let partner_kg = app + .world() + .entity(entity_b) + .get::() + .expect("partner has KG"); + let fact = partner_kg + .facts + .get(&FactId("test.fact".to_string())) + .expect("fact exists"); + assert_eq!( + fact.confidence, + KnowledgeConfidence::KnowsDetails, + "existing higher confidence must not be downgraded" + ); + } +} diff --git a/server/src/simulation/poi.rs b/server/src/simulation/poi.rs new file mode 100644 index 000000000..04eade838 --- /dev/null +++ b/server/src/simulation/poi.rs @@ -0,0 +1,191 @@ +//! Point of Interest data model (#148). +//! +//! POIs are discoverable world locations: quest-relevant places, hidden +//! areas, landmarks, vendors, etc. They integrate with the knowledge +//! graph via `FactId("poi.*")` namespace per D-079. +//! +//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted` +//! with `Fact` variant to grant POI facts to observers. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::types::FactId; +use crate::simulation::movement::TilePosition; + +/// Category of point of interest. Determines client-side icon and color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PoiCategory { + /// Named location (dock, bar, office, residential block). + Location, + /// Vendor or service provider (fixer, medic, data broker). + Service, + /// Quest-relevant target (drop point, meeting place, evidence site). + QuestTarget, + /// Hidden area (secret passage, concealed cache, restricted zone). + Hidden, + /// Navigation landmark visible from a distance. + Landmark, +} + +/// How a POI was placed in the world (content provenance). +/// +/// Distinct from visibility rules: discovery_source tracks *why* the POI +/// exists; visibility tracks *how* it can be found. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PoiDiscoverySource { + /// Part of the map template — always present on this map. + MapTemplate, + /// Procedurally generated at world creation. + Procedural, + /// Created by a quest or storyline event at runtime. + QuestGenerated, + /// Revealed by NPC testimony via knowledge grant. + NpcRevealed, +} + +/// Rules governing when an observer can discover this POI. +/// +/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge +/// graph. The discovery system (#149) evaluates these rules each tick +/// for POIs not yet known to the observer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PoiVisibility { + /// Discoverable when within line of sight (standard LOS rules). + LineOfSight, + /// Discoverable only within a specific tile range (Manhattan distance). + Proximity { range: u32 }, + /// Not discoverable by observation. Requires a `KnowledgeGranted` + /// event from dialogue, evidence, or NPC testimony. + KnowledgeOnly, + /// Discoverable by LOS, but only if the observer already knows a + /// prerequisite fact. Example: a hidden door visible only if the + /// observer knows `"quest.secret_passage_hint"`. + RequiresFact { fact_id: String }, +} + +/// Point of Interest ECS component (#148). +/// +/// Attached to world entities that represent discoverable locations. +/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added +/// to their `KnowledgeGraph` via the discovery system (#149). +/// +/// BTreeMap ordering note: POI entities use `StableEntityId` like all +/// other entities. The `poi_id` string is for the fact namespace only. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct PointOfInterest { + /// Unique identifier within the `poi.*` fact namespace. + /// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`. + /// Must be unique across all POIs in the world. + pub poi_id: String, + /// Display name shown to the player after discovery. + pub name: String, + /// World position of the POI (center tile). + pub position: TilePosition, + /// Category for client-side rendering (icon, minimap marker). + pub category: PoiCategory, + /// Content provenance — how this POI was placed in the world. + pub discovery_source: PoiDiscoverySource, + /// Rules for when/how an observer can discover this POI. + pub visibility: PoiVisibility, +} + +impl PointOfInterest { + /// Generate the `FactId` for this POI in the knowledge graph. + /// Format: `"poi.{poi_id}"` per D-079 namespace convention. + pub fn fact_id(&self) -> FactId { + FactId(format!("poi.{}", self.poi_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest { + PointOfInterest { + poi_id: id.to_string(), + name: format!("Test POI {}", id), + position: TilePosition::new(10, 20, 0), + category, + discovery_source: PoiDiscoverySource::MapTemplate, + visibility, + } + } + + #[test] + fn fact_id_uses_poi_namespace() { + let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight); + assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string())); + } + + #[test] + fn fact_id_format_is_deterministic() { + let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly); + let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly); + assert_eq!(poi1.fact_id(), poi2.fact_id()); + } + + #[test] + fn different_poi_ids_produce_different_fact_ids() { + let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight); + let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight); + assert_ne!(poi1.fact_id(), poi2.fact_id()); + } + + #[test] + fn proximity_visibility_stores_range() { + let poi = make_poi( + "hidden_cache", + PoiCategory::Hidden, + PoiVisibility::Proximity { range: 5 }, + ); + match poi.visibility { + PoiVisibility::Proximity { range } => assert_eq!(range, 5), + _ => panic!("Expected Proximity visibility"), + } + } + + #[test] + fn requires_fact_visibility_stores_fact_id() { + let poi = make_poi( + "secret_door", + PoiCategory::Hidden, + PoiVisibility::RequiresFact { + fact_id: "quest.secret_passage_hint".to_string(), + }, + ); + match &poi.visibility { + PoiVisibility::RequiresFact { fact_id } => { + assert_eq!(fact_id, "quest.secret_passage_hint"); + } + _ => panic!("Expected RequiresFact visibility"), + } + } + + #[test] + fn poi_categories_are_distinct() { + assert_ne!(PoiCategory::Location, PoiCategory::Service); + assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden); + assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark); + } + + #[test] + fn poi_discovery_sources_are_distinct() { + assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural); + assert_ne!( + PoiDiscoverySource::QuestGenerated, + PoiDiscoverySource::NpcRevealed + ); + } + + #[test] + fn poi_serialization_roundtrip() { + let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight); + let serialized = serde_yaml::to_string(&poi).expect("serialize"); + let deserialized: PointOfInterest = + serde_yaml::from_str(&serialized).expect("deserialize"); + assert_eq!(deserialized.poi_id, "med_bay"); + assert_eq!(deserialized.category, PoiCategory::Service); + } +} diff --git a/server/src/simulation/poi_discovery.rs b/server/src/simulation/poi_discovery.rs new file mode 100644 index 000000000..8056d7228 --- /dev/null +++ b/server/src/simulation/poi_discovery.rs @@ -0,0 +1,426 @@ +//! POI discovery system (#149). +//! +//! Detects when the player observer discovers a Point of Interest and +//! grants the corresponding `FactId("poi.*")` to their knowledge graph. +//! +//! Discovery methods handled here: +//! - Physical discovery (LOS, proximity) — checked each tick +//! +//! Discovery methods handled elsewhere: +//! - Character background — inserted at spawn time by content system +//! - NPC tips / research — via `KnowledgeGranted` event (#546) + +use bevy_ecs::prelude::*; + +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::types::{ + FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, +}; +use crate::perception::query::VisibilityGeometry; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::poi::{PoiVisibility, PointOfInterest}; +use crate::simulation::time::SimulationTime; + +/// Event emitted when the player discovers a POI. +/// +/// Other systems (monologue, minimap update, storyteller) can react to +/// this event. Consumed and cleared each tick. +#[derive(Debug, Clone)] +pub struct PoiDiscoveredEvent { + /// The `poi_id` string of the discovered POI. + pub poi_id: String, + /// Display name for monologue/UI use. + pub name: String, + /// Tick when discovered. + pub tick: u64, +} + +/// Resource: queue of POI discovery events from the current tick. +#[derive(Resource, Default)] +pub struct PoiDiscoveryEventQueue { + events: Vec, +} + +impl PoiDiscoveryEventQueue { + pub fn push(&mut self, event: PoiDiscoveredEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// System: check for POI physical discovery by the player observer. +/// +/// Runs after visibility geometry is computed. For each undiscovered POI, +/// checks visibility rules against the observer's position and known facts. +/// Discovered POIs are added as `FactId("poi.*")` facts to the observer's +/// KnowledgeGraph with `DirectObservation` source. +pub fn discover_pois( + time: Res, + geometry: Res, + mut discovery_queue: ResMut, + poi_query: Query<&PointOfInterest>, + mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With>, +) { + let Ok((observer_pos, mut kg)) = observer_query.single_mut() else { + return; + }; + + for poi in poi_query.iter() { + let fact_id = poi.fact_id(); + + // Skip already-known POIs + if kg.knows_fact(&fact_id) { + continue; + } + + if can_discover(observer_pos, &geometry, &kg, poi) { + // Direct KG write — bypasses the KnowledgeGranted event queue. + // Justified for LOS-based physical discovery: the observer sees + // the POI directly, no intermediary grant source. This is a D-079 + // carve-out; NPC tips and research-based POI discovery (Sprint 18) + // will use the event queue path. + kg.facts.insert( + fact_id, + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::DirectObservation { tick: time.tick }, + state: KnowledgeState::Active, + acquired_tick: time.tick, + disclosure_blocked: false, + }, + ); + + discovery_queue.push(PoiDiscoveredEvent { + poi_id: poi.poi_id.clone(), + name: poi.name.clone(), + tick: time.tick, + }); + + tracing::info!( + poi_id = %poi.poi_id, + name = %poi.name, + tick = time.tick, + "Player discovered POI" + ); + } + } +} + +/// Evaluate whether an observer can discover a POI based on its visibility rules. +fn can_discover( + observer_pos: &TilePosition, + geometry: &VisibilityGeometry, + kg: &KnowledgeGraph, + poi: &PointOfInterest, +) -> bool { + match &poi.visibility { + PoiVisibility::LineOfSight => { + poi.position.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(poi.position.x, poi.position.y)) + } + PoiVisibility::Proximity { range } => observer_pos + .manhattan_distance(&poi.position) + .is_some_and(|d| d <= *range), + PoiVisibility::KnowledgeOnly => { + // Not discoverable by physical observation. + // Requires KnowledgeGranted event from dialogue/evidence. + false + } + PoiVisibility::RequiresFact { fact_id } => { + // Must know the prerequisite fact AND see the POI in LOS. + kg.knows_fact(&FactId(fact_id.clone())) + && poi.position.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(poi.position.x, poi.position.y)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::poi::{PoiCategory, PoiDiscoverySource}; + use std::collections::BTreeSet; + + fn make_poi( + id: &str, + position: TilePosition, + visibility: PoiVisibility, + ) -> PointOfInterest { + PointOfInterest { + poi_id: id.to_string(), + name: format!("Test {}", id), + position, + category: PoiCategory::Location, + discovery_source: PoiDiscoverySource::MapTemplate, + visibility, + } + } + + fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry { + VisibilityGeometry { + visible_tiles: vec![], + visible_positions: visible.iter().copied().collect::>(), + sector_lookup: Default::default(), + observer_z: z, + } + } + + // --- can_discover tests --- + + #[test] + fn los_poi_discovered_when_in_visible_positions() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(10, 5)], 0); + let kg = KnowledgeGraph::new(); + + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn los_poi_not_discovered_when_not_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn los_poi_not_discovered_on_different_z() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1 + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_discovered_within_range() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(7, 5, 0), + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // Manhattan distance = 2, range = 3 → discovered + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_not_discovered_outside_range() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(10, 5, 0), + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // Manhattan distance = 5, range = 3 → not discovered + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_not_discovered_different_z() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(5, 6, 1), // different z + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // manhattan_distance returns None for different z + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn knowledge_only_never_discovered_physically() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "secret", + TilePosition::new(5, 5, 0), // same tile + PoiVisibility::KnowledgeOnly, + ); + let geometry = make_geometry(&[(5, 5)], 0); + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_discovered_when_fact_known_and_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[(8, 5)], 0); + let kg = KnowledgeGraph::with_background(vec![( + FactId("quest.secret_hint".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_not_discovered_without_fact() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[(8, 5)], 0); + let kg = KnowledgeGraph::new(); // no facts + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_not_discovered_when_not_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[], 0); // not visible + let kg = KnowledgeGraph::with_background(vec![( + FactId("quest.secret_hint".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + // --- System integration test --- + + #[test] + fn discover_pois_system_grants_fact() { + use bevy_ecs::world::World; + + let mut world = World::new(); + + // Resources + let mut time = SimulationTime::default(); + time.tick = 50; + world.insert_resource(time); + world.insert_resource(make_geometry(&[(10, 5)], 0)); + world.insert_resource(PoiDiscoveryEventQueue::default()); + + // Player observer + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + + // POI entity + world.spawn(make_poi( + "docking_bay", + TilePosition::new(10, 5, 0), + PoiVisibility::LineOfSight, + )); + + // Run system + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(discover_pois); + schedule.run(&mut world); + + // Verify: player now knows the POI fact + let mut query = world.query_filtered::<&KnowledgeGraph, With>(); + let kg = query.single(&world).expect("player should exist"); + let fact_id = FactId("poi.docking_bay".to_string()); + assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay"); + assert_eq!( + kg.facts.get(&fact_id).unwrap().confidence, + KnowledgeConfidence::KnowsOf + ); + + // Verify: discovery event was emitted + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!(queue.events[0].poi_id, "docking_bay"); + assert_eq!(queue.events[0].tick, 50); + } + + #[test] + fn discover_pois_system_skips_already_known() { + use bevy_ecs::world::World; + + let mut world = World::new(); + + let mut time = SimulationTime::default(); + time.tick = 100; + world.insert_resource(time); + world.insert_resource(make_geometry(&[(10, 5)], 0)); + world.insert_resource(PoiDiscoveryEventQueue::default()); + + // Player already knows this POI + let kg = KnowledgeGraph::with_background(vec![( + FactId("poi.docking_bay".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + + world.spawn(make_poi( + "docking_bay", + TilePosition::new(10, 5, 0), + PoiVisibility::LineOfSight, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(discover_pois); + schedule.run(&mut world); + + // No new events — already known + let queue = world.resource::(); + assert!(queue.is_empty(), "No discovery event for already-known POI"); + } +} diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index cec058f99..bedfbd9bf 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -214,6 +214,7 @@ fn verify_template_role_slots() { fn spawn_npc_from_content_store() { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); // Create a minimal content store with one test NPC let mut store = ContentStore::default(); @@ -333,6 +334,7 @@ fn spawn_real_content_with_relationships_and_secrets() { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); let store = load_content(&root).expect("content loading should succeed"); diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index e3bdb38e7..48af4ebdb 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1364,6 +1364,229 @@ fn v10_payload_deserializes_into_v11_struct() { ); } +// --------------------------------------------------------------------------- +// #232: Protocol versioning scheme tests +// --------------------------------------------------------------------------- + +/// A snapshot serialized without newer optional fields (simulating an older server) +/// must deserialize with serde defaults — core migration pattern (#232). +/// +/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify +/// they fill in as their zero/None values on deserialization. +#[test] +fn serde_default_fields_fill_in_when_missing_from_wire() { + // JSON with only the required fields (simulating a minimal old snapshot). + // `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc. + // are all `#[serde(default)]` — they must default to None/empty when absent. + let minimal_json = serde_json::json!({ + "version": 13, + "tick": 42, + "game_time": { + "day": 0, + "time_of_day": 0, + "day_phase": "Morning", + "tick_rate": "Full" + }, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "entities": [{ + "entity_id": 1, + "x": 5.0, + "y": 5.0, + "z": 0, + "kind": "Npc", + "visibility": "Forward", + "relationship": "Unknown", + "observation": "Visible" + // "tell_state" intentionally absent + }], + "visible_tiles": [], + "nearby_interactions": [], + "current_monologue": null, + "pending_recognitions": [], + "dialogue_response": null, + "blocked_entities": [], + "scan_events": [], + "sound_events": [], + "conversation_events": [], + "conversation_ended": [], + "follow_state": null, + "rng_seed": null + }); + + let decoded: ObserverSnapshot = + serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); + + // Version and required fields present + assert_eq!(decoded.version, PROTOCOL_VERSION); + assert_eq!(decoded.tick, 42); + assert_eq!(decoded.entities.len(), 1); + + // `#[serde(default, skip_serializing_if = "Option::is_none")]` field + // defaults to None when absent from the wire + assert_eq!( + decoded.entities[0].tell_state, None, + "tell_state must default to None when absent from wire" + ); + assert_eq!( + decoded.rng_seed, None, + "rng_seed must default to None when absent from wire" + ); + assert!( + decoded.follow_state.is_none(), + "follow_state must default to None when absent from wire" + ); +} + +/// A snapshot with version != PROTOCOL_VERSION can be detected by checking +/// the version field after deserialization (#232 compatibility checking). +#[test] +fn snapshot_version_mismatch_is_detectable() { + let mut snapshot = test_snapshot(0, vec![]); + let future_version: u8 = PROTOCOL_VERSION + 1; + snapshot.version = future_version; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + // The version field faithfully preserves the value — caller detects mismatch + assert_eq!( + decoded.version, future_version, + "version field must survive round-trip unchanged" + ); + assert_ne!( + decoded.version, PROTOCOL_VERSION, + "client should detect this as a version mismatch" + ); +} + +/// tell_state=None is skipped in msgpack serialization (skip_serializing_if). +/// A snapshot with tell_state=None produces fewer bytes than one with +/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract. +#[test] +fn tell_state_none_is_omitted_from_wire() { + let entity_no_tell = VisibleEntity { + entity_id: 1, + x: 0.0, + y: 0.0, + z: 0, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }; + let entity_with_tell = VisibleEntity { + tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous), + ..entity_no_tell.clone() + }; + + let bytes_no_tell = + rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state"); + let bytes_with_tell = + rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state"); + + assert!( + bytes_no_tell.len() < bytes_with_tell.len(), + "tell_state=None should produce fewer bytes (skip_serializing_if contract)" + ); +} + +/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity. +/// Closing coverage gap for v13 tell_state field (#90, D-024). +#[test] +fn all_tell_category_variants_roundtrip() { + use settled_reach_server::npc::tell_state::TellCategory; + + let categories = [ + TellCategory::Nervous, + TellCategory::Angry, + TellCategory::Friendly, + TellCategory::Guarded, + TellCategory::RoutineDeviation, + ]; + + for category in categories { + let entity = VisibleEntity { + entity_id: 1, + x: 3.0, + y: 4.0, + z: 0, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: Some(category), + }; + let bytes = rmp_serde::to_vec_named(&entity).expect("serialize"); + let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!( + decoded.tell_state, + Some(category), + "TellCategory::{:?} did not survive round-trip", + category + ); + } +} + +/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232). +/// Closes a coverage gap — not all VerbKind variants were previously verified. +#[test] +fn all_verb_kind_variants_roundtrip_v232() { + let all_verbs = [ + VerbKind::ExamineNpc, + VerbKind::Talk, + VerbKind::Observe, + VerbKind::Read, + VerbKind::Open, + VerbKind::Close, + VerbKind::Search, + VerbKind::Use, + VerbKind::Take, + VerbKind::Sit, + VerbKind::Follow, + VerbKind::Confront, + VerbKind::ExamineObject, + ]; + + for kind in all_verbs { + let interaction = NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind, + label: "Test".into(), + priority: 1, + available: true, + }], + object_type: None, + contradicted: false, + }; + let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize"); + let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!( + decoded.verbs[0].kind, kind, + "VerbKind::{:?} did not survive round-trip", + kind + ); + } +} + +/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232). +/// This guards against accidental widening of the version type. +#[test] +fn protocol_version_fits_in_u8() { + // u8 max is 255 — enough for ~242 more protocol iterations. + // If PROTOCOL_VERSION ever reaches 200, consider migrating to u16. + assert!( + PROTOCOL_VERSION <= 200, + "PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type", + PROTOCOL_VERSION + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test]