//! 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::cmp::Reverse; 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::movement::{PlayerCharacter, TilePosition}; use crate::simulation::npc_components::NpcConversation; use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // 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_key(|c| Reverse(c.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::movement::TilePosition; use crate::simulation::npc_components::NpcConversation; use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; 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" ); } }