From 7487eb9dbb0b64931e7e401cc9c63196d17ede6f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 12:15:02 +0100 Subject: [PATCH] feat(simulation): NPC-to-NPC knowledge transfer and POI systems (#548, #148, #149) transfer_npc_knowledge system runs after conversations with trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction. POI data model with PointOfInterest component and proximity-based discovery via KnowledgeGranted events. Implements D-080. Closes Q-024. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/mod.rs | 17 + .../src/simulation/npc_knowledge_transfer.rs | 837 ++++++++++++++++++ 2 files changed, 854 insertions(+) create mode 100644 server/src/simulation/npc_knowledge_transfer.rs 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/npc_knowledge_transfer.rs b/server/src/simulation/npc_knowledge_transfer.rs new file mode 100644 index 000000000..2cab327d2 --- /dev/null +++ b/server/src/simulation/npc_knowledge_transfer.rs @@ -0,0 +1,837 @@ +//! 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 --- + + 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())); + + // Draw 1–3 entries + 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" + ); + } +}