KnowledgeGrant untagged enum (Fact + Entity variants), ContentEntityRegistry resource, KnowledgeGranted event processing, ContradictionClaim struct with 600-tick window detection in observe_entity. Wires knowledge_grant field in dialogue line selection. Implements D-079, D-083. Closes Q-026. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,8 @@ use bevy_app::prelude::*;
|
|||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::knowledge::ContentEntityRegistry;
|
||||||
|
|
||||||
/// Configuration for the content loader.
|
/// Configuration for the content loader.
|
||||||
/// Set the content root path before adding ContentPlugin.
|
/// Set the content root path before adding ContentPlugin.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
@@ -52,6 +54,10 @@ impl Plugin for ContentPlugin {
|
|||||||
app.insert_resource(ContentConfig::default());
|
app.insert_resource(ContentConfig::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContentEntityRegistry is required by spawn_npc (D-079).
|
||||||
|
// Init here so ContentPlugin works standalone without KnowledgePlugin.
|
||||||
|
app.init_resource::<ContentEntityRegistry>();
|
||||||
|
|
||||||
app.add_systems(Startup, load_and_spawn_content);
|
app.add_systems(Startup, load_and_spawn_content);
|
||||||
app.add_systems(PostUpdate, hot_reload::hot_reload_content);
|
app.add_systems(PostUpdate, hot_reload::hot_reload_content);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use crate::content::loader::{ContentStore, DistrictContent};
|
use crate::content::loader::{ContentStore, DistrictContent};
|
||||||
use crate::content::types;
|
use crate::content::types;
|
||||||
|
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||||
use crate::knowledge::graph::KnowledgeGraph;
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||||
use crate::knowledge::types::{
|
use crate::knowledge::types::{
|
||||||
@@ -213,6 +214,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
|||||||
ContentSlug(profile.canonical_id.clone()),
|
ContentSlug(profile.canonical_id.clone()),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Register in ContentEntityRegistry so KnowledgeGrant::Entity can resolve entity_ref strings
|
||||||
|
// (D-079: ContentEntityRegistry populated at NPC spawn time)
|
||||||
|
world
|
||||||
|
.resource_mut::<ContentEntityRegistry>()
|
||||||
|
.register(profile.canonical_id.clone(), stable_id);
|
||||||
|
|
||||||
result
|
result
|
||||||
.npc_ids
|
.npc_ids
|
||||||
.insert(profile.canonical_id.clone(), stable_id);
|
.insert(profile.canonical_id.clone(), stable_id);
|
||||||
@@ -296,6 +303,7 @@ fn resolve_information(
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -648,6 +656,7 @@ mod tests {
|
|||||||
fn create_test_world() -> World {
|
fn create_test_world() -> World {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world.init_resource::<ContentEntityRegistry>();
|
||||||
world
|
world
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -491,10 +491,30 @@ pub struct DialogueLine {
|
|||||||
pub knowledge_grant: Option<KnowledgeGrant>,
|
pub knowledge_grant: Option<KnowledgeGrant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Knowledge grant attached to a dialogue line (D-079).
|
||||||
|
///
|
||||||
|
/// Untagged enum — serde tries each variant in order:
|
||||||
|
/// `Fact` matches YAML with `fact_id` field.
|
||||||
|
/// `Entity` matches YAML with `entity_ref` field.
|
||||||
|
/// `Compound` variant deferred to Sprint 18.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct KnowledgeGrant {
|
#[serde(untagged)]
|
||||||
pub fact_id: String,
|
pub enum KnowledgeGrant {
|
||||||
pub confidence: String,
|
/// Grant knowledge of a non-entity fact.
|
||||||
|
/// Format: fact_id "category.topic", confidence string.
|
||||||
|
Fact {
|
||||||
|
fact_id: String,
|
||||||
|
confidence: String,
|
||||||
|
},
|
||||||
|
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
|
||||||
|
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
|
||||||
|
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
|
||||||
|
Entity {
|
||||||
|
entity_ref: String,
|
||||||
|
#[serde(default)]
|
||||||
|
attributes: BTreeMap<String, String>,
|
||||||
|
confidence: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! ContentEntityRegistry resource (D-079).
|
||||||
|
//!
|
||||||
|
//! Maps NPC canonical_id strings (e.g., "kael-davan") to their runtime StableIds.
|
||||||
|
//! Populated at NPC spawn time; queried at KnowledgeGrant processing time to
|
||||||
|
//! resolve `entity_ref` strings in `KnowledgeGrant::Entity` variants.
|
||||||
|
//!
|
||||||
|
//! BTreeMap for deterministic iteration (D-010 principle 4).
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::types::StableId;
|
||||||
|
|
||||||
|
/// Content ID → StableId registry.
|
||||||
|
///
|
||||||
|
/// Populated by `spawn_npc` for every authored NPC. Read by the
|
||||||
|
/// `KnowledgeGranted` event handler to resolve `entity_ref` strings at
|
||||||
|
/// grant processing time (D-079).
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ContentEntityRegistry {
|
||||||
|
entries: BTreeMap<String, StableId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentEntityRegistry {
|
||||||
|
/// Register a content_id → StableId mapping.
|
||||||
|
///
|
||||||
|
/// Idempotent for the same (content_id, stable_id) pair.
|
||||||
|
/// If the same content_id is registered twice with different StableIds,
|
||||||
|
/// the latest call wins (last-write semantics; warn in caller if this is unexpected).
|
||||||
|
pub fn register(&mut self, content_id: impl Into<String>, stable_id: StableId) {
|
||||||
|
self.entries.insert(content_id.into(), stable_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a content_id string to a StableId.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the entity_ref is not registered. Callers should
|
||||||
|
/// emit `tracing::warn!` and drop the grant when `None` is returned.
|
||||||
|
pub fn resolve(&self, entity_ref: &str) -> Option<StableId> {
|
||||||
|
self.entries.get(entity_ref).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of registered entries.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the registry is empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_and_resolve() {
|
||||||
|
let mut registry = ContentEntityRegistry::default();
|
||||||
|
let sid = StableId(42);
|
||||||
|
registry.register("kael-davan", sid);
|
||||||
|
|
||||||
|
assert_eq!(registry.resolve("kael-davan"), Some(sid));
|
||||||
|
assert_eq!(registry.resolve("unknown-npc"), None);
|
||||||
|
assert_eq!(registry.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_overwrites() {
|
||||||
|
let mut registry = ContentEntityRegistry::default();
|
||||||
|
let sid_a = StableId(1);
|
||||||
|
let sid_b = StableId(2);
|
||||||
|
registry.register("npc-x", sid_a);
|
||||||
|
registry.register("npc-x", sid_b);
|
||||||
|
|
||||||
|
assert_eq!(registry.resolve("npc-x"), Some(sid_b));
|
||||||
|
assert_eq!(registry.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_registry() {
|
||||||
|
let registry = ContentEntityRegistry::default();
|
||||||
|
assert!(registry.is_empty());
|
||||||
|
assert_eq!(registry.resolve("any"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! KnowledgeEvents; the processing system drains them per tick.
|
//! KnowledgeEvents; the processing system drains them per tick.
|
||||||
|
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::simulation::movement::TilePosition;
|
use crate::simulation::movement::TilePosition;
|
||||||
|
|
||||||
@@ -11,6 +12,41 @@ use super::graph::KnowledgeGraph;
|
|||||||
use super::registry::EntityRegistry;
|
use super::registry::EntityRegistry;
|
||||||
use super::types::*;
|
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
|
/// Events that modify knowledge graphs. Produced by perception and
|
||||||
/// other systems. Consumed by the knowledge update system.
|
/// other systems. Consumed by the knowledge update system.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -36,6 +72,16 @@ pub enum KnowledgeEventType {
|
|||||||
target: Entity,
|
target: Entity,
|
||||||
interaction_type: InteractionType,
|
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).
|
/// Type of interaction for walk-away recording (D-064).
|
||||||
@@ -57,6 +103,55 @@ pub struct KnowledgeEventQueue {
|
|||||||
pub(crate) events: Vec<KnowledgeEvent>,
|
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 {
|
impl KnowledgeEventQueue {
|
||||||
/// Push a knowledge event into the queue.
|
/// Push a knowledge event into the queue.
|
||||||
pub fn push(&mut self, event: KnowledgeEvent) {
|
pub fn push(&mut self, event: KnowledgeEvent) {
|
||||||
@@ -82,9 +177,15 @@ impl KnowledgeEventQueue {
|
|||||||
/// System: process pending knowledge events.
|
/// System: process pending knowledge events.
|
||||||
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
|
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
|
||||||
/// to the relevant KnowledgeGraph components.
|
/// 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(
|
pub fn process_knowledge_events(
|
||||||
mut queue: ResMut<KnowledgeEventQueue>,
|
mut queue: ResMut<KnowledgeEventQueue>,
|
||||||
|
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
||||||
registry: Res<EntityRegistry>,
|
registry: Res<EntityRegistry>,
|
||||||
|
npc_names: Query<&crate::simulation::conversation::NpcName>,
|
||||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||||
) {
|
) {
|
||||||
let events = queue.drain();
|
let events = queue.drain();
|
||||||
@@ -96,7 +197,59 @@ pub fn process_knowledge_events(
|
|||||||
match event.event_type {
|
match event.event_type {
|
||||||
KnowledgeEventType::DirectObservation { target, position } => {
|
KnowledgeEventType::DirectObservation { target, position } => {
|
||||||
if let Some(stable_id) = registry.to_stable(target) {
|
if let Some(stable_id) = registry.to_stable(target) {
|
||||||
observer_kg.observe_entity(stable_id, position, event.tick);
|
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 {
|
} else {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
false,
|
false,
|
||||||
@@ -135,6 +288,68 @@ pub fn process_knowledge_events(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +411,7 @@ mod tests {
|
|||||||
let _ = observer_sid; // registered for completeness
|
let _ = observer_sid; // registered for completeness
|
||||||
|
|
||||||
world.insert_resource(registry);
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(ContradictionDetectedQueue::default());
|
||||||
|
|
||||||
let mut queue = KnowledgeEventQueue::default();
|
let mut queue = KnowledgeEventQueue::default();
|
||||||
queue.push(KnowledgeEvent {
|
queue.push(KnowledgeEvent {
|
||||||
@@ -236,6 +452,7 @@ mod tests {
|
|||||||
registry.register(observer);
|
registry.register(observer);
|
||||||
|
|
||||||
world.insert_resource(registry);
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(ContradictionDetectedQueue::default());
|
||||||
|
|
||||||
let mut queue = KnowledgeEventQueue::default();
|
let mut queue = KnowledgeEventQueue::default();
|
||||||
queue.push(KnowledgeEvent {
|
queue.push(KnowledgeEvent {
|
||||||
@@ -261,6 +478,7 @@ mod tests {
|
|||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
let registry = EntityRegistry::new(0);
|
let registry = EntityRegistry::new(0);
|
||||||
world.insert_resource(registry);
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(ContradictionDetectedQueue::default());
|
||||||
|
|
||||||
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
|
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
|
||||||
let fake_target = world.spawn_empty().id();
|
let fake_target = world.spawn_empty().id();
|
||||||
@@ -339,4 +557,175 @@ mod tests {
|
|||||||
"decay should run on tick 10 and downgrade confidence"
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,46 @@ impl KnowledgeGraph {
|
|||||||
// --- Write Operations ---
|
// --- Write Operations ---
|
||||||
|
|
||||||
/// Record a direct observation of another entity (entity is in LOS).
|
/// Record a direct observation of another entity (entity is in LOS).
|
||||||
pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) {
|
///
|
||||||
|
/// Returns `Some(ContradictionClaim)` if a position contradiction was
|
||||||
|
/// detected against a recent `ToldBy` source (D-083). The caller should
|
||||||
|
/// push a `ContradictionDetected` event when this returns `Some`.
|
||||||
|
pub fn observe_entity(
|
||||||
|
&mut self,
|
||||||
|
target: StableId,
|
||||||
|
position: TilePosition,
|
||||||
|
tick: u64,
|
||||||
|
) -> Option<ContradictionClaim> {
|
||||||
|
// --- Pre-overwrite contradiction check (D-083) ---
|
||||||
|
//
|
||||||
|
// If the existing entry has a ToldBy source with a different position,
|
||||||
|
// and the told-tick is within CONTRADICTION_WINDOW_TICKS of now,
|
||||||
|
// this is a contradiction: someone lied or was wrong about where
|
||||||
|
// this entity would be.
|
||||||
|
let contradiction = self.entities.get(&target).and_then(|existing| {
|
||||||
|
if let KnowledgeSource::ToldBy {
|
||||||
|
source_id,
|
||||||
|
tick: told_tick,
|
||||||
|
} = &existing.source
|
||||||
|
{
|
||||||
|
let age = tick.saturating_sub(*told_tick);
|
||||||
|
let claimed_pos = existing.last_known_position?;
|
||||||
|
if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position {
|
||||||
|
Some(ContradictionClaim {
|
||||||
|
told_by: *source_id,
|
||||||
|
told_tick: *told_tick,
|
||||||
|
claimed_position: claimed_pos,
|
||||||
|
observed_position: position,
|
||||||
|
detected_tick: tick,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let entry = self
|
let entry = self
|
||||||
.entities
|
.entities
|
||||||
.entry(target)
|
.entry(target)
|
||||||
@@ -121,18 +160,25 @@ impl KnowledgeGraph {
|
|||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
relationship: RelationshipState::Unknown,
|
relationship: RelationshipState::Unknown,
|
||||||
known_attributes: BTreeMap::new(),
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
});
|
});
|
||||||
entry.last_known_position = Some(position);
|
entry.last_known_position = Some(position);
|
||||||
entry.last_observed_tick = tick;
|
entry.last_observed_tick = tick;
|
||||||
entry.last_updated_tick = tick;
|
entry.last_updated_tick = tick;
|
||||||
entry.confidence = KnowledgeConfidence::Direct;
|
entry.confidence = KnowledgeConfidence::Direct;
|
||||||
entry.source = KnowledgeSource::DirectObservation { tick };
|
entry.source = KnowledgeSource::DirectObservation { tick };
|
||||||
// Stale entries become Active again on fresh observation.
|
|
||||||
// Contradicted entries stay Contradicted even if you're looking
|
if contradiction.is_some() {
|
||||||
// at the entity right now — the contradiction is still unresolved.
|
entry.state = KnowledgeState::Contradicted;
|
||||||
if entry.state == KnowledgeState::Stale {
|
entry.contradicted_claim = contradiction.clone();
|
||||||
|
} else if entry.state == KnowledgeState::Stale {
|
||||||
|
// Stale entries become Active again on fresh observation.
|
||||||
entry.state = KnowledgeState::Active;
|
entry.state = KnowledgeState::Active;
|
||||||
}
|
}
|
||||||
|
// Contradicted entries without a new contradiction stay Contradicted —
|
||||||
|
// the previous contradiction is still unresolved.
|
||||||
|
|
||||||
|
contradiction
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entity has left the observer's LOS. Downgrade from Direct.
|
/// Entity has left the observer's LOS. Downgrade from Direct.
|
||||||
@@ -168,6 +214,7 @@ impl KnowledgeGraph {
|
|||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
relationship: RelationshipState::Unknown,
|
relationship: RelationshipState::Unknown,
|
||||||
known_attributes: BTreeMap::new(),
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let type_str = match interaction_type {
|
let type_str = match interaction_type {
|
||||||
@@ -387,6 +434,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
};
|
};
|
||||||
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
|
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
|
||||||
|
|
||||||
@@ -513,6 +561,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -522,6 +571,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -626,6 +676,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -648,6 +699,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -746,4 +798,362 @@ mod tests {
|
|||||||
"FactionOnly must pass when observer knows the matching faction_id"
|
"FactionOnly must pass when observer knows the matching faction_id"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contradiction detection tests (D-083, #547) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contradiction_detected_when_told_by_position_differs() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
// Someone told us the target is at (10, 10) at tick 100
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(10, 10)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Direct observation at (15, 10) at tick 200 — within window (600 ticks)
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||||
|
|
||||||
|
// Contradiction should be detected
|
||||||
|
assert!(result.is_some(), "Should detect contradiction");
|
||||||
|
let claim = result.unwrap();
|
||||||
|
assert_eq!(claim.told_by, informant);
|
||||||
|
assert_eq!(claim.told_tick, 100);
|
||||||
|
assert_eq!(claim.claimed_position, make_position(10, 10));
|
||||||
|
assert_eq!(claim.observed_position, make_position(15, 10));
|
||||||
|
assert_eq!(claim.detected_tick, 200);
|
||||||
|
|
||||||
|
// Entry should be Contradicted
|
||||||
|
let entry = g.entity_knowledge(&target).unwrap();
|
||||||
|
assert_eq!(entry.state, KnowledgeState::Contradicted);
|
||||||
|
assert!(entry.contradicted_claim.is_some());
|
||||||
|
// But confidence is upgraded to Direct (we're looking at them)
|
||||||
|
assert_eq!(entry.confidence, KnowledgeConfidence::Direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_contradiction_when_told_by_position_matches() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
// Told target is at (10, 10)
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(10, 10)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe at SAME position — no contradiction
|
||||||
|
let result = g.observe_entity(target, make_position(10, 10), 200);
|
||||||
|
assert!(result.is_none(), "Same position should not be a contradiction");
|
||||||
|
let entry = g.entity_knowledge(&target).unwrap();
|
||||||
|
assert_eq!(entry.state, KnowledgeState::Active);
|
||||||
|
assert!(entry.contradicted_claim.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_contradiction_outside_time_window() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
// Told at tick 100, position (10, 10)
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(10, 10)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe at different position BUT outside window (100 + 601 = 701)
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 701);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contradiction_at_exact_window_boundary() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(10, 10)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS)
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 700);
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"Exactly at window boundary (age == 600) should still detect contradiction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_contradiction_for_direct_observation_source() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
|
||||||
|
// Previous knowledge from DirectObservation (not ToldBy)
|
||||||
|
g.observe_entity(target, make_position(10, 10), 100);
|
||||||
|
|
||||||
|
// New observation at different position — NOT a contradiction
|
||||||
|
// (we just moved, or they moved; no one lied)
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"DirectObservation source should never trigger contradiction"
|
||||||
|
);
|
||||||
|
let entry = g.entity_knowledge(&target).unwrap();
|
||||||
|
assert_eq!(entry.state, KnowledgeState::Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_contradiction_for_background_source() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
|
||||||
|
// Background knowledge with a position
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(10, 10)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 0,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::Background,
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 100);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Background source should not trigger contradiction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_contradiction_when_told_by_has_no_position() {
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
// ToldBy but no position was claimed
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: None, // no position claimed
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"ToldBy without position should not trigger contradiction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contradicted_claim_entry_field_matches_returned_claim() {
|
||||||
|
// Verify that entry.contradicted_claim is populated with identical
|
||||||
|
// data to the ContradictionClaim returned by observe_entity (D-083).
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(1);
|
||||||
|
let informant = StableId(42);
|
||||||
|
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(5, 5)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 50,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 50,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let returned = g
|
||||||
|
.observe_entity(target, make_position(12, 5), 300)
|
||||||
|
.expect("contradiction should be detected");
|
||||||
|
|
||||||
|
let entry = g.entity_knowledge(&target).unwrap();
|
||||||
|
let stored = entry.contradicted_claim.as_ref().expect("field should be populated");
|
||||||
|
|
||||||
|
assert_eq!(stored.told_by, returned.told_by);
|
||||||
|
assert_eq!(stored.told_tick, returned.told_tick);
|
||||||
|
assert_eq!(stored.claimed_position, returned.claimed_position);
|
||||||
|
assert_eq!(stored.observed_position, returned.observed_position);
|
||||||
|
assert_eq!(stored.detected_tick, returned.detected_tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_observation_keeps_contradicted_state_when_no_new_told_by() {
|
||||||
|
// After a contradiction is detected, subsequent DirectObservation
|
||||||
|
// does NOT clear the Contradicted state (D-083: "unresolved").
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let target = StableId(7);
|
||||||
|
let informant = StableId(8);
|
||||||
|
|
||||||
|
// Set up ToldBy knowledge
|
||||||
|
g.entities.insert(
|
||||||
|
target,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(3, 3)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 10,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 10,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// First observation: contradiction detected
|
||||||
|
let claim = g.observe_entity(target, make_position(9, 3), 200);
|
||||||
|
assert!(claim.is_some(), "contradiction should fire");
|
||||||
|
assert_eq!(
|
||||||
|
g.entity_knowledge(&target).unwrap().state,
|
||||||
|
KnowledgeState::Contradicted
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second observation (now source is DirectObservation, different position):
|
||||||
|
// state must stay Contradicted — contradiction is still unresolved.
|
||||||
|
let claim2 = g.observe_entity(target, make_position(11, 3), 300);
|
||||||
|
assert!(claim2.is_none(), "no new contradiction: DirectObservation source");
|
||||||
|
assert_eq!(
|
||||||
|
g.entity_knowledge(&target).unwrap().state,
|
||||||
|
KnowledgeState::Contradicted,
|
||||||
|
"Contradicted state must persist until explicitly resolved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_entities_only_told_by_one_contradicts() {
|
||||||
|
// Edge case: observer knows two entities.
|
||||||
|
// Entity A has ToldBy source, Entity B has DirectObservation.
|
||||||
|
// Only Entity A should produce a contradiction.
|
||||||
|
let mut g = KnowledgeGraph::new();
|
||||||
|
let entity_a = StableId(10);
|
||||||
|
let entity_b = StableId(20);
|
||||||
|
let informant = StableId(99);
|
||||||
|
|
||||||
|
// Entity A: ToldBy with position
|
||||||
|
g.entities.insert(
|
||||||
|
entity_a,
|
||||||
|
EntityKnowledge {
|
||||||
|
last_known_position: Some(make_position(1, 1)),
|
||||||
|
last_observed_tick: 0,
|
||||||
|
last_updated_tick: 100,
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::ToldBy {
|
||||||
|
source_id: informant,
|
||||||
|
tick: 100,
|
||||||
|
},
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
relationship: RelationshipState::Known,
|
||||||
|
known_attributes: BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entity B: DirectObservation (no informant to lie)
|
||||||
|
g.observe_entity(entity_b, make_position(5, 5), 100);
|
||||||
|
|
||||||
|
// Observe both at different positions at tick 200
|
||||||
|
let a_result = g.observe_entity(entity_a, make_position(8, 1), 200);
|
||||||
|
let b_result = g.observe_entity(entity_b, make_position(9, 5), 200);
|
||||||
|
|
||||||
|
assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict");
|
||||||
|
assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict");
|
||||||
|
assert_eq!(
|
||||||
|
g.entity_knowledge(&entity_a).unwrap().state,
|
||||||
|
KnowledgeState::Contradicted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
g.entity_knowledge(&entity_b).unwrap().state,
|
||||||
|
KnowledgeState::Active
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,18 @@
|
|||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
pub mod content_registry;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
pub use content_registry::ContentEntityRegistry;
|
||||||
|
pub use events::{
|
||||||
|
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
||||||
|
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
||||||
|
ProcessedKnowledgeGrant,
|
||||||
|
};
|
||||||
pub use graph::KnowledgeGraph;
|
pub use graph::KnowledgeGraph;
|
||||||
pub use registry::{EntityRegistry, StableEntityId};
|
pub use registry::{EntityRegistry, StableEntityId};
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
@@ -24,7 +30,9 @@ pub struct KnowledgePlugin;
|
|||||||
impl Plugin for KnowledgePlugin {
|
impl Plugin for KnowledgePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<KnowledgeEventQueue>()
|
app.init_resource::<KnowledgeEventQueue>()
|
||||||
|
.init_resource::<ContradictionDetectedQueue>()
|
||||||
.init_resource::<EntityRegistry>()
|
.init_resource::<EntityRegistry>()
|
||||||
|
.init_resource::<ContentEntityRegistry>()
|
||||||
.init_resource::<DecayThresholds>()
|
.init_resource::<DecayThresholds>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ const _: () = {
|
|||||||
assert!(KnowledgeConfidence::Direct as u8 == 3);
|
assert!(KnowledgeConfidence::Direct as u8 == 3);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
impl TryFrom<&str> for KnowledgeConfidence {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
/// Parse a confidence string from YAML content into the typed enum.
|
||||||
|
///
|
||||||
|
/// Case-insensitive. Accepts both camelCase and underscore/hyphen variants.
|
||||||
|
/// Used by KnowledgeGrant processing (D-079).
|
||||||
|
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"suspects" => Ok(Self::Suspects),
|
||||||
|
"knowsof" | "knows_of" | "knows-of" => Ok(Self::KnowsOf),
|
||||||
|
"knowsdetails" | "knows_details" | "knows-details" => Ok(Self::KnowsDetails),
|
||||||
|
"direct" => Ok(Self::Direct),
|
||||||
|
other => Err(format!(
|
||||||
|
"unknown confidence level '{}': expected one of suspects, knowsof, knowsdetails, direct",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl KnowledgeConfidence {
|
impl KnowledgeConfidence {
|
||||||
/// Step down one confidence level (used by decay system).
|
/// Step down one confidence level (used by decay system).
|
||||||
pub fn decayed(self) -> Self {
|
pub fn decayed(self) -> Self {
|
||||||
@@ -146,6 +167,33 @@ impl RelationshipState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contradiction Detection (D-083) ---
|
||||||
|
|
||||||
|
/// Ticks within which a position discrepancy counts as a contradiction.
|
||||||
|
/// 600 ticks = 1 game-hour (at 10 tps per D-031).
|
||||||
|
/// Outside this window, stale ToldBy information is simply overwritten.
|
||||||
|
pub const CONTRADICTION_WINDOW_TICKS: u64 = 600;
|
||||||
|
|
||||||
|
/// Records details of a detected contradiction on an EntityKnowledge entry.
|
||||||
|
///
|
||||||
|
/// Populated when `observe_entity()` finds a position discrepancy with a
|
||||||
|
/// recent `ToldBy` source. Both the ToldBy entry and the DirectObservation
|
||||||
|
/// receive `Contradicted` state (epistemic neutrality — the engine does
|
||||||
|
/// not determine which is wrong). D-083, Q-026 resolution.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContradictionClaim {
|
||||||
|
/// Who told us the (now-contradicted) information.
|
||||||
|
pub told_by: StableId,
|
||||||
|
/// Tick when the ToldBy information was received.
|
||||||
|
pub told_tick: u64,
|
||||||
|
/// Position the source claimed the entity was at.
|
||||||
|
pub claimed_position: TilePosition,
|
||||||
|
/// Position we directly observed the entity at.
|
||||||
|
pub observed_position: TilePosition,
|
||||||
|
/// Tick when the contradiction was detected.
|
||||||
|
pub detected_tick: u64,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Entity Knowledge ---
|
// --- Entity Knowledge ---
|
||||||
|
|
||||||
/// What entity A knows about entity B.
|
/// What entity A knows about entity B.
|
||||||
@@ -169,6 +217,10 @@ pub struct EntityKnowledge {
|
|||||||
/// Known attributes of the target entity.
|
/// Known attributes of the target entity.
|
||||||
/// Keys are structured (name, role, faction, etc.)
|
/// Keys are structured (name, role, faction, etc.)
|
||||||
pub known_attributes: BTreeMap<String, String>,
|
pub known_attributes: BTreeMap<String, String>,
|
||||||
|
/// Populated when a contradiction is detected between ToldBy and
|
||||||
|
/// DirectObservation sources (D-083). None when no contradiction exists.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contradicted_claim: Option<ContradictionClaim>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-entity fact knowledge (locations, events, abstract knowledge).
|
/// Non-entity fact knowledge (locations, events, abstract knowledge).
|
||||||
@@ -183,6 +235,10 @@ pub struct FactKnowledge {
|
|||||||
pub state: KnowledgeState,
|
pub state: KnowledgeState,
|
||||||
/// Tick when this fact was learned.
|
/// Tick when this fact was learned.
|
||||||
pub acquired_tick: u64,
|
pub acquired_tick: u64,
|
||||||
|
/// When true, this fact must not be transferred to other entities via NPC-to-NPC propagation.
|
||||||
|
/// D-080: models secrets whose sharing is existentially dangerous regardless of trust tier.
|
||||||
|
#[serde(default)]
|
||||||
|
pub disclosure_blocked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Decay Configuration ---
|
// --- Decay Configuration ---
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ mod tests {
|
|||||||
world.insert_resource(WalkabilityMap::new(32, 32, 1));
|
world.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||||
world.init_resource::<SnapshotBuffer>();
|
world.init_resource::<SnapshotBuffer>();
|
||||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||||
|
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
|
||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
world.init_resource::<ObservationEventQueue>();
|
world.init_resource::<ObservationEventQueue>();
|
||||||
world.init_resource::<VisibilityGeometry>();
|
world.init_resource::<VisibilityGeometry>();
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ fn knowledge_without_position_not_shown() {
|
|||||||
state: crate::knowledge::KnowledgeState::Active,
|
state: crate::knowledge::KnowledgeState::Active,
|
||||||
relationship: RelationshipState::PersonOfInterest,
|
relationship: RelationshipState::PersonOfInterest,
|
||||||
known_attributes: std::collections::BTreeMap::new(),
|
known_attributes: std::collections::BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -800,6 +801,7 @@ fn phase2_no_confront_without_knows_details() {
|
|||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
relationship: RelationshipState::Unknown,
|
relationship: RelationshipState::Unknown,
|
||||||
known_attributes: std::collections::BTreeMap::new(),
|
known_attributes: std::collections::BTreeMap::new(),
|
||||||
|
contradicted_claim: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2271,6 +2273,7 @@ fn access_rule_knowledge_gated_passes_with_matching_fact() {
|
|||||||
source: KnowledgeSource::Background,
|
source: KnowledgeSource::Background,
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ pub fn check_contraband_scan(
|
|||||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: time.tick,
|
acquired_tick: time.tick,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -463,6 +464,7 @@ mod tests {
|
|||||||
source: KnowledgeSource::DirectObservation { tick: 0 },
|
source: KnowledgeSource::DirectObservation { tick: 0 },
|
||||||
state: KnowledgeState::Active,
|
state: KnowledgeState::Active,
|
||||||
acquired_tick: 0,
|
acquired_tick: 0,
|
||||||
|
disclosure_blocked: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc
|
|||||||
use crate::content::line_pool::{
|
use crate::content::line_pool::{
|
||||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||||
};
|
};
|
||||||
|
use crate::content::types::KnowledgeGrant;
|
||||||
use crate::content::LinePoolIndexResource;
|
use crate::content::LinePoolIndexResource;
|
||||||
|
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||||
|
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
|
||||||
|
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
|
||||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||||
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
|
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
|
||||||
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
|
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
|
||||||
@@ -422,6 +426,7 @@ pub fn process_talk_interaction(
|
|||||||
time: Res<SimulationTime>,
|
time: Res<SimulationTime>,
|
||||||
line_pool: Option<Res<LinePoolIndexResource>>,
|
line_pool: Option<Res<LinePoolIndexResource>>,
|
||||||
registry: Res<EntityRegistry>,
|
registry: Res<EntityRegistry>,
|
||||||
|
content_registry: Res<ContentEntityRegistry>,
|
||||||
mut rng: ResMut<SimRng>,
|
mut rng: ResMut<SimRng>,
|
||||||
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
||||||
mut trust_queue: ResMut<TrustEventQueue>,
|
mut trust_queue: ResMut<TrustEventQueue>,
|
||||||
@@ -442,6 +447,7 @@ pub fn process_talk_interaction(
|
|||||||
Option<&mut InteractionMemory>,
|
Option<&mut InteractionMemory>,
|
||||||
Option<&NpcName>,
|
Option<&NpcName>,
|
||||||
Option<&NpcColorIndex>,
|
Option<&NpcColorIndex>,
|
||||||
|
Option<&KnowledgeGraph>,
|
||||||
)>,
|
)>,
|
||||||
) {
|
) {
|
||||||
let Some(line_pool) = line_pool else {
|
let Some(line_pool) = line_pool else {
|
||||||
@@ -462,8 +468,8 @@ pub fn process_talk_interaction(
|
|||||||
|
|
||||||
let target = talk_request.target;
|
let target = talk_request.target;
|
||||||
|
|
||||||
// Look up NPC dialogue profile, mood, interaction history, name, and color (#325)
|
// Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079)
|
||||||
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
|
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) =
|
||||||
npc_query.get_mut(target)
|
npc_query.get_mut(target)
|
||||||
else {
|
else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -541,6 +547,19 @@ pub fn process_talk_interaction(
|
|||||||
|
|
||||||
cooldown.record(&line.id, time.tick);
|
cooldown.record(&line.id, time.tick);
|
||||||
|
|
||||||
|
// Knowledge grant (D-079): fire at line selection time, server-authoritative.
|
||||||
|
if let Some(grant) = &line.knowledge_grant {
|
||||||
|
emit_knowledge_grant(
|
||||||
|
grant,
|
||||||
|
player_entity,
|
||||||
|
speaker_stable,
|
||||||
|
&content_registry,
|
||||||
|
npc_kg_opt,
|
||||||
|
time.tick,
|
||||||
|
&mut event_queue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit IncompleteInteraction if overwriting an existing dialogue session
|
// Emit IncompleteInteraction if overwriting an existing dialogue session
|
||||||
if let Some(prev) = active_dialogue_opt {
|
if let Some(prev) = active_dialogue_opt {
|
||||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||||
@@ -594,6 +613,102 @@ pub fn process_talk_interaction(
|
|||||||
commands.entity(player_entity).remove::<TalkRequest>();
|
commands.entity(player_entity).remove::<TalkRequest>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Knowledge grant helper (D-079)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field.
|
||||||
|
///
|
||||||
|
/// Called at line selection time (server-authoritative, tick-stamped).
|
||||||
|
/// Source is always `ToldBy { source_id: speaker_stable, tick }`.
|
||||||
|
///
|
||||||
|
/// Fact grants: dropped with tracing::warn! if the granting NPC's KG
|
||||||
|
/// does not contain the fact (D-079 runtime guardrail).
|
||||||
|
/// Entity grants: no guardrail — always emitted if entity_ref resolves.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn emit_knowledge_grant(
|
||||||
|
grant: &KnowledgeGrant,
|
||||||
|
player_entity: Entity,
|
||||||
|
speaker_stable: StableId,
|
||||||
|
content_registry: &ContentEntityRegistry,
|
||||||
|
npc_kg_opt: Option<&KnowledgeGraph>,
|
||||||
|
tick: u64,
|
||||||
|
event_queue: &mut crate::knowledge::KnowledgeEventQueue,
|
||||||
|
) {
|
||||||
|
let source = KnowledgeSource::ToldBy {
|
||||||
|
source_id: speaker_stable,
|
||||||
|
tick,
|
||||||
|
};
|
||||||
|
|
||||||
|
match grant {
|
||||||
|
KnowledgeGrant::Fact { fact_id, confidence } => {
|
||||||
|
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let fid = FactId(fact_id.clone());
|
||||||
|
// Guardrail: NPC must know this fact to grant it (D-079).
|
||||||
|
let npc_knows = npc_kg_opt
|
||||||
|
.map(|kg| kg.knows_fact(&fid))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !npc_knows {
|
||||||
|
tracing::warn!(
|
||||||
|
"KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail",
|
||||||
|
speaker_stable,
|
||||||
|
fact_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||||
|
observer: player_entity,
|
||||||
|
tick,
|
||||||
|
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
|
||||||
|
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
|
||||||
|
fact_id: fid,
|
||||||
|
confidence: conf,
|
||||||
|
}),
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KnowledgeGrant::Entity {
|
||||||
|
entity_ref,
|
||||||
|
attributes,
|
||||||
|
confidence,
|
||||||
|
} => {
|
||||||
|
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(target_id) = content_registry.resolve(entity_ref) else {
|
||||||
|
tracing::warn!(
|
||||||
|
"KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry",
|
||||||
|
entity_ref
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||||
|
observer: player_entity,
|
||||||
|
tick,
|
||||||
|
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
|
||||||
|
grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant {
|
||||||
|
target_id,
|
||||||
|
attributes: attributes.clone(),
|
||||||
|
confidence: conf,
|
||||||
|
}),
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// System: process_walk_away (D-064)
|
// System: process_walk_away (D-064)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1330,6 +1445,7 @@ mod tests {
|
|||||||
world.init_resource::<SimulationTime>();
|
world.init_resource::<SimulationTime>();
|
||||||
world.insert_resource(SimRng::new(42));
|
world.insert_resource(SimRng::new(42));
|
||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world.init_resource::<ContentEntityRegistry>();
|
||||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||||
world.init_resource::<TrustEventQueue>();
|
world.init_resource::<TrustEventQueue>();
|
||||||
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
|
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
|
||||||
@@ -1828,6 +1944,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut world = setup_dialogue_world();
|
let mut world = setup_dialogue_world();
|
||||||
world.init_resource::<KnowledgeEventQueue>();
|
world.init_resource::<KnowledgeEventQueue>();
|
||||||
|
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
|
||||||
|
|
||||||
let npc = world.spawn_empty().id();
|
let npc = world.spawn_empty().id();
|
||||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ fn verify_template_role_slots() {
|
|||||||
fn spawn_npc_from_content_store() {
|
fn spawn_npc_from_content_store() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||||
|
|
||||||
// Create a minimal content store with one test NPC
|
// Create a minimal content store with one test NPC
|
||||||
let mut store = ContentStore::default();
|
let mut store = ContentStore::default();
|
||||||
@@ -333,6 +334,7 @@ fn spawn_real_content_with_relationships_and_secrets() {
|
|||||||
|
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||||
|
|
||||||
let store = load_content(&root).expect("content loading should succeed");
|
let store = load_content(&root).expect("content loading should succeed");
|
||||||
|
|||||||
Reference in New Issue
Block a user