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:
@@ -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 (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 ---
|
||||
|
||||
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 1–3 entries
|
||||
let count = rng.rng.random_range(1u32..=3u32) as usize;
|
||||
let count = count.min(candidates.len());
|
||||
|
||||
tracing::debug!(
|
||||
"NPC transfer: {:?} → {:?}, tier={:?}, trust={}, drawing {}/{}",
|
||||
speaker_entity,
|
||||
partner_entity,
|
||||
tier,
|
||||
trust,
|
||||
count,
|
||||
candidates.len(),
|
||||
);
|
||||
|
||||
// --- Apply transfers to partner's KG ---
|
||||
|
||||
for candidate in candidates.into_iter().take(count) {
|
||||
match candidate {
|
||||
TransferCandidate::Fact { id, confidence, .. } => {
|
||||
// Confidence cap: speaker's knowledge degrades to at most KnowsOf.
|
||||
let capped = confidence.min(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Upgrade-only: never downgrade existing knowledge.
|
||||
let should_write = partner_kg
|
||||
.facts
|
||||
.get(&id)
|
||||
.map(|existing| existing.confidence < capped)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_write {
|
||||
partner_kg.facts.insert(
|
||||
id.clone(),
|
||||
FactKnowledge {
|
||||
confidence: capped,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: speaker_sid,
|
||||
tick,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
"NPC transfer: fact {:?} at {:?} → {:?}",
|
||||
id,
|
||||
capped,
|
||||
partner_entity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransferCandidate::Entity { id, ek } => {
|
||||
// Confidence cap: at most KnowsOf.
|
||||
let capped = ek.confidence.min(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Preserve existing relationship state if the partner already knows this entity.
|
||||
let (should_write, existing_relationship) =
|
||||
match partner_kg.entities.get(&id) {
|
||||
None => (true, RelationshipState::Unknown),
|
||||
Some(existing) => {
|
||||
(existing.confidence < capped, existing.relationship)
|
||||
}
|
||||
};
|
||||
|
||||
if should_write {
|
||||
partner_kg.entities.insert(
|
||||
id,
|
||||
EntityKnowledge {
|
||||
last_known_position: ek.last_known_position,
|
||||
last_observed_tick: ek.last_observed_tick,
|
||||
last_updated_tick: tick,
|
||||
confidence: capped,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: speaker_sid,
|
||||
tick,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: existing_relationship,
|
||||
known_attributes: ek.known_attributes.clone(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
"NPC transfer: entity {:?} at {:?} → {:?}",
|
||||
id,
|
||||
capped,
|
||||
partner_entity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Player overhear grants ---
|
||||
|
||||
emit_player_overhear_grants(
|
||||
&player_query,
|
||||
speaker_pos,
|
||||
tick,
|
||||
speaker_sid,
|
||||
partner_sid,
|
||||
&mut event_queue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `Heard` entity grants to all players within VOICE_RANGE_TILES of a conversation.
|
||||
///
|
||||
/// Called regardless of transfer tier — even if the NPCs aren't sharing information,
|
||||
/// the player can still learn that these two entities exist from overhearing them talk.
|
||||
fn emit_player_overhear_grants(
|
||||
player_query: &Query<(Entity, &TilePosition), With<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user