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>
731 lines
28 KiB
Rust
731 lines
28 KiB
Rust
//! Knowledge event queue and processing system (#363).
|
|
//!
|
|
//! Event-driven knowledge updates. Perception and other systems push
|
|
//! KnowledgeEvents; the processing system drains them per tick.
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::simulation::movement::TilePosition;
|
|
|
|
use super::graph::KnowledgeGraph;
|
|
use super::registry::EntityRegistry;
|
|
use super::types::*;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Processed knowledge grant types (D-079)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Processed Fact grant — confidence string parsed to typed enum at creation time.
|
|
/// Used in `KnowledgeEventType::KnowledgeGranted`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProcessedFactGrant {
|
|
pub fact_id: FactId,
|
|
pub confidence: KnowledgeConfidence,
|
|
}
|
|
|
|
/// Processed Entity grant — entity_ref resolved to StableId at creation time.
|
|
/// Used in `KnowledgeEventType::KnowledgeGranted`.
|
|
///
|
|
/// Creates an `EntityKnowledge` entry in the observer's KG with `ToldBy` source,
|
|
/// enabling contradiction detection when a subsequent `DirectObservation` disagrees.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProcessedEntityGrant {
|
|
pub target_id: StableId,
|
|
pub attributes: BTreeMap<String, String>,
|
|
pub confidence: KnowledgeConfidence,
|
|
}
|
|
|
|
/// Typed knowledge grant payload — all string fields resolved at event creation.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ProcessedKnowledgeGrant {
|
|
Fact(ProcessedFactGrant),
|
|
Entity(ProcessedEntityGrant),
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Knowledge event types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Events that modify knowledge graphs. Produced by perception and
|
|
/// other systems. Consumed by the knowledge update system.
|
|
#[derive(Debug, Clone)]
|
|
pub struct KnowledgeEvent {
|
|
pub observer: Entity,
|
|
pub tick: u64,
|
|
pub event_type: KnowledgeEventType,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum KnowledgeEventType {
|
|
/// Observer saw entity at position (sets Direct confidence).
|
|
DirectObservation {
|
|
target: Entity,
|
|
position: TilePosition,
|
|
},
|
|
/// Entity left observer's LOS (downgrades from Direct).
|
|
LeftLOS { target: Entity },
|
|
/// Observer walked away from an active interaction (D-064).
|
|
/// Records incompleteness in the target's known_attributes for future
|
|
/// dialogue/monologue consequences.
|
|
IncompleteInteraction {
|
|
target: Entity,
|
|
interaction_type: InteractionType,
|
|
},
|
|
/// Knowledge granted to observer via dialogue line selection (D-079).
|
|
///
|
|
/// Fires at line selection time in `process_talk_interaction`.
|
|
/// Source is `ToldBy { source_id, tick }` for NPC testimony.
|
|
/// For Fact grants, the granting NPC's KG must contain the fact (guardrail enforced
|
|
/// at event creation time — event is only pushed if guardrail passes).
|
|
KnowledgeGranted {
|
|
grant: ProcessedKnowledgeGrant,
|
|
source: KnowledgeSource,
|
|
},
|
|
}
|
|
|
|
/// Type of interaction for walk-away recording (D-064).
|
|
/// Differentiates casual conversation from confrontation —
|
|
/// future dialogue may react differently.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum InteractionType {
|
|
/// Normal Talk conversation.
|
|
Talk,
|
|
/// Confrontation (D-063). Walking away from confrontation
|
|
/// carries heavier consequences than casual talk.
|
|
Confront,
|
|
}
|
|
|
|
/// Resource: queue of pending knowledge events.
|
|
/// Drained once per tick by the knowledge update system.
|
|
#[derive(Resource, Default)]
|
|
pub struct KnowledgeEventQueue {
|
|
pub(crate) events: Vec<KnowledgeEvent>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Contradiction detection output (D-083)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Event emitted when `observe_entity()` detects a position contradiction
|
|
/// between a `ToldBy` source and a `DirectObservation`.
|
|
///
|
|
/// Consumed by the monologue system (D-083 → monologue trigger) and
|
|
/// potentially the storyteller. One event per detected contradiction per tick.
|
|
///
|
|
/// Display names are pre-resolved by `process_knowledge_events` via EntityRegistry
|
|
/// and NpcName, so downstream consumers (monologue) are pure string consumers.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ContradictionDetectedEvent {
|
|
/// The observer who detected the contradiction.
|
|
pub observer: Entity,
|
|
/// The entity whose position was contradicted.
|
|
pub target: StableId,
|
|
/// Full contradiction details (who told what, where observed, when).
|
|
pub claim: ContradictionClaim,
|
|
/// Pre-resolved display name of the NPC who told the false position (told_by source).
|
|
pub source_display_name: String,
|
|
/// Pre-resolved display name of the entity whose position was contradicted (target).
|
|
pub subject_display_name: String,
|
|
}
|
|
|
|
/// Resource: queue of contradictions detected this tick.
|
|
///
|
|
/// Populated by `process_knowledge_events` when `observe_entity()` returns
|
|
/// a `ContradictionClaim`. Drained by downstream systems (monologue, storyteller).
|
|
#[derive(Resource, Default)]
|
|
pub struct ContradictionDetectedQueue {
|
|
events: Vec<ContradictionDetectedEvent>,
|
|
}
|
|
|
|
impl ContradictionDetectedQueue {
|
|
pub fn push(&mut self, event: ContradictionDetectedEvent) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
pub fn drain(&mut self) -> Vec<ContradictionDetectedEvent> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.events.is_empty()
|
|
}
|
|
}
|
|
|
|
impl KnowledgeEventQueue {
|
|
/// Push a knowledge event into the queue.
|
|
pub fn push(&mut self, event: KnowledgeEvent) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
/// Drain all pending events.
|
|
pub fn drain(&mut self) -> Vec<KnowledgeEvent> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
|
|
/// Number of pending events.
|
|
pub fn len(&self) -> usize {
|
|
self.events.len()
|
|
}
|
|
|
|
/// Whether the queue is empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.events.is_empty()
|
|
}
|
|
}
|
|
|
|
/// System: process pending knowledge events.
|
|
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
|
|
/// to the relevant KnowledgeGraph components.
|
|
///
|
|
/// On contradiction detection (D-083):
|
|
/// - Shifts the ToldBy source entity to PersonOfInterest in the observer's KG.
|
|
/// - Pre-resolves display names for downstream monologue consumer.
|
|
pub fn process_knowledge_events(
|
|
mut queue: ResMut<KnowledgeEventQueue>,
|
|
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
|
registry: Res<EntityRegistry>,
|
|
npc_names: Query<&crate::simulation::npc_components::NpcName>,
|
|
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
|
) {
|
|
let events = queue.drain();
|
|
for event in events {
|
|
let Ok(mut observer_kg) = knowledge_query.get_mut(event.observer) else {
|
|
continue;
|
|
};
|
|
|
|
match event.event_type {
|
|
KnowledgeEventType::DirectObservation { target, position } => {
|
|
if let Some(stable_id) = registry.to_stable(target) {
|
|
if let Some(claim) = observer_kg.observe_entity(stable_id, position, event.tick)
|
|
{
|
|
// StableId is Copy — capture before moving claim into event.
|
|
let told_by = claim.told_by;
|
|
|
|
// Relationship shift (D-083): NPC who provided false info
|
|
// becomes PersonOfInterest in the observer's knowledge graph.
|
|
// Upsert: create a minimal entry if the source isn't yet known.
|
|
observer_kg
|
|
.entities
|
|
.entry(told_by)
|
|
.and_modify(|e| e.relationship = RelationshipState::PersonOfInterest)
|
|
.or_insert_with(|| EntityKnowledge {
|
|
last_known_position: None,
|
|
last_observed_tick: 0,
|
|
last_updated_tick: event.tick,
|
|
confidence: KnowledgeConfidence::Suspects,
|
|
source: KnowledgeSource::Inferred { basis: vec![] },
|
|
state: KnowledgeState::Active,
|
|
relationship: RelationshipState::PersonOfInterest,
|
|
known_attributes: BTreeMap::new(),
|
|
contradicted_claim: None,
|
|
});
|
|
|
|
// Pre-resolve display names for the monologue consumer.
|
|
let source_entity = registry.to_entity(&told_by);
|
|
let source_display_name = source_entity
|
|
.and_then(|e| npc_names.get(e).ok())
|
|
.map(|n| n.0.clone())
|
|
.unwrap_or_else(|| format!("#{}", told_by.0));
|
|
let subject_display_name = npc_names
|
|
.get(target)
|
|
.ok()
|
|
.map(|n| n.0.clone())
|
|
.unwrap_or_else(|| format!("#{}", stable_id.0));
|
|
|
|
tracing::info!(
|
|
observer = ?event.observer,
|
|
target = stable_id.0,
|
|
told_by = told_by.0,
|
|
source = source_display_name,
|
|
subject = subject_display_name,
|
|
"Contradiction detected: ToldBy position differs from direct observation (D-083)"
|
|
);
|
|
contradiction_queue.push(ContradictionDetectedEvent {
|
|
observer: event.observer,
|
|
target: stable_id,
|
|
claim,
|
|
source_display_name,
|
|
subject_display_name,
|
|
});
|
|
}
|
|
} else {
|
|
debug_assert!(
|
|
false,
|
|
"DirectObservation target {:?} not in EntityRegistry",
|
|
target
|
|
);
|
|
tracing::error!(
|
|
"DirectObservation target {:?} not in EntityRegistry",
|
|
target
|
|
);
|
|
}
|
|
}
|
|
KnowledgeEventType::LeftLOS { target } => {
|
|
if let Some(stable_id) = registry.to_stable(target) {
|
|
observer_kg.observe_entity_leaving_los(&stable_id, event.tick);
|
|
} else {
|
|
debug_assert!(false, "LeftLOS target {:?} not in EntityRegistry", target);
|
|
tracing::error!("LeftLOS target {:?} not in EntityRegistry", target);
|
|
}
|
|
}
|
|
KnowledgeEventType::IncompleteInteraction {
|
|
target,
|
|
interaction_type,
|
|
} => {
|
|
if let Some(stable_id) = registry.to_stable(target) {
|
|
observer_kg.record_incomplete_interaction(
|
|
&stable_id,
|
|
interaction_type,
|
|
event.tick,
|
|
);
|
|
tracing::debug!(
|
|
"Recorded incomplete {:?} interaction with {:?} at tick {}",
|
|
interaction_type,
|
|
stable_id,
|
|
event.tick,
|
|
);
|
|
}
|
|
}
|
|
KnowledgeEventType::KnowledgeGranted { grant, source } => {
|
|
match grant {
|
|
ProcessedKnowledgeGrant::Fact(fg) => {
|
|
let should_insert = observer_kg
|
|
.facts
|
|
.get(&fg.fact_id)
|
|
.map(|existing| fg.confidence > existing.confidence)
|
|
.unwrap_or(true);
|
|
if should_insert {
|
|
observer_kg.facts.insert(
|
|
fg.fact_id.clone(),
|
|
FactKnowledge {
|
|
confidence: fg.confidence,
|
|
source,
|
|
state: KnowledgeState::Active,
|
|
acquired_tick: event.tick,
|
|
disclosure_blocked: false,
|
|
},
|
|
);
|
|
tracing::debug!(
|
|
"KnowledgeGranted(Fact): {:?} at confidence {:?}, tick {}",
|
|
fg.fact_id,
|
|
fg.confidence,
|
|
event.tick,
|
|
);
|
|
}
|
|
}
|
|
ProcessedKnowledgeGrant::Entity(eg) => {
|
|
// Insert or upgrade entity knowledge entry.
|
|
// Always use ToldBy source — entity grants come from NPC testimony.
|
|
let entry = observer_kg.entities.entry(eg.target_id).or_insert_with(|| {
|
|
EntityKnowledge {
|
|
last_known_position: None,
|
|
last_observed_tick: 0,
|
|
last_updated_tick: event.tick,
|
|
confidence: eg.confidence,
|
|
source: source.clone(),
|
|
state: KnowledgeState::Active,
|
|
relationship: RelationshipState::Unknown,
|
|
known_attributes: BTreeMap::new(),
|
|
contradicted_claim: None,
|
|
}
|
|
});
|
|
// Upgrade confidence and source if new grant is higher.
|
|
if eg.confidence > entry.confidence {
|
|
entry.confidence = eg.confidence;
|
|
entry.source = source;
|
|
entry.last_updated_tick = event.tick;
|
|
}
|
|
// Merge attributes (grant may supply partial attribute set).
|
|
for (k, v) in eg.attributes {
|
|
entry.known_attributes.insert(k, v);
|
|
}
|
|
tracing::debug!(
|
|
"KnowledgeGranted(Entity): StableId {:?} at confidence {:?}, tick {}",
|
|
eg.target_id,
|
|
eg.confidence,
|
|
event.tick,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// System: run knowledge decay once per game-minute (every 10 ticks per D-031).
|
|
pub fn decay_knowledge(
|
|
time: Res<crate::simulation::time::SimulationTime>,
|
|
thresholds: Res<DecayThresholds>,
|
|
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
|
) {
|
|
// Decay runs every 10 ticks (1 game-minute per D-031)
|
|
if !time.tick.is_multiple_of(10) {
|
|
return;
|
|
}
|
|
for mut kg in knowledge_query.iter_mut() {
|
|
kg.decay(time.tick, &thresholds);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use bevy_ecs::world::World;
|
|
|
|
#[test]
|
|
fn queue_push_and_drain() {
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
assert!(queue.is_empty());
|
|
|
|
let mut world = World::new();
|
|
let e1 = world.spawn_empty().id();
|
|
let e2 = world.spawn_empty().id();
|
|
|
|
queue.push(KnowledgeEvent {
|
|
observer: e1,
|
|
tick: 100,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target: e2,
|
|
position: TilePosition::new(5, 5, 0),
|
|
},
|
|
});
|
|
assert_eq!(queue.len(), 1);
|
|
|
|
let events = queue.drain();
|
|
assert_eq!(events.len(), 1);
|
|
assert!(queue.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn process_direct_observation_event() {
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let observer = world.spawn(KnowledgeGraph::new()).id();
|
|
let target = world.spawn_empty().id();
|
|
|
|
let observer_sid = registry.register(observer);
|
|
let target_sid = registry.register(target);
|
|
let _ = observer_sid; // registered for completeness
|
|
|
|
world.insert_resource(registry);
|
|
world.insert_resource(ContradictionDetectedQueue::default());
|
|
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
queue.push(KnowledgeEvent {
|
|
observer,
|
|
tick: 50,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target,
|
|
position: TilePosition::new(10, 10, 0),
|
|
},
|
|
});
|
|
world.insert_resource(queue);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_knowledge_events);
|
|
schedule.run(&mut world);
|
|
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
assert!(kg.knows_entity(&target_sid));
|
|
assert_eq!(
|
|
kg.confidence_of(&target_sid),
|
|
Some(KnowledgeConfidence::Direct)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_left_los_event() {
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let target = world.spawn_empty().id();
|
|
let target_sid = registry.register(target);
|
|
|
|
// Pre-populate observer with Direct knowledge
|
|
let mut kg = KnowledgeGraph::new();
|
|
kg.observe_entity(target_sid, TilePosition::new(10, 10, 0), 50);
|
|
|
|
let observer = world.spawn(kg).id();
|
|
registry.register(observer);
|
|
|
|
world.insert_resource(registry);
|
|
world.insert_resource(ContradictionDetectedQueue::default());
|
|
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
queue.push(KnowledgeEvent {
|
|
observer,
|
|
tick: 60,
|
|
event_type: KnowledgeEventType::LeftLOS { target },
|
|
});
|
|
world.insert_resource(queue);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_knowledge_events);
|
|
schedule.run(&mut world);
|
|
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
assert_eq!(
|
|
kg.confidence_of(&target_sid),
|
|
Some(KnowledgeConfidence::KnowsDetails)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_event_for_missing_observer_is_no_op() {
|
|
let mut world = World::new();
|
|
let registry = EntityRegistry::new(0);
|
|
world.insert_resource(registry);
|
|
world.insert_resource(ContradictionDetectedQueue::default());
|
|
|
|
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
|
|
let fake_target = world.spawn_empty().id();
|
|
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
queue.push(KnowledgeEvent {
|
|
observer: fake_observer,
|
|
tick: 100,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target: fake_target,
|
|
position: TilePosition::new(5, 5, 0),
|
|
},
|
|
});
|
|
world.insert_resource(queue);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_knowledge_events);
|
|
schedule.run(&mut world); // Should not panic
|
|
}
|
|
|
|
#[test]
|
|
fn decay_skips_non_minute_ticks() {
|
|
use crate::simulation::time::SimulationTime;
|
|
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let target_entity = world.spawn_empty().id();
|
|
let target_sid = registry.register(target_entity);
|
|
|
|
let mut kg = KnowledgeGraph::new();
|
|
kg.observe_entity(target_sid, TilePosition::new(5, 5, 0), 0);
|
|
kg.observe_entity_leaving_los(&target_sid, 1); // KnowsDetails
|
|
|
|
let observer = world.spawn(kg).id();
|
|
registry.register(observer);
|
|
world.insert_resource(registry);
|
|
|
|
let thresholds = DecayThresholds {
|
|
decay_after: 5,
|
|
stale_after: 100,
|
|
};
|
|
world.insert_resource(thresholds);
|
|
|
|
// Tick 7: not a multiple of 10, decay should NOT run
|
|
world.insert_resource({
|
|
let mut t = SimulationTime::default();
|
|
t.tick = 7;
|
|
t
|
|
});
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(decay_knowledge);
|
|
schedule.run(&mut world);
|
|
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
assert_eq!(
|
|
kg.confidence_of(&target_sid),
|
|
Some(KnowledgeConfidence::KnowsDetails),
|
|
"decay should not run on tick 7 (not a game-minute boundary)"
|
|
);
|
|
|
|
// Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5)
|
|
world.insert_resource({
|
|
let mut t = SimulationTime::default();
|
|
t.tick = 10;
|
|
t
|
|
});
|
|
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
|
schedule2.add_systems(decay_knowledge);
|
|
schedule2.run(&mut world);
|
|
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
assert_eq!(
|
|
kg.confidence_of(&target_sid),
|
|
Some(KnowledgeConfidence::KnowsOf),
|
|
"decay should run on tick 10 and downgrade confidence"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_direct_observation_detects_contradiction() {
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let target_ecs = world.spawn_empty().id();
|
|
let target_sid = registry.register(target_ecs);
|
|
let informant_sid = StableId(999);
|
|
|
|
// Observer has ToldBy knowledge: target at (10, 10) at tick 100
|
|
let mut kg = KnowledgeGraph::new();
|
|
kg.entities.insert(
|
|
target_sid,
|
|
EntityKnowledge {
|
|
last_known_position: Some(TilePosition::new(10, 10, 0)),
|
|
last_observed_tick: 0,
|
|
last_updated_tick: 100,
|
|
confidence: KnowledgeConfidence::KnowsOf,
|
|
source: KnowledgeSource::ToldBy {
|
|
source_id: informant_sid,
|
|
tick: 100,
|
|
},
|
|
state: KnowledgeState::Active,
|
|
relationship: RelationshipState::Known,
|
|
known_attributes: BTreeMap::new(),
|
|
contradicted_claim: None,
|
|
},
|
|
);
|
|
|
|
let observer = world.spawn(kg).id();
|
|
registry.register(observer);
|
|
world.insert_resource(registry);
|
|
world.insert_resource(ContradictionDetectedQueue::default());
|
|
|
|
// Push DirectObservation at DIFFERENT position, within window
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
queue.push(KnowledgeEvent {
|
|
observer,
|
|
tick: 200,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target: target_ecs,
|
|
position: TilePosition::new(15, 10, 0),
|
|
},
|
|
});
|
|
world.insert_resource(queue);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_knowledge_events);
|
|
schedule.run(&mut world);
|
|
|
|
// Verify: KG entry is Contradicted
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
let entry = kg.entity_knowledge(&target_sid).unwrap();
|
|
assert_eq!(entry.state, KnowledgeState::Contradicted);
|
|
assert!(entry.contradicted_claim.is_some());
|
|
let claim = entry.contradicted_claim.as_ref().unwrap();
|
|
assert_eq!(claim.told_by, informant_sid);
|
|
assert_eq!(claim.claimed_position, TilePosition::new(10, 10, 0));
|
|
assert_eq!(claim.observed_position, TilePosition::new(15, 10, 0));
|
|
|
|
// Verify: ContradictionDetectedQueue has the event
|
|
let cq = world.resource::<ContradictionDetectedQueue>();
|
|
assert_eq!(cq.events.len(), 1);
|
|
assert_eq!(cq.events[0].target, target_sid);
|
|
assert_eq!(cq.events[0].claim.told_by, informant_sid);
|
|
}
|
|
|
|
#[test]
|
|
fn no_contradiction_event_when_position_matches() {
|
|
// DirectObservation at the SAME position as ToldBy:
|
|
// ContradictionDetectedQueue must stay empty.
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let target_ecs = world.spawn_empty().id();
|
|
let target_sid = registry.register(target_ecs);
|
|
let informant_sid = StableId(77);
|
|
|
|
let mut kg = KnowledgeGraph::new();
|
|
kg.entities.insert(
|
|
target_sid,
|
|
EntityKnowledge {
|
|
last_known_position: Some(TilePosition::new(10, 10, 0)),
|
|
last_observed_tick: 0,
|
|
last_updated_tick: 100,
|
|
confidence: KnowledgeConfidence::KnowsOf,
|
|
source: KnowledgeSource::ToldBy {
|
|
source_id: informant_sid,
|
|
tick: 100,
|
|
},
|
|
state: KnowledgeState::Active,
|
|
relationship: RelationshipState::Known,
|
|
known_attributes: BTreeMap::new(),
|
|
contradicted_claim: None,
|
|
},
|
|
);
|
|
|
|
let observer = world.spawn(kg).id();
|
|
registry.register(observer);
|
|
world.insert_resource(registry);
|
|
world.insert_resource(ContradictionDetectedQueue::default());
|
|
|
|
// DirectObservation at the SAME position
|
|
let mut queue = KnowledgeEventQueue::default();
|
|
queue.push(KnowledgeEvent {
|
|
observer,
|
|
tick: 200,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target: target_ecs,
|
|
position: TilePosition::new(10, 10, 0), // same position
|
|
},
|
|
});
|
|
world.insert_resource(queue);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_knowledge_events);
|
|
schedule.run(&mut world);
|
|
|
|
let cq = world.resource::<ContradictionDetectedQueue>();
|
|
assert!(
|
|
cq.is_empty(),
|
|
"Matching position should not produce a contradiction event"
|
|
);
|
|
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
|
let entry = kg.entity_knowledge(&target_sid).unwrap();
|
|
assert_eq!(entry.state, KnowledgeState::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn contradiction_detected_queue_drains_correctly() {
|
|
// ContradictionDetectedQueue.drain() should empty the queue
|
|
// and return all accumulated events.
|
|
let mut queue = ContradictionDetectedQueue::default();
|
|
assert!(queue.is_empty());
|
|
|
|
let mut world = World::new();
|
|
let e = world.spawn_empty().id();
|
|
|
|
queue.push(ContradictionDetectedEvent {
|
|
observer: e,
|
|
target: StableId(1),
|
|
claim: ContradictionClaim {
|
|
told_by: StableId(99),
|
|
told_tick: 50,
|
|
claimed_position: TilePosition::new(1, 1, 0),
|
|
observed_position: TilePosition::new(5, 5, 0),
|
|
detected_tick: 100,
|
|
},
|
|
source_display_name: "Sera".to_string(),
|
|
subject_display_name: "Kael".to_string(),
|
|
});
|
|
queue.push(ContradictionDetectedEvent {
|
|
observer: e,
|
|
target: StableId(2),
|
|
claim: ContradictionClaim {
|
|
told_by: StableId(88),
|
|
told_tick: 60,
|
|
claimed_position: TilePosition::new(2, 2, 0),
|
|
observed_position: TilePosition::new(6, 6, 0),
|
|
detected_tick: 100,
|
|
},
|
|
source_display_name: "NPC_88".to_string(),
|
|
subject_display_name: "NPC_2".to_string(),
|
|
});
|
|
|
|
assert!(!queue.is_empty());
|
|
let drained = queue.drain();
|
|
assert_eq!(drained.len(), 2);
|
|
assert!(queue.is_empty(), "Queue should be empty after drain");
|
|
}
|
|
}
|