diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index 24c795acd..728b2f9a1 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -177,6 +177,12 @@ impl KnowledgeGraph { } // 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 } diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 281e92ee0..711eb8f8c 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -37,6 +37,10 @@ impl Plugin for KnowledgePlugin { .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/npc/disclosure.rs b/server/src/npc/disclosure.rs index c1a749def..d68ae79bc 100644 --- a/server/src/npc/disclosure.rs +++ b/server/src/npc/disclosure.rs @@ -293,6 +293,9 @@ pub fn process_unprompted_disclosure( 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. @@ -404,6 +407,10 @@ pub fn process_unprompted_disclosure( } // 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; diff --git a/server/src/npc/trait_modifiers.rs b/server/src/npc/trait_modifiers.rs index 574bad7d0..677c78174 100644 --- a/server/src/npc/trait_modifiers.rs +++ b/server/src/npc/trait_modifiers.rs @@ -189,17 +189,12 @@ impl TraitModifierConfig { // --------------------------------------------------------------------------- /// 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 { - match s { - "suspects" => Some(KnowledgeConfidence::Suspects), - "knows_of" => Some(KnowledgeConfidence::KnowsOf), - "knows_details" => Some(KnowledgeConfidence::KnowsDetails), - "direct" => Some(KnowledgeConfidence::Direct), - _ => { - tracing::warn!("Unknown confidence level in trait config: {:?}", s); - None - } - } + 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. diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 2f87facd3..0f802f54d 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -674,6 +674,11 @@ fn emit_knowledge_grant( }, }); } + // 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, diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 966950882..5b0e1ef23 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -457,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()) diff --git a/server/src/simulation/npc_knowledge_transfer.rs b/server/src/simulation/npc_knowledge_transfer.rs index 2cab327d2..311593acb 100644 --- a/server/src/simulation/npc_knowledge_transfer.rs +++ b/server/src/simulation/npc_knowledge_transfer.rs @@ -197,6 +197,10 @@ pub fn transfer_npc_knowledge( } // --- 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]) @@ -265,7 +269,8 @@ pub fn transfer_npc_knowledge( // 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())); - // Draw 1–3 entries + // 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()); diff --git a/server/src/simulation/poi_discovery.rs b/server/src/simulation/poi_discovery.rs index f784c4772..8056d7228 100644 --- a/server/src/simulation/poi_discovery.rs +++ b/server/src/simulation/poi_discovery.rs @@ -81,6 +81,11 @@ pub fn discover_pois( } 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 {