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>
858 lines
29 KiB
Rust
858 lines
29 KiB
Rust
//! 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 (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<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 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<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"
|
||
);
|
||
}
|
||
}
|