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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:15:02 +01:00
co-authored by Claude Opus 4.6
parent 3d636fd4bb
commit 7487eb9dbb
2 changed files with 854 additions and 0 deletions
+17
View File
@@ -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::<spatial::NaiveSpatialIndex>()
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
// 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::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.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),
),
);
@@ -0,0 +1,837 @@
//! 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::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<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 ---
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(|a, b| b.sort_key().cmp(&a.sort_key()));
// Draw 13 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<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::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::<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"
);
}
}