Files
settled-reach/server/src/simulation/npc_knowledge_transfer.rs
T
jpmschweitzerandClaude Sonnet 4.6 e86e53ec06 feat(engine): retire D-078 overheard conversation system (#848)
Per R-012: delete conversation.rs, both overheard content files, and
remove all 6 wire-up points (social_plugin, bridge/types, monologue,
voice/integration). Protocol version 22 → 23. Scope confirmed by
#842 audit — npc/ and content/global/ untouched. Surviving NPC
components (NpcName, NpcColorIndex, NpcConversation) migrated to
simulation/npc_components.rs for use by D-080 knowledge propagation.
Also applies pre-existing cargo fmt debt (names.rs and 4 others).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:08:26 +02:00

858 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! NPC-to-NPC knowledge transfer system (D-080, ticket #548).
//!
//! When an NPC-to-NPC conversation starts (`Added<NpcConversation>`), 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<NpcConversation>`).
/// Reads the trust level between the two NPCs from `RelationshipGraph` and
/// transfers a sample (13 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<SimulationTime>,
mut rng: ResMut<SimRng>,
relationship_graph: Res<RelationshipGraph>,
mut event_queue: ResMut<KnowledgeEventQueue>,
// NPCs that just started a conversation — Added fires once per conversation.
new_conv_query: Query<
(
Entity,
&NpcConversation,
&TilePosition,
Option<&StableEntityId>,
),
(With<Npc>, With<ActiveSim>, Added<NpcConversation>),
>,
// Read-only StableEntityId on NPC partner (distinct query, no KG conflict).
partner_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
// 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<PlayerCharacter>>,
) {
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<TransferCandidate> = 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 13 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<PlayerCharacter>>,
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::<SimulationTime>();
app.insert_resource(SimRng::new(42));
app.init_resource::<EntityRegistry>();
app.init_resource::<RelationshipGraph>();
app.init_resource::<KnowledgeEventQueue>();
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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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::<RelationshipGraph>();
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::<KnowledgeGraph>()
.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"
);
}
}