Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
This commit is contained in:
2026-02-24 13:00:39 +01:00
27 changed files with 4764 additions and 23 deletions
+14
View File
@@ -17,3 +17,17 @@ When producing many files (wiki pages, content batches, bulk docs):
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
## Team monitoring (stuck agent detection)
When leading a team (sprint, workshop, or any multi-agent session):
**Agent heartbeat rule** — include in every agent spawn prompt:
> If you have been working on a single task for more than 15 minutes
> without making progress, message the team lead with what is blocking
> you. Do not keep retrying the same approach silently.
**Team lead proactive checks:**
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
+13 -4
View File
@@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow
### 1. Determine the branch
### 0. Branch guard — MUST be on `main`
If the user provided a branch name as argument, use it. Otherwise use the
current branch (`git branch --show-current`). If on `main`, ask the user
which branch to review.
```bash
git branch --show-current
```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run from the `main` worktree. Switch to `main` first."
Do NOT proceed with the review from a team branch.
### 1. Determine the branch to review
If the user provided a branch name as argument, use it. Otherwise list open
PRs and ask the user which branch to review.
To list open PRs on Gitea:
```bash
+11
View File
@@ -8,6 +8,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Added
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
+5 -1
View File
@@ -178,6 +178,10 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::sound::collect_sound_events)
.after(crate::simulation::conversation::run_npc_conversations)
.after(crate::simulation::dialogue::process_walk_away),
// Contradiction monologue fires from queue populated by prior tick's
// process_knowledge_events (which runs after the snapshot).
crate::simulation::monologue::process_contradiction_monologue
.after(crate::simulation::monologue::trigger_event_monologue),
crate::simulation::follow::update_follow_state
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::movement::validate_movement)
@@ -185,7 +189,7 @@ impl Plugin for BridgePlugin {
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::trigger_event_monologue)
.after(crate::simulation::monologue::process_contradiction_monologue)
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_confrontation_response)
.after(crate::simulation::dialogue::process_dialogue_response)
+6
View File
@@ -20,6 +20,8 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::path::PathBuf;
use crate::knowledge::ContentEntityRegistry;
/// Configuration for the content loader.
/// Set the content root path before adding ContentPlugin.
#[derive(Resource, Debug, Clone)]
@@ -52,6 +54,10 @@ impl Plugin for ContentPlugin {
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(PostUpdate, hot_reload::hot_reload_content);
+9
View File
@@ -22,6 +22,7 @@ use std::collections::BTreeMap;
use crate::content::loader::{ContentStore, DistrictContent};
use crate::content::types;
use crate::knowledge::content_registry::ContentEntityRegistry;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
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()),
));
// 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
.npc_ids
.insert(profile.canonical_id.clone(), stable_id);
@@ -296,6 +303,7 @@ fn resolve_information(
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)
})
@@ -648,6 +656,7 @@ mod tests {
fn create_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<ContentEntityRegistry>();
world
}
+23 -3
View File
@@ -491,10 +491,30 @@ pub struct DialogueLine {
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)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
#[serde(untagged)]
pub enum KnowledgeGrant {
/// 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,
},
}
// ---------------------------------------------------------------------------
+86
View File
@@ -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);
}
}
+390 -1
View File
@@ -4,6 +4,7 @@
//! KnowledgeEvents; the processing system drains them per tick.
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use crate::simulation::movement::TilePosition;
@@ -11,6 +12,41 @@ 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)]
@@ -36,6 +72,16 @@ pub enum KnowledgeEventType {
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).
@@ -57,6 +103,55 @@ 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) {
@@ -82,9 +177,15 @@ impl KnowledgeEventQueue {
/// 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::conversation::NpcName>,
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
let events = queue.drain();
@@ -96,7 +197,59 @@ pub fn process_knowledge_events(
match event.event_type {
KnowledgeEventType::DirectObservation { target, position } => {
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 {
debug_assert!(
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
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
@@ -236,6 +452,7 @@ mod tests {
registry.register(observer);
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
@@ -261,6 +478,7 @@ mod tests {
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();
@@ -339,4 +557,175 @@ mod tests {
"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");
}
}
+429 -6
View File
@@ -108,7 +108,46 @@ impl KnowledgeGraph {
// --- Write Operations ---
/// 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
.entities
.entry(target)
@@ -121,18 +160,31 @@ impl KnowledgeGraph {
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
});
entry.last_known_position = Some(position);
entry.last_observed_tick = tick;
entry.last_updated_tick = tick;
entry.confidence = KnowledgeConfidence::Direct;
entry.source = KnowledgeSource::DirectObservation { tick };
// Stale entries become Active again on fresh observation.
// Contradicted entries stay Contradicted even if you're looking
// at the entity right now — the contradiction is still unresolved.
if entry.state == KnowledgeState::Stale {
if contradiction.is_some() {
entry.state = KnowledgeState::Contradicted;
entry.contradicted_claim = contradiction.clone();
} else if entry.state == KnowledgeState::Stale {
// Stale entries become Active again on fresh observation.
entry.state = KnowledgeState::Active;
}
// Contradicted entries without a new contradiction stay Contradicted —
// the previous contradiction is still unresolved.
//
// After this write, entry.source is DirectObservation, so subsequent
// observations will NOT re-trigger contradiction detection (the
// pre-overwrite check only fires when existing.source is ToldBy).
// This is intentional: once contradicted, the entry reflects the
// observer's own eyes and cannot be "contradicted" again by looking.
contradiction
}
/// Entity has left the observer's LOS. Downgrade from Direct.
@@ -164,10 +216,18 @@ impl KnowledgeGraph {
last_observed_tick: 0,
last_updated_tick: 0,
confidence: KnowledgeConfidence::Suspects,
source: KnowledgeSource::DirectObservation { tick },
// Heard/Close — not DirectObservation, because walk-away is
// not a confirmed sighting. Using DirectObservation here
// would falsely inoculate the entry against contradiction
// detection (pre-overwrite check only fires on ToldBy source).
source: KnowledgeSource::Heard {
tick,
range: super::types::SoundRange::Close,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
});
let type_str = match interaction_type {
@@ -387,6 +447,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
};
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
@@ -513,6 +574,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
),
(
@@ -522,6 +584,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
),
];
@@ -626,6 +689,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
@@ -648,6 +712,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
@@ -746,4 +811,362 @@ mod tests {
"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
);
}
}
+13 -1
View File
@@ -7,12 +7,18 @@
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
pub mod content_registry;
pub mod events;
pub mod graph;
pub mod registry;
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 registry::{EntityRegistry, StableEntityId};
pub use types::*;
@@ -24,11 +30,17 @@ pub struct KnowledgePlugin;
impl Plugin for KnowledgePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<KnowledgeEventQueue>()
.init_resource::<ContradictionDetectedQueue>()
.init_resource::<EntityRegistry>()
.init_resource::<ContentEntityRegistry>()
.init_resource::<DecayThresholds>()
.add_systems(
Update,
(
// Runs after snapshot: contradiction monologue and relationship
// shifts from KnowledgeGranted events lag by one tick (~0.1s).
// Acceptable — the player perceives the contradiction on the
// next snapshot, which reads as a natural reaction delay.
events::process_knowledge_events
.after(crate::perception::observer::compute_observer_snapshot),
events::decay_knowledge.after(events::process_knowledge_events),
+56
View File
@@ -54,6 +54,27 @@ const _: () = {
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 {
/// Step down one confidence level (used by decay system).
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 ---
/// What entity A knows about entity B.
@@ -169,6 +217,10 @@ pub struct EntityKnowledge {
/// Known attributes of the target entity.
/// Keys are structured (name, role, faction, etc.)
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).
@@ -183,6 +235,10 @@ pub struct FactKnowledge {
pub state: KnowledgeState,
/// Tick when this fact was learned.
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 ---
+696
View File
@@ -0,0 +1,696 @@
//! Unprompted disclosure system (D-081).
//!
//! Two-system pipeline:
//! - `derive_disclosure_candidates`: per-NPC KG filter, recomputed every 30 ticks
//! - `process_unprompted_disclosure`: trigger gates, StableId-ordered firing
//!
//! NPCs check only their own KG (D-010 principle 2 — no cross-entity KG reads).
//! Disclosure grants the fact to the player's KG via `KnowledgeGranted` event
//! and emits a placeholder `MonologueEvent` (Layer 4 line selection in #172).
use std::collections::BTreeSet;
use bevy_ecs::prelude::*;
use crate::bridge::types::MonologueEvent;
use crate::knowledge::events::{
KnowledgeEvent, KnowledgeEventType, ProcessedFactGrant, ProcessedKnowledgeGrant,
};
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, KnowledgeState, RelationshipState, StableId};
use crate::knowledge::{KnowledgeEventQueue, KnowledgeGraph, StableEntityId};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::relationships::RelationshipGraph;
use crate::npc::trait_modifiers::{traits_to_keys, TraitModifierConfig};
use crate::npc::{Contentment, Npc, PersonalityTraits};
use crate::simulation::monologue::MonologueBuffer;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Recompute `DisclosureCandidates` every N ticks (30 ticks = 3 game-minutes at 10 tps).
const CANDIDATE_REFRESH_TICKS: u64 = 30;
/// Per-NPC cooldown ticks after a disclosure fires (300 = 30 game-minutes).
const NPC_COOLDOWN_TICKS: u64 = 300;
/// Minimum ticks between any two disclosures (global rate limit).
const GLOBAL_RATE_LIMIT_TICKS: u64 = 10;
/// Max candidates retained in `DisclosureCandidates`.
const MAX_CANDIDATES: usize = 10;
/// Witness inhibition check radius (Manhattan distance, tiles).
const WITNESS_RADIUS: u32 = 5;
/// Minimum NPC→player trust for Surface-tier disclosure.
const SURFACE_TRUST: i8 = 0;
/// NPC→player trust at which witness inhibition is waived (Secret tier, D-081).
const SECRET_TRUST: i8 = 7;
/// Trust level below which a nearby NPC counts as an untrusted witness.
const REAL_TRUST: i8 = 3;
/// Player proximity range for candidate recompute (tiles). Matches voice range.
const PLAYER_RANGE_TILES: u32 = 8;
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Per-NPC computed disclosure candidate pool (D-081).
///
/// Recomputed every `CANDIDATE_REFRESH_TICKS` ticks by `derive_disclosure_candidates`.
/// Consumed by `process_unprompted_disclosure` when all trigger gates pass.
///
/// Facts are sorted: confidence desc, then `acquired_tick` desc. Capped at
/// `MAX_CANDIDATES`.
#[derive(Component, Debug, Clone, Default)]
pub struct DisclosureCandidates {
/// Fact IDs eligible for disclosure, ordered by priority.
pub candidates: Vec<FactId>,
/// Tick when this pool was last computed. 0 = never computed.
pub computed_tick: u64,
}
/// Per-NPC disclosure cooldown state (D-081).
///
/// Tracks which facts have been disclosed during the current window
/// (primary narrative quality gate) and the per-NPC silence period.
#[derive(Component, Debug, Clone, Default)]
pub struct DisclosureCooldown {
/// Facts disclosed this window — prevents repeating the same fact.
pub per_fact_history: BTreeSet<FactId>,
/// Tick when the per-NPC cooldown expires. 0 = no active cooldown.
pub npc_cooldown_until: u64,
}
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Global rate limiter: at most 1 unprompted disclosure per `GLOBAL_RATE_LIMIT_TICKS`.
///
/// When multiple NPCs are eligible simultaneously, the one with the lowest
/// StableId fires first (deterministic, D-010 principle 4).
#[derive(Resource, Debug, Clone, Default)]
pub struct DisclosureGlobalRateLimit {
pub last_disclosure_tick: u64,
}
// ---------------------------------------------------------------------------
// derive_disclosure_candidates
// ---------------------------------------------------------------------------
/// Recompute `DisclosureCandidates` for each Active NPC.
///
/// Runs every `CANDIDATE_REFRESH_TICKS` ticks (checked per-NPC via `computed_tick`).
/// Filters the NPC's own KG through:
///
/// - `KnowledgeState::Active` only
/// - Confidence >= `KnowsOf` (or trait-lowered threshold via `TraitModifierConfig`)
/// - Not in `per_fact_history`
/// - `disclosure_blocked != true`
/// - ToldBy exclusion if Cautious trait is configured
///
/// Applies Stage 1 trait filters from `TraitModifierConfig`. Sorted by
/// confidence desc then `acquired_tick` desc. Capped at `MAX_CANDIDATES`.
pub fn derive_disclosure_candidates(
time: Res<SimulationTime>,
trait_config: Res<TraitModifierConfig>,
mut npc_query: Query<
(
&KnowledgeGraph,
&DisclosureCooldown,
Option<&PersonalityTraits>,
&mut DisclosureCandidates,
),
(With<Npc>, With<ActiveSim>),
>,
) {
let current_tick = time.tick;
for (kg, cooldown, traits_opt, mut candidates) in &mut npc_query {
// Only recompute when the refresh interval has elapsed.
if candidates.computed_tick != 0
&& current_tick.saturating_sub(candidates.computed_tick) < CANDIDATE_REFRESH_TICKS
{
continue;
}
let trait_keys = traits_opt
.map(|t| traits_to_keys(&t.traits))
.unwrap_or_default();
// Effective confidence floor.
// `lowest_min_confidence` returns the most permissive threshold across
// all traits (additive expansion — e.g., Gossipy sets Suspects,
// which wins over Cautious raising to KnowsDetails).
let min_confidence = trait_config
.lowest_min_confidence(&trait_keys)
.unwrap_or(KnowledgeConfidence::KnowsOf);
// Cautious trait: exclude facts with ToldBy (rumour) source.
let exclude_told_by = trait_keys.iter().any(|k| {
trait_config
.modifier_for(k)
.is_some_and(|m| m.stage1.exclude_told_by)
});
// Loyal trait: exclude facts sourced from high-trust entities.
// "Don't gossip about your friends" — if ToldBy source has Friendly
// relationship in this NPC's KG, suppress the fact. D-081.
let exclude_high_trust = trait_config.any_excludes_high_trust(&trait_keys);
let mut pool: Vec<(FactId, u64, KnowledgeConfidence)> = kg
.known_facts_iter()
.filter(|(fact_id, fact)| {
// Active state only.
if fact.state != KnowledgeState::Active {
return false;
}
// Not already disclosed this window.
if cooldown.per_fact_history.contains(*fact_id) {
return false;
}
// Existentially dangerous secrets never disclosed (D-080).
if fact.disclosure_blocked {
return false;
}
// Confidence threshold.
if fact.confidence < min_confidence {
return false;
}
// Cautious: skip ToldBy-source facts.
if exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
return false;
}
// Loyal: skip facts from high-trust (Friendly) source entities.
if exclude_high_trust {
if let KnowledgeSource::ToldBy { source_id, .. } = &fact.source {
if kg.relationship_with(source_id) == RelationshipState::Friendly {
return false;
}
}
}
true
})
.map(|(id, fact)| (id.clone(), fact.acquired_tick, fact.confidence))
.collect();
// Sort: confidence desc, then acquired_tick desc (most recent first).
pool.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
pool.truncate(MAX_CANDIDATES);
candidates.candidates = pool.into_iter().map(|(id, _, _)| id).collect();
candidates.computed_tick = current_tick;
}
}
// ---------------------------------------------------------------------------
// process_unprompted_disclosure
// ---------------------------------------------------------------------------
/// Collected during the read pass; used for sorting and winner selection.
struct EligibleNpc {
entity: Entity,
stable_id: StableId,
pos: TilePosition,
fact_id: FactId,
override_witness: bool,
}
/// Fire one unprompted disclosure per tick window when all trigger gates pass (D-081).
///
/// Trigger gates (all must pass for a given NPC):
///
/// 1. NPC has a `StableEntityId` (required for trust lookup)
/// 2. `DisclosureCandidates` pool is non-empty
/// 3. NPC→player trust >= `SURFACE_TRUST`
/// 4. `MoodState` != `NpcMood::Hostile`
/// 5. `Contentment.level` >= 10
/// 6. Per-NPC cooldown not active
/// 7. Player within `PLAYER_RANGE_TILES`
/// 8. Witness inhibition: no untrusted NPCs within `WITNESS_RADIUS` tiles
/// (waived if NPC→player trust >= `SECRET_TRUST` or Talkative trait)
/// 9. Location privacy: stubbed as always-pass — full impl in #172
///
/// Multiple eligible NPCs sorted by ascending StableId (D-010 principle 4).
/// First in order that also passes witness inhibition fires.
/// Subject to global rate limit (`GLOBAL_RATE_LIMIT_TICKS`).
pub fn process_unprompted_disclosure(
time: Res<SimulationTime>,
spatial: Res<NaiveSpatialIndex>,
relationship_graph: Res<RelationshipGraph>,
trait_config: Res<TraitModifierConfig>,
mut rate_limit: ResMut<DisclosureGlobalRateLimit>,
mut event_queue: ResMut<KnowledgeEventQueue>,
player_pos_query: Query<
(Entity, &TilePosition, Option<&StableEntityId>),
With<PlayerCharacter>,
>,
mut player_mono_query: Query<&mut MonologueBuffer, With<PlayerCharacter>>,
mut npc_query: Query<
(
Entity,
&TilePosition,
Option<&StableEntityId>,
Option<&MoodState>,
Option<&Contentment>,
Option<&PersonalityTraits>,
&DisclosureCandidates,
&mut DisclosureCooldown,
),
(With<Npc>, With<ActiveSim>),
>,
witness_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
) {
let current_tick = time.tick;
// Gate: global rate limit — at most 1 disclosure per GLOBAL_RATE_LIMIT_TICKS.
if current_tick.saturating_sub(rate_limit.last_disclosure_tick) < GLOBAL_RATE_LIMIT_TICKS {
return;
}
// Collect player state. Single-player assumption (D-010).
let Ok((player_entity, player_pos_ref, player_sid_opt)) = player_pos_query.single() else {
return;
};
let player_pos = *player_pos_ref;
let player_sid: Option<StableId> = player_sid_opt.map(|s| s.0);
// --- Read pass: collect all NPCs passing the non-spatial gates ---
let mut eligible: Vec<EligibleNpc> = npc_query
.iter()
.filter_map(
|(entity, pos, sid_opt, mood_opt, content_opt, traits_opt, candidates, cooldown)| {
// Gate 1: must have a StableId for trust lookup.
let npc_sid = sid_opt?.0;
// Gate 2: candidate pool non-empty.
// Takes highest-priority candidate (sorted by confidence desc,
// then recency desc in derive_disclosure_candidates). Full Layer 4
// line selection with variety tracking is deferred to #172.
let fact_id = candidates.candidates.first()?.clone();
// Gate 3: NPC→player trust >= Surface.
let npc_player_trust = player_sid
.and_then(|psid| relationship_graph.get_relationship(&npc_sid, &psid))
.map(|e| e.trust)
.unwrap_or(0);
if npc_player_trust < SURFACE_TRUST {
return None;
}
// Gate 4: mood not Hostile.
if mood_opt.is_some_and(|ms| ms.mood == NpcMood::Hostile) {
return None;
}
// Gate 5: contentment >= -10.
if content_opt.is_some_and(|c| c.level < -10) {
return None;
}
// Gate 6: per-NPC cooldown not active.
if current_tick < cooldown.npc_cooldown_until {
return None;
}
// Gate 7: player within PLAYER_RANGE_TILES (same z-level only).
let in_range = pos
.manhattan_distance(&player_pos)
.is_some_and(|d| d <= PLAYER_RANGE_TILES);
if !in_range {
return None;
}
// Gate 9: location privacy — stubbed always-pass.
// Full implementation deferred to #172 (Layer 4 disclosure pipeline).
// Compute witness inhibition override for gate 8.
let trait_keys = traits_opt
.map(|t| traits_to_keys(&t.traits))
.unwrap_or_default();
let override_witness = npc_player_trust >= SECRET_TRUST
|| trait_config.any_overrides_witness_inhibition(&trait_keys);
Some(EligibleNpc {
entity,
stable_id: npc_sid,
pos: *pos,
fact_id,
override_witness,
})
},
)
.collect();
if eligible.is_empty() {
return;
}
// Sort by ascending StableId for determinism (D-010 principle 4).
eligible.sort_by_key(|c| c.stable_id);
// Gate 8: witness inhibition — find first NPC that passes spatial check.
let winner = eligible.into_iter().find(|candidate| {
if candidate.override_witness {
return true;
}
!has_untrusted_witness(
&candidate.pos,
candidate.stable_id,
player_entity,
&spatial,
&witness_sid_query,
&relationship_graph,
)
});
let Some(winner) = winner else {
return;
};
// --- Fire the disclosure ---
// 1. Grant the fact to the player's KG via KnowledgeGranted event (ToldBy source).
// Confidence capped at KnowsOf (same rule as NPC-to-NPC transfer, D-080).
event_queue.push(KnowledgeEvent {
observer: player_entity,
tick: current_tick,
event_type: KnowledgeEventType::KnowledgeGranted {
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
fact_id: winner.fact_id.clone(),
confidence: KnowledgeConfidence::KnowsOf,
}),
source: KnowledgeSource::ToldBy {
source_id: winner.stable_id,
tick: current_tick,
},
},
});
// 2. Placeholder monologue event — actual line selection deferred to #172
// (Layer 4 unprompted disclosure pipeline reads DisclosureCandidates).
if let Ok(mut mono_buf) = player_mono_query.single_mut() {
mono_buf.set(MonologueEvent {
id: format!("disclosure_{}", winner.fact_id.0),
text: String::from("(Layer 4 line selection — ticket #172)"),
duration_seconds: 4.0,
});
}
// 3. Update NPC cooldown state.
// Note: re-queries npc_query mutably after the read pass above. This is
// safe because the read pass only borrows shared refs and completes before
// this point. The two-phase pattern (read → select winner → write) avoids
// holding a mutable borrow during iteration.
if let Ok((_, _, _, _, _, _, _, mut cooldown)) = npc_query.get_mut(winner.entity) {
cooldown.per_fact_history.insert(winner.fact_id.clone());
cooldown.npc_cooldown_until = current_tick + NPC_COOLDOWN_TICKS;
}
// 4. Advance global rate limit.
rate_limit.last_disclosure_tick = current_tick;
tracing::debug!(
tick = current_tick,
npc_sid = ?winner.stable_id,
fact_id = %winner.fact_id.0,
"unprompted disclosure fired"
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns true if any untrusted NPC is within `WITNESS_RADIUS` of `pos`.
///
/// "Untrusted" = the disclosing NPC's trust toward that witness is below
/// `REAL_TRUST`. The player entity is excluded (they are the target).
fn has_untrusted_witness(
pos: &TilePosition,
npc_sid: StableId,
player_entity: Entity,
spatial: &NaiveSpatialIndex,
witness_sid_query: &Query<Option<&StableEntityId>, With<Npc>>,
relationship_graph: &RelationshipGraph,
) -> bool {
for witness_entity in spatial.entities_in_range(pos, WITNESS_RADIUS) {
if witness_entity == player_entity {
continue;
}
if let Ok(Some(witness_sid)) = witness_sid_query.get(witness_entity) {
let trust = relationship_graph
.get_relationship(&npc_sid, &witness_sid.0)
.map(|e| e.trust)
.unwrap_or(0);
if trust < REAL_TRUST {
return true;
}
}
}
false
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use bevy_app::prelude::*;
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::{KnowledgeConfidence, KnowledgeSource, KnowledgeState};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
fn make_fact(
confidence: KnowledgeConfidence,
state: KnowledgeState,
acquired_tick: u64,
disclosure_blocked: bool,
) -> crate::knowledge::types::FactKnowledge {
crate::knowledge::types::FactKnowledge {
confidence,
source: KnowledgeSource::Background,
state,
acquired_tick,
disclosure_blocked,
}
}
fn make_kg(facts: Vec<(FactId, crate::knowledge::types::FactKnowledge)>) -> KnowledgeGraph {
KnowledgeGraph::with_background(facts)
}
/// Build a minimal App for derive_disclosure_candidates tests.
fn build_app() -> App {
let mut app = App::new();
app.init_resource::<SimulationTime>();
app.init_resource::<TraitModifierConfig>();
app.add_systems(Update, derive_disclosure_candidates);
app
}
// -----------------------------------------------------------------------
// derive_disclosure_candidates tests
// -----------------------------------------------------------------------
#[test]
fn active_knowsof_fact_becomes_candidate() {
let mut app = build_app();
let fact_id = FactId("investigation.clue".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
)]);
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert!(
candidates.candidates.contains(&fact_id),
"Active KnowsOf fact should be in pool"
);
}
#[test]
fn disclosure_blocked_fact_excluded() {
let mut app = build_app();
let fact_id = FactId("secret.dangerous".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::KnowsDetails, KnowledgeState::Active, 5, true),
)]);
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert!(
!candidates.candidates.contains(&fact_id),
"disclosure_blocked fact must never be a candidate"
);
}
#[test]
fn stale_fact_excluded() {
let mut app = build_app();
let fact_id = FactId("cargo.manifest".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Stale, 5, false),
)]);
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert!(
!candidates.candidates.contains(&fact_id),
"Stale fact must be excluded"
);
}
#[test]
fn already_disclosed_fact_excluded() {
let mut app = build_app();
let fact_id = FactId("dock.schedule".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
)]);
let mut cooldown = DisclosureCooldown::default();
cooldown.per_fact_history.insert(fact_id.clone());
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, cooldown, DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert!(
!candidates.candidates.contains(&fact_id),
"Fact in per_fact_history must be excluded"
);
}
#[test]
fn candidates_capped_at_max() {
let mut app = build_app();
// Spawn 15 facts — only MAX_CANDIDATES should survive.
let facts: Vec<_> = (0..15)
.map(|i| {
(
FactId(format!("fact.{:02}", i)),
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, i as u64, false),
)
})
.collect();
let kg = make_kg(facts);
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert_eq!(
candidates.candidates.len(),
MAX_CANDIDATES,
"Candidate pool must be capped at MAX_CANDIDATES"
);
}
#[test]
fn refresh_skipped_within_interval() {
let mut app = build_app();
let fact_id = FactId("investigation.clue".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
)]);
// Set computed_tick = 1 (non-zero). Tick 5 is within the 30-tick refresh window.
let mut candidates = DisclosureCandidates::default();
candidates.computed_tick = 1;
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), candidates))
.id();
// Advance tick to 5 (within CANDIDATE_REFRESH_TICKS = 30).
app.world_mut().resource_mut::<SimulationTime>().tick = 5;
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert_eq!(
candidates.computed_tick, 1,
"Refresh should be skipped within the interval"
);
assert!(
candidates.candidates.is_empty(),
"Candidates should remain empty (no recompute)"
);
}
#[test]
fn suspects_confidence_below_default_floor_excluded() {
let mut app = build_app();
let fact_id = FactId("rumour.vague".to_string());
let kg = make_kg(vec![(
fact_id.clone(),
make_fact(KnowledgeConfidence::Suspects, KnowledgeState::Active, 5, false),
)]);
let npc = app
.world_mut()
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
.id();
app.update();
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
assert!(
!candidates.candidates.contains(&fact_id),
"Suspects-confidence fact must be below the KnowsOf default floor"
);
}
}
+9
View File
@@ -2,6 +2,7 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod disclosure;
pub mod generate;
pub mod interaction;
pub mod mood;
@@ -9,6 +10,7 @@ pub mod relationships;
pub mod routine;
pub mod tell_state;
pub mod tolerance;
pub mod trait_modifiers;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -31,6 +33,8 @@ impl Plugin for NpcPlugin {
.init_resource::<routine::PreviousDayPhase>()
.init_resource::<tolerance::ToleranceBreachEventQueue>()
.init_resource::<routine::RoutineDeviationEventQueue>()
.init_resource::<disclosure::DisclosureGlobalRateLimit>()
.init_resource::<trait_modifiers::TraitModifierConfig>()
.add_systems(
Update,
(
@@ -61,6 +65,11 @@ impl Plugin for NpcPlugin {
.after(mood::update_mood)
.after(routine::detect_routine_deviation)
.before(crate::perception::observer::compute_observer_snapshot),
disclosure::derive_disclosure_candidates
.before(crate::perception::observer::compute_observer_snapshot),
disclosure::process_unprompted_disclosure
.after(disclosure::derive_disclosure_candidates)
.before(crate::perception::observer::compute_observer_snapshot),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
+206 -4
View File
@@ -21,6 +21,8 @@
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::RelationshipState;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{
Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold,
@@ -77,6 +79,7 @@ fn derive_category(
mood_state: &MoodState,
relationships_opt: Option<&Relationships>,
deviation_opt: Option<&RoutineDeviation>,
kg_opt: Option<&KnowledgeGraph>,
) -> Option<TellCategory> {
// Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4)
if deviation_opt.is_some() {
@@ -102,10 +105,20 @@ fn derive_category(
}
// Priority 5: Friendly — high contentment with at least one trusted relationship
// D-082: prefer KG relationship state over ground-truth axis data.
// Self-axis components (secret, stress, contentment, mood) remain ground-truth;
// OTHER-entity relationship assessment uses the knowledge graph.
if contentment.level > 20 {
let has_positive_relationship = relationships_opt
.map(|rels| rels.entries.iter().any(|r| r.trust_level > 3))
.unwrap_or(false);
let has_positive_relationship = if let Some(kg) = kg_opt {
// D-082: use knowledge graph for other-entity relationship assessment
kg.known_entities_iter()
.any(|(_, ek)| ek.relationship == RelationshipState::Friendly)
} else {
// No KG: fall through to ground-truth axis data
relationships_opt
.map(|rels| rels.entries.iter().any(|r| r.trust_level > 3))
.unwrap_or(false)
};
if has_positive_relationship {
return Some(TellCategory::Friendly);
}
@@ -135,12 +148,13 @@ pub fn derive_tell_state(
&MoodState,
Option<&Relationships>,
Option<&RoutineDeviation>,
Option<&KnowledgeGraph>,
&mut DerivedTellState,
),
(With<Npc>, With<ActiveSim>),
>,
) {
for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in
for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, kg_opt, mut tell) in
npcs.iter_mut()
{
tell.category = derive_category(
@@ -150,6 +164,7 @@ pub fn derive_tell_state(
mood_state,
relationships_opt,
deviation_opt,
kg_opt,
);
}
}
@@ -248,6 +263,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
Some(&deviation()),
None,
);
assert_eq!(result, Some(TellCategory::RoutineDeviation));
}
@@ -261,6 +277,7 @@ mod tests {
&mood(NpcMood::Hostile),
None,
Some(&deviation()),
None,
);
assert_eq!(result, Some(TellCategory::RoutineDeviation));
}
@@ -274,6 +291,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None, // No deviation
None,
);
assert_ne!(result, Some(TellCategory::RoutineDeviation));
}
@@ -292,6 +310,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
assert_eq!(result, Some(TellCategory::Nervous));
}
@@ -306,6 +325,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
assert_eq!(result, Some(TellCategory::Guarded));
}
@@ -319,6 +339,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
assert_ne!(result, Some(TellCategory::Nervous));
}
@@ -333,6 +354,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
// Still Guarded (Major secret, priority 4)
assert_eq!(result, Some(TellCategory::Guarded));
@@ -351,6 +373,7 @@ mod tests {
&mood(NpcMood::Hostile),
None,
None,
None,
);
assert_eq!(result, Some(TellCategory::Angry));
}
@@ -364,6 +387,7 @@ mod tests {
&mood(NpcMood::Hostile),
None,
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
@@ -377,6 +401,7 @@ mod tests {
&mood(NpcMood::Anxious), // Not Hostile
None,
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
@@ -391,6 +416,7 @@ mod tests {
&mood(NpcMood::Hostile),
None,
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
@@ -408,6 +434,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
assert_eq!(result, Some(TellCategory::Guarded));
}
@@ -421,6 +448,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
// Moderate secret doesn't trigger Guarded
assert_ne!(result, Some(TellCategory::Guarded));
@@ -439,6 +467,7 @@ mod tests {
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // Trust > 3
None,
None,
);
assert_eq!(result, Some(TellCategory::Friendly));
}
@@ -452,6 +481,7 @@ mod tests {
&mood(NpcMood::Neutral),
Some(&neutral_relationships()), // Trust = 0
None,
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
@@ -465,6 +495,7 @@ mod tests {
&mood(NpcMood::Neutral),
None, // No relationships at all
None,
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
@@ -479,6 +510,7 @@ mod tests {
&mood(NpcMood::Neutral),
Some(&positive_relationships()),
None,
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
@@ -496,6 +528,7 @@ mod tests {
&mood(NpcMood::Neutral),
None,
None,
None,
);
assert_eq!(result, None);
}
@@ -509,6 +542,7 @@ mod tests {
&mood(NpcMood::Anxious), // Not Hostile
None,
None,
None,
);
assert_eq!(result, None);
}
@@ -572,4 +606,172 @@ mod tests {
let state = world.get::<DerivedTellState>(entity).unwrap();
assert_eq!(state.category, None);
}
// -----------------------------------------------------------------------
// D-082: KG-aware Friendly tell
// -----------------------------------------------------------------------
fn kg_with_friendly_entity() -> KnowledgeGraph {
use crate::simulation::movement::TilePosition;
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
kg.set_relationship(&StableId(1), RelationshipState::Friendly);
kg
}
fn kg_with_known_entity() -> KnowledgeGraph {
use crate::simulation::movement::TilePosition;
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
kg.set_relationship(&StableId(1), RelationshipState::Known);
kg
}
#[test]
fn kg_friendly_relationship_triggers_friendly_tell() {
let kg = kg_with_friendly_entity();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None, // Relationships component doesn't matter when KG exists
None,
Some(&kg),
);
assert_eq!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_known_relationship_does_not_trigger_friendly_tell() {
// Known != Friendly — only Friendly relationship triggers the tell
let kg = kg_with_known_entity();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None,
None,
Some(&kg),
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_empty_does_not_trigger_friendly_tell() {
let kg = KnowledgeGraph::new();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None,
None,
Some(&kg),
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn no_kg_falls_through_to_relationships_component() {
// Without KG, the old behavior (Relationships component) should work
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
Some(&positive_relationships()),
None,
None, // No KG
);
assert_eq!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_overrides_relationships_component() {
// KG says no Friendly relationships, even though Relationships
// component has trust > 3 — KG wins (D-082).
let kg = kg_with_known_entity(); // Known, not Friendly
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // ground-truth says Friendly
None,
Some(&kg), // KG says Known (not Friendly)
);
assert_ne!(
result,
Some(TellCategory::Friendly),
"KG should override Relationships component for Friendly tell"
);
}
// -----------------------------------------------------------------------
// Priority chain: verify ordering at boundaries (QA gap closure)
// -----------------------------------------------------------------------
#[test]
fn nervous_beats_angry_when_both_conditions_met() {
// NPC has: Major secret, stress past midpoint (Nervous), AND
// low contentment + Hostile mood (Angry).
// Priority 2 (Nervous) must win over Priority 3 (Angry).
let result = derive_category(
&major_secret(),
&tolerance(80, 100), // stress*2=160 > 100 → Nervous
&contentment(-50), // < -20 → Angry condition met
&mood(NpcMood::Hostile), // Angry condition met
None,
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Nervous),
"Nervous (priority 2) must beat Angry (priority 3)"
);
}
#[test]
fn angry_beats_guarded_when_both_conditions_met() {
// NPC has: Major secret (Guarded), AND low contentment + Hostile (Angry).
// Priority 3 (Angry) must win over Priority 4 (Guarded).
// Note: stress is LOW so Nervous does not trigger.
let result = derive_category(
&major_secret(),
&tolerance(10, 100), // Low stress — not Nervous
&contentment(-30), // < -20 → Angry
&mood(NpcMood::Hostile),
None,
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Angry),
"Angry (priority 3) must beat Guarded (priority 4)"
);
}
#[test]
fn guarded_beats_friendly_when_both_conditions_met() {
// NPC has: Major secret (Guarded), AND high contentment with positive
// relationship (Friendly). Priority 4 (Guarded) must win over Priority 5.
let result = derive_category(
&major_secret(),
&tolerance(0, 100), // Low stress — not Nervous
&contentment(50), // > +20 → Friendly condition met
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // Friendly condition met
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Guarded),
"Guarded (priority 4) must beat Friendly (priority 5)"
);
}
}
+556
View File
@@ -0,0 +1,556 @@
//! Trait modifier system for unprompted disclosure (#173, D-081).
//!
//! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool,
//! Stage 2 (HOW) weights line selection via delivery tags.
//!
//! Traits map to filter predicates via content-authorable YAML config —
//! not hard-coded enum dispatch. Content authors define what each trait
//! does to the candidate pool and which delivery tags it prefers.
use std::collections::BTreeMap;
use bevy_ecs::prelude::*;
use serde::Deserialize;
use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource};
// ---------------------------------------------------------------------------
// YAML-authored trait modifier config
// ---------------------------------------------------------------------------
/// Full trait modifier configuration resource. Loaded from YAML.
///
/// Keys are trait names (lowercase, matching `PersonalityTrait` string
/// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc.
///
/// BTreeMap for deterministic iteration (D-010 principle 4).
#[derive(Resource, Debug, Clone, Default, Deserialize)]
pub struct TraitModifierConfig {
/// Trait name → modifier rules. Trait names are lowercase_snake_case.
#[serde(default)]
pub modifiers: BTreeMap<String, TraitModifier>,
}
/// A single trait's filter and scoring rules.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TraitModifier {
/// Stage 1: candidate pool filter (WHAT gets disclosed).
#[serde(default)]
pub stage1: Stage1Filter,
/// Stage 2: line pool scoring (HOW it's delivered).
#[serde(default)]
pub stage2: Stage2Scoring,
}
/// Stage 1 filter predicates — modify the disclosure candidate pool.
///
/// Applied per-fact during candidate selection in `DisclosureCandidates`
/// (#551). Multiple traits compose additively: if any trait includes a
/// candidate that would otherwise be excluded, it's included.
///
/// Default values (all false/None) produce no modification to the
/// baseline filter, which requires KnowsOf minimum confidence and
/// includes all source types.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Stage1Filter {
/// Minimum confidence to enter the disclosure pool.
/// Parsed at load time: "suspects", "knows_of", "knows_details", "direct".
/// None = use system default (KnowsOf).
#[serde(default)]
pub min_confidence: Option<String>,
/// If true, exclude facts with `ToldBy` source (won't pass on rumors).
/// Cautious trait behavior.
#[serde(default)]
pub exclude_told_by: bool,
/// If true, exclude facts linked to entities with trust_level >= Real
/// in NPC Relationships. Loyal trait behavior.
#[serde(default)]
pub exclude_high_trust_entities: bool,
/// If true, override the witness inhibition gate. Talkative trait behavior.
#[serde(default)]
pub override_witness_inhibition: bool,
}
/// Stage 2 scoring — influence line selection weighting.
///
/// Delivery tags in `IndexedDialogueLine.tags` are matched against
/// the NPC's trait-derived preferred tags. Lines with matching tags
/// receive a scoring bonus during Layer 4 selection.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Stage2Scoring {
/// Preferred delivery tags for line selection weighting.
/// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`.
/// Lines with matching tags receive a scoring bonus.
#[serde(default)]
pub delivery_tags: Vec<String>,
}
// ---------------------------------------------------------------------------
// Filter predicate evaluation
// ---------------------------------------------------------------------------
impl Stage1Filter {
/// Parse the min_confidence string into a `KnowledgeConfidence` value.
/// Returns `None` (use system default) for unparseable or absent values.
pub fn min_confidence_level(&self) -> Option<KnowledgeConfidence> {
self.min_confidence.as_deref().and_then(parse_confidence)
}
/// Evaluate whether a fact passes this trait's Stage 1 filter.
///
/// Returns `false` if the fact should be excluded by this trait.
/// The caller (#551) composes multiple trait filters: a fact is
/// included if it passes the composite filter.
pub fn allows_fact(&self, fact: &FactKnowledge) -> bool {
// Check minimum confidence
if let Some(min) = self.min_confidence_level() {
if fact.confidence < min {
return false;
}
}
// Exclude ToldBy-source facts (Cautious behavior)
if self.exclude_told_by {
if matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
return false;
}
}
true
}
}
impl Stage2Scoring {
/// Check if a line's tags contain any of this trait's preferred delivery tags.
/// Returns the number of matching tags (0 = no bonus).
pub fn tag_match_count(&self, line_tags: &[String]) -> usize {
self.delivery_tags
.iter()
.filter(|dt| line_tags.contains(dt))
.count()
}
/// Check if a line has at least one matching delivery tag.
pub fn has_matching_tag(&self, line_tags: &[String]) -> bool {
self.tag_match_count(line_tags) > 0
}
}
impl TraitModifierConfig {
/// Look up the modifier for a trait by name.
pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> {
self.modifiers.get(trait_name)
}
/// Collect all Stage 2 delivery tags for a set of trait names.
/// Returns a deduplicated, sorted list for deterministic matching.
pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec<String> {
let mut tags: Vec<String> = trait_names
.iter()
.filter_map(|name| self.modifiers.get(name.as_str()))
.flat_map(|m| m.stage2.delivery_tags.iter().cloned())
.collect();
tags.sort();
tags.dedup();
tags
}
/// Check if any trait in the set overrides witness inhibition.
pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool {
trait_names.iter().any(|name| {
self.modifiers
.get(name.as_str())
.is_some_and(|m| m.stage1.override_witness_inhibition)
})
}
/// Check if any trait in the set excludes high-trust entity facts.
pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool {
trait_names.iter().any(|name| {
self.modifiers
.get(name.as_str())
.is_some_and(|m| m.stage1.exclude_high_trust_entities)
})
}
/// Get the most permissive (lowest) min_confidence across all traits.
/// Returns None if no traits specify a minimum (use system default).
pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option<KnowledgeConfidence> {
trait_names
.iter()
.filter_map(|name| self.modifiers.get(name.as_str()))
.filter_map(|m| m.stage1.min_confidence_level())
.min()
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Parse a confidence string from YAML config to enum value.
/// Delegates to `KnowledgeConfidence::try_from` (which accepts both
/// camelCase and underscore forms) rather than duplicating the match.
fn parse_confidence(s: &str) -> Option<KnowledgeConfidence> {
KnowledgeConfidence::try_from(s).map_err(|e| {
tracing::warn!("Unknown confidence level in trait config: {}", e);
}).ok()
}
/// Convert a `PersonalityTrait` to its lowercase YAML key.
/// Used to look up trait modifiers from the config.
pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str {
match trait_val {
super::PersonalityTrait::Cautious => "cautious",
super::PersonalityTrait::Bold => "bold",
super::PersonalityTrait::Honest => "honest",
super::PersonalityTrait::Deceptive => "deceptive",
super::PersonalityTrait::Compassionate => "compassionate",
super::PersonalityTrait::Ruthless => "ruthless",
super::PersonalityTrait::Curious => "curious",
super::PersonalityTrait::Incurious => "incurious",
super::PersonalityTrait::Social => "social",
super::PersonalityTrait::Reclusive => "reclusive",
}
}
/// Convert an NPC's personality trait list to YAML config keys.
pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec<String> {
traits.iter().map(|t| trait_to_key(t).to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge {
FactKnowledge {
confidence,
source,
state: crate::knowledge::types::KnowledgeState::Active,
acquired_tick: 100,
disclosure_blocked: false,
}
}
fn cautious_config() -> TraitModifierConfig {
let yaml = r#"
modifiers:
cautious:
stage1:
min_confidence: "knows_details"
exclude_told_by: true
stage2:
delivery_tags: ["cautious_delivery"]
gossipy:
stage1:
min_confidence: "suspects"
stage2:
delivery_tags: ["gossip_delivery", "casual_delivery"]
loyal:
stage1:
exclude_high_trust_entities: true
stage2:
delivery_tags: ["professional_delivery"]
talkative:
stage1:
override_witness_inhibition: true
min_confidence: "suspects"
stage2:
delivery_tags: ["casual_delivery", "gossip_delivery"]
"#;
serde_yaml::from_str(yaml).expect("valid trait config YAML")
}
#[test]
fn parse_config_from_yaml() {
let config = cautious_config();
assert_eq!(config.modifiers.len(), 4);
assert!(config.modifiers.contains_key("cautious"));
assert!(config.modifiers.contains_key("gossipy"));
assert!(config.modifiers.contains_key("loyal"));
assert!(config.modifiers.contains_key("talkative"));
}
#[test]
fn cautious_excludes_low_confidence() {
let config = cautious_config();
let cautious = &config.modifiers["cautious"].stage1;
let suspects_fact = make_fact(
KnowledgeConfidence::Suspects,
KnowledgeSource::DirectObservation { tick: 50 },
);
let details_fact = make_fact(
KnowledgeConfidence::KnowsDetails,
KnowledgeSource::DirectObservation { tick: 50 },
);
assert!(!cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects");
assert!(cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails");
}
#[test]
fn cautious_excludes_told_by() {
let config = cautious_config();
let cautious = &config.modifiers["cautious"].stage1;
let told_fact = make_fact(
KnowledgeConfidence::KnowsDetails,
KnowledgeSource::ToldBy {
source_id: crate::knowledge::types::StableId(42),
tick: 50,
},
);
assert!(!cautious.allows_fact(&told_fact), "Cautious excludes ToldBy");
}
#[test]
fn gossipy_includes_suspects() {
let config = cautious_config();
let gossipy = &config.modifiers["gossipy"].stage1;
let suspects_fact = make_fact(
KnowledgeConfidence::Suspects,
KnowledgeSource::DirectObservation { tick: 50 },
);
assert!(gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects");
}
#[test]
fn talkative_overrides_witness_inhibition() {
let config = cautious_config();
let traits = vec!["talkative".to_string()];
assert!(config.any_overrides_witness_inhibition(&traits));
let traits = vec!["cautious".to_string()];
assert!(!config.any_overrides_witness_inhibition(&traits));
}
#[test]
fn loyal_excludes_high_trust() {
let config = cautious_config();
let traits = vec!["loyal".to_string()];
assert!(config.any_excludes_high_trust(&traits));
let traits = vec!["gossipy".to_string()];
assert!(!config.any_excludes_high_trust(&traits));
}
#[test]
fn lowest_min_confidence_picks_most_permissive() {
let config = cautious_config();
// Gossipy (suspects) + Cautious (knows_details) → suspects wins
let traits = vec!["gossipy".to_string(), "cautious".to_string()];
assert_eq!(
config.lowest_min_confidence(&traits),
Some(KnowledgeConfidence::Suspects)
);
}
#[test]
fn delivery_tags_deduped_and_sorted() {
let config = cautious_config();
// Gossipy + Talkative both have "casual_delivery" and "gossip_delivery"
let traits = vec!["gossipy".to_string(), "talkative".to_string()];
let tags = config.delivery_tags_for(&traits);
assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]);
}
#[test]
fn stage2_tag_matching() {
let config = cautious_config();
let scoring = &config.modifiers["cautious"].stage2;
let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()];
assert!(scoring.has_matching_tag(&line_tags));
assert_eq!(scoring.tag_match_count(&line_tags), 1);
let no_match_tags = vec!["gossip_delivery".to_string()];
assert!(!scoring.has_matching_tag(&no_match_tags));
}
#[test]
fn unknown_trait_returns_none() {
let config = cautious_config();
assert!(config.modifier_for("unknown_trait").is_none());
}
#[test]
fn trait_to_key_roundtrip() {
use super::super::PersonalityTrait;
assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious");
assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold");
assert_eq!(trait_to_key(&PersonalityTrait::Social), "social");
}
#[test]
fn traits_to_keys_conversion() {
use super::super::PersonalityTrait;
let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social];
let keys = traits_to_keys(&traits);
assert_eq!(keys, vec!["cautious", "social"]);
}
#[test]
fn empty_config_is_no_op() {
let config = TraitModifierConfig::default();
let traits = vec!["cautious".to_string()];
assert!(!config.any_overrides_witness_inhibition(&traits));
assert!(!config.any_excludes_high_trust(&traits));
assert_eq!(config.lowest_min_confidence(&traits), None);
assert!(config.delivery_tags_for(&traits).is_empty());
}
#[test]
fn default_filter_allows_everything() {
let filter = Stage1Filter::default();
let fact = make_fact(
KnowledgeConfidence::Suspects,
KnowledgeSource::ToldBy {
source_id: crate::knowledge::types::StableId(1),
tick: 10,
},
);
assert!(filter.allows_fact(&fact), "Default filter allows all facts");
}
#[test]
fn parse_confidence_values() {
assert_eq!(parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects));
assert_eq!(parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf));
assert_eq!(parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails));
assert_eq!(parse_confidence("direct"), Some(KnowledgeConfidence::Direct));
assert_eq!(parse_confidence("invalid"), None);
}
// --- Coverage gap closure tests ---
#[test]
fn cautious_excludes_knows_of_below_threshold() {
// Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails,
// so a KnowsOf fact must be excluded (not just Suspects).
let config = cautious_config();
let cautious = &config.modifiers["cautious"].stage1;
let knows_of_fact = make_fact(
KnowledgeConfidence::KnowsOf,
KnowledgeSource::DirectObservation { tick: 50 },
);
assert!(
!cautious.allows_fact(&knows_of_fact),
"Cautious should exclude KnowsOf (below knows_details threshold)"
);
}
#[test]
fn cautious_allows_direct_confidence() {
// Direct > KnowsDetails, so Direct passes cautious min_confidence.
let config = cautious_config();
let cautious = &config.modifiers["cautious"].stage1;
let direct_fact = make_fact(
KnowledgeConfidence::Direct,
KnowledgeSource::DirectObservation { tick: 50 },
);
assert!(
cautious.allows_fact(&direct_fact),
"Cautious should allow Direct confidence (above threshold)"
);
}
#[test]
fn gossipy_allows_all_confidence_levels() {
// Gossipy min_confidence is "suspects" — all confidence levels pass.
let config = cautious_config();
let gossipy = &config.modifiers["gossipy"].stage1;
for (confidence, label) in [
(KnowledgeConfidence::Suspects, "Suspects"),
(KnowledgeConfidence::KnowsOf, "KnowsOf"),
(KnowledgeConfidence::KnowsDetails, "KnowsDetails"),
(KnowledgeConfidence::Direct, "Direct"),
] {
let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 });
assert!(
gossipy.allows_fact(&fact),
"Gossipy should allow {} confidence",
label
);
}
}
#[test]
fn all_personality_traits_map_to_unique_keys() {
use super::super::PersonalityTrait;
let all_traits = vec![
PersonalityTrait::Cautious,
PersonalityTrait::Bold,
PersonalityTrait::Honest,
PersonalityTrait::Deceptive,
PersonalityTrait::Compassionate,
PersonalityTrait::Ruthless,
PersonalityTrait::Curious,
PersonalityTrait::Incurious,
PersonalityTrait::Social,
PersonalityTrait::Reclusive,
];
let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect();
// All 10 traits produce a non-empty key
for (trait_, key) in all_traits.iter().zip(keys.iter()) {
assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_);
}
// All keys are unique (no two traits share a key)
let mut sorted = keys.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
all_traits.len(),
"All personality traits must map to distinct keys"
);
}
#[test]
fn stage2_multiple_tag_matches_counts_correctly() {
// When a line has two matching delivery tags, tag_match_count returns 2.
let config = cautious_config();
// Talkative has: ["casual_delivery", "gossip_delivery"]
let talkative_scoring = &config.modifiers["talkative"].stage2;
let line_tags = vec![
"casual_delivery".to_string(),
"gossip_delivery".to_string(),
"unrelated_tag".to_string(),
];
assert_eq!(
talkative_scoring.tag_match_count(&line_tags),
2,
"Both delivery tags should match"
);
assert!(talkative_scoring.has_matching_tag(&line_tags));
}
#[test]
fn gossipy_does_not_exclude_told_by() {
// Gossipy has no exclude_told_by restriction — it should pass ToldBy facts.
let config = cautious_config();
let gossipy = &config.modifiers["gossipy"].stage1;
let told_fact = make_fact(
KnowledgeConfidence::Suspects,
KnowledgeSource::ToldBy {
source_id: crate::knowledge::types::StableId(5),
tick: 10,
},
);
assert!(
gossipy.allows_fact(&told_fact),
"Gossipy should not exclude ToldBy-source facts"
);
}
}
+1
View File
@@ -208,6 +208,7 @@ mod tests {
world.insert_resource(WalkabilityMap::new(32, 32, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<ObservationEventQueue>();
world.init_resource::<VisibilityGeometry>();
+3
View File
@@ -505,6 +505,7 @@ fn knowledge_without_position_not_shown() {
state: crate::knowledge::KnowledgeState::Active,
relationship: RelationshipState::PersonOfInterest,
known_attributes: std::collections::BTreeMap::new(),
contradicted_claim: None,
},
);
@@ -800,6 +801,7 @@ fn phase2_no_confront_without_knows_details() {
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
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,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
);
+2
View File
@@ -127,6 +127,7 @@ pub fn check_contraband_scan(
source: KnowledgeSource::DirectObservation { tick: time.tick },
state: KnowledgeState::Active,
acquired_tick: time.tick,
disclosure_blocked: false,
},
);
@@ -463,6 +464,7 @@ mod tests {
source: KnowledgeSource::DirectObservation { tick: 0 },
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
);
+124 -2
View File
@@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::types::KnowledgeGrant;
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::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
@@ -422,6 +426,7 @@ pub fn process_talk_interaction(
time: Res<SimulationTime>,
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
content_registry: Res<ContentEntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
@@ -442,6 +447,7 @@ pub fn process_talk_interaction(
Option<&mut InteractionMemory>,
Option<&NpcName>,
Option<&NpcColorIndex>,
Option<&KnowledgeGraph>,
)>,
) {
let Some(line_pool) = line_pool else {
@@ -462,8 +468,8 @@ pub fn process_talk_interaction(
let target = talk_request.target;
// Look up NPC dialogue profile, mood, interaction history, name, and color (#325)
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
// 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, npc_kg_opt)) =
npc_query.get_mut(target)
else {
tracing::debug!(
@@ -541,6 +547,19 @@ pub fn process_talk_interaction(
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
if let Some(prev) = active_dialogue_opt {
event_queue.push(crate::knowledge::KnowledgeEvent {
@@ -594,6 +613,107 @@ pub fn process_talk_interaction(
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,
},
});
}
// Entity grants have no "NPC knows this entity" guardrail (unlike Fact
// grants above). This is intentional per D-079: entity grants introduce
// NEW knowledge about an entity the NPC is talking about — the NPC
// doesn't need to "know" the entity in their own KG to reference it
// in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG.
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)
// ---------------------------------------------------------------------------
@@ -1330,6 +1450,7 @@ mod tests {
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world.init_resource::<ContentEntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
@@ -1828,6 +1949,7 @@ mod tests {
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
let npc = world.spawn_empty().id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
+17
View File
@@ -14,8 +14,11 @@ pub mod inventory;
pub mod listening;
pub mod monologue;
pub mod movement;
pub mod npc_knowledge_transfer;
pub mod path_follow;
pub mod pathfinding;
pub mod poi;
pub mod poi_discovery;
pub mod rng;
pub mod sound;
pub mod spatial;
@@ -42,6 +45,15 @@ impl Plugin for SimulationPlugin {
.init_resource::<spatial::NaiveSpatialIndex>()
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
// transfer_npc_knowledge reads RelationshipGraph (also init by NpcPlugin) and
// KnowledgeEventQueue (also init by KnowledgePlugin).
// Init here so SimulationPlugin works standalone in tests without those plugins.
.init_resource::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.add_systems(
Update,
(
@@ -58,9 +70,14 @@ impl Plugin for SimulationPlugin {
conversation::run_npc_conversations
.after(movement::validate_movement)
.before(sound::collect_sound_events),
npc_knowledge_transfer::transfer_npc_knowledge
.after(conversation::run_npc_conversations),
sound::collect_sound_events
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
poi_discovery::discover_pois
.after(crate::perception::observer::compute_visibility_geometry)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+411 -1
View File
@@ -15,6 +15,8 @@ use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::content::ContentStoreResource;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
@@ -455,7 +457,10 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri
"hear_sound" => HEAR_SOUND_LINES,
"witness_interaction" => WITNESS_INTERACTION_LINES,
"post_conversation" => POST_CONVERSATION_LINES,
_ => OBSERVE_NPC_LINES,
unknown => {
tracing::warn!("select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc", unknown);
OBSERVE_NPC_LINES
}
};
let index = rng.random_range(0..lines.len());
(lines[index].0.to_string(), lines[index].1.to_string())
@@ -738,6 +743,102 @@ pub fn trigger_monologue(
);
}
// ---------------------------------------------------------------------------
// Contradiction monologue trigger (#550, D-083)
// ---------------------------------------------------------------------------
/// Hardcoded v0.1 contradiction monologue template lines.
/// Placeholders: `{source}` = NPC who gave false info, `{subject}` = NPC whose
/// position was contradicted. Hand-authored Sera/Kael lines come from copy team (#552).
const CONTRADICTION_TEMPLATE_LINES: &[&str] = &[
"{source} told me where {subject} would be. They were wrong.",
"Something's off. {source} sent me the wrong way for {subject}.",
"{subject} wasn't where {source} said. Was that a mistake — or a lie?",
];
/// Resolve a display name from a StableId via EntityRegistry + NpcName query.
/// Falls back to `"#<id>"` when the entity is not registered or has no NpcName.
/// Called from tests and available for future triggers needing live name resolution.
#[allow(dead_code)]
pub(crate) fn resolve_name(
stable_id: crate::knowledge::types::StableId,
registry: &EntityRegistry,
names: &Query<&NpcName>,
) -> String {
registry
.to_entity(&stable_id)
.and_then(|e| names.get(e).ok())
.map(|n| n.0.clone())
.unwrap_or_else(|| format!("#{}", stable_id.0))
}
/// Contradiction monologue trigger (#550, D-083).
///
/// Drains ContradictionDetectedQueue once per tick. On first contradiction,
/// fires a monologue line with the pre-resolved source and subject names.
/// Bypasses normal cooldown (event-driven), but updates last_fired_tick.
///
/// Relationship shift (PersonOfInterest) is already done by `process_knowledge_events`
/// before this system runs. This system is a pure consumer of the resolved strings.
///
/// System ordering: after trigger_event_monologue, before compute_observer_snapshot.
pub fn process_contradiction_monologue(
time: Res<SimulationTime>,
_registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
_npc_names: Query<&NpcName>,
mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With<PlayerCharacter>>,
) {
// _registry and _npc_names are available for future triggers needing live name resolution
// via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload.
if contradiction_queue.is_empty() {
return;
}
let Ok((mut buffer, mut state)) = player_query.single_mut() else {
contradiction_queue.drain();
return;
};
// Don't override a higher-priority monologue that already fired this tick.
if buffer.event.is_some() {
contradiction_queue.drain();
return;
}
let events = contradiction_queue.drain();
// Process only the first contradiction per tick (first-in wins).
let Some(event) = events.into_iter().next() else {
return;
};
let template_idx = rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
let text = CONTRADICTION_TEMPLATE_LINES[template_idx]
.replace("{source}", &event.source_display_name)
.replace("{subject}", &event.subject_display_name);
let id = format!("contradiction_{:02}", template_idx + 1);
buffer.event = Some(MonologueEvent {
id: id.clone(),
text,
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.insert(id.clone());
state.last_fired_tick = time.tick;
tracing::debug!(
"Contradiction monologue fired: id={}, source={}, subject={}, tick={}",
id,
event.source_display_name,
event.subject_display_name,
time.tick,
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2183,4 +2284,313 @@ mod tests {
"last_observation_tick should track highest event tick"
);
}
// -----------------------------------------------------------------------
// process_contradiction_monologue tests (#550, D-083)
// -----------------------------------------------------------------------
fn setup_contradiction_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.insert_resource(ContradictionDetectedQueue::default());
world.init_resource::<EntityRegistry>();
world
}
fn spawn_contradiction_player(world: &mut bevy_ecs::world::World) -> bevy_ecs::entity::Entity {
world.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
)).id()
}
#[test]
fn contradiction_monologue_fires_with_resolved_names() {
// ContradictionDetectedQueue has an event with pre-resolved names.
// process_contradiction_monologue should fire a monologue line containing both names.
let mut world = setup_contradiction_world();
let player = spawn_contradiction_player(&mut world);
// Advance past tick 0 so last_fired_tick=0 cooldown doesn't block
world.resource_mut::<SimulationTime>().tick = 500;
// Populate the contradiction queue with pre-resolved names
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: player,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(5, 5, 0),
observed_position: TilePosition::new(10, 10, 0),
detected_tick: 500,
},
source_display_name: "Sera".to_string(),
subject_display_name: "Kael".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some(), "contradiction monologue should fire");
let event = buf.event.as_ref().unwrap();
assert!(
event.text.contains("Sera"),
"monologue text should mention the source: got '{}'",
event.text
);
assert!(
event.text.contains("Kael"),
"monologue text should mention the subject: got '{}'",
event.text
);
// ID should be contradiction_01 / _02 / _03
assert!(
event.id.starts_with("contradiction_"),
"monologue id should be contradiction_NN: got '{}'",
event.id
);
}
#[test]
fn contradiction_monologue_does_not_override_existing_buffer() {
// If MonologueBuffer already has an event, contradiction must not clobber it.
let mut world = setup_contradiction_world();
let player = spawn_contradiction_player(&mut world);
// Pre-fill buffer with a higher-priority monologue
world.get_mut::<MonologueBuffer>(player).unwrap().set(MonologueEvent {
id: "prior_event".to_string(),
text: "Something already fired.".to_string(),
duration_seconds: 5.0,
});
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: player,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(5, 5, 0),
observed_position: TilePosition::new(10, 10, 0),
detected_tick: 100,
},
source_display_name: "Sera".to_string(),
subject_display_name: "Kael".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world);
// Buffer should still have the prior event
let buf = world.get::<MonologueBuffer>(player).unwrap();
let event = buf.event.as_ref().unwrap();
assert_eq!(event.id, "prior_event", "prior monologue should not be overridden");
// Queue should have been drained regardless
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be drained even when buffer is occupied"
);
}
#[test]
fn contradiction_monologue_drains_queue_when_no_player() {
// If there's no player entity, the queue must still be drained (no panic).
let mut world = setup_contradiction_world();
// No player spawned
let fake_world_entity = world.spawn_empty().id();
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: fake_world_entity,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(1, 1, 0),
observed_position: TilePosition::new(5, 5, 0),
detected_tick: 100,
},
source_display_name: "Unknown".to_string(),
subject_display_name: "Unknown".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world); // should not panic
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be drained even without a player"
);
let _ = fake_world_entity; // suppress unused warning
}
#[test]
fn the_friend_arc_integration_full_sequence() {
// THE FRIEND arc integration test (#550, D-083).
//
// Tick T1: KnowledgeGranted creates ToldBy entry (Kael at (5,5), told by Sera)
// Tick T2: DirectObservation fires ContradictionDetected (Kael at (10,10))
// → relationship shift: Sera becomes PersonOfInterest in player's KG
// → ContradictionDetectedEvent pushed with resolved names
// Tick T3: process_contradiction_monologue fires monologue with Sera/Kael names
//
// Tests the full D-083 event chain end-to-end.
use crate::knowledge::{
EntityRegistry, KnowledgeGraph, KnowledgeEventQueue, KnowledgeEventType,
};
use crate::knowledge::events::{process_knowledge_events, KnowledgeEvent};
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::{
EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
RelationshipState, StableId,
};
use std::collections::BTreeMap;
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(7));
world.init_resource::<ContradictionDetectedQueue>();
// Registry
let mut registry = EntityRegistry::new(0);
// Spawn NPCs with NpcName components
let sera_entity = world.spawn(NpcName("Sera".to_string())).id();
let kael_entity = world.spawn((
NpcName("Kael".to_string()),
TilePosition::new(10, 10, 0),
)).id();
let sera_sid = registry.register(sera_entity);
let kael_sid = registry.register(kael_entity);
// Spawn player with KnowledgeGraph
let mut player_kg = KnowledgeGraph::new();
// Tick T1: Pre-populate KG with ToldBy entry — Sera told us Kael is at (5,5)
player_kg.entities.insert(
kael_sid,
EntityKnowledge {
last_known_position: Some(TilePosition::new(5, 5, 0)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: sera_sid,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let player = world.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
player_kg,
StableEntityId(StableId(999)),
)).id();
registry.register(player);
world.insert_resource(registry);
// Tick T2: Push DirectObservation of Kael at (10,10) — contradicts (5,5)
let tick_t2 = 200u64;
world.resource_mut::<SimulationTime>().tick = tick_t2;
let mut ke_queue = KnowledgeEventQueue::default();
ke_queue.push(KnowledgeEvent {
observer: player,
tick: tick_t2,
event_type: KnowledgeEventType::DirectObservation {
target: kael_entity,
position: TilePosition::new(10, 10, 0),
},
});
world.insert_resource(ke_queue);
let mut schedule_t2 = bevy_ecs::schedule::Schedule::default();
schedule_t2.add_systems(process_knowledge_events);
schedule_t2.run(&mut world);
// Verify contradiction was detected and queued
{
let cq = world.resource::<ContradictionDetectedQueue>();
assert!(!cq.is_empty(), "contradiction should be in queue after T2");
}
// Verify Sera became PersonOfInterest in player's KG
{
let kg = world.get::<KnowledgeGraph>(player).unwrap();
let sera_entry = kg.entity_knowledge(&sera_sid);
assert!(
sera_entry.is_some(),
"Sera should have an entry in player's KG after contradiction"
);
assert_eq!(
sera_entry.unwrap().relationship,
RelationshipState::PersonOfInterest,
"Sera should be PersonOfInterest after giving false location info"
);
}
// Verify ContradictionDetectedEvent has correct pre-resolved names
{
// Drain to inspect event contents, then re-push for the monologue consumer.
let mut events = world.resource_mut::<ContradictionDetectedQueue>().drain();
assert_eq!(events.len(), 1, "should have exactly one contradiction event");
let event = &events[0];
assert_eq!(event.source_display_name, "Sera");
assert_eq!(event.subject_display_name, "Kael");
// Re-push so process_contradiction_monologue can consume it on T3.
let event = events.remove(0);
world.resource_mut::<ContradictionDetectedQueue>().push(event);
}
// Tick T3: Run process_contradiction_monologue
world.resource_mut::<SimulationTime>().tick = 300;
let mut schedule_t3 = bevy_ecs::schedule::Schedule::default();
schedule_t3.add_systems(process_contradiction_monologue);
schedule_t3.run(&mut world);
// Verify monologue fired with both names
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some(), "monologue should fire on T3");
let mono_event = buf.event.as_ref().unwrap();
assert!(
mono_event.text.contains("Sera"),
"monologue text should mention Sera: '{}'",
mono_event.text
);
assert!(
mono_event.text.contains("Kael"),
"monologue text should mention Kael: '{}'",
mono_event.text
);
// Verify queue is drained after monologue fires
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be empty after monologue consumed the event"
);
}
}
@@ -0,0 +1,842 @@
//! NPC-to-NPC knowledge transfer system (D-080, ticket #548).
//!
//! When an NPC-to-NPC conversation starts (`Added<NpcConversation>`), this system
//! transfers a sample of the speaker's KG entries to the listener (ToldBy source).
//! Transfer eligibility and volume are gated by the trust level between the two NPCs
//! from `RelationshipGraph`. The confidence cap (max KnowsOf) ensures information
//! degrades as it propagates through social networks.
//!
//! Player overhear: if the player is within VOICE_RANGE_TILES of the conversation,
//! they gain entity-level knowledge about both NPCs at Suspects confidence
//! (Heard source). This models ambient social information gathering.
//!
//! Closes Q-024: NPC-to-NPC propagation rate.
use bevy_ecs::prelude::*;
use rand::Rng;
use std::collections::BTreeMap;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::{
EntityKnowledge, FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
RelationshipState, SoundRange, StableId,
};
use crate::knowledge::{
KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, ProcessedEntityGrant,
ProcessedKnowledgeGrant,
};
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Range within which player can overhear NPC-to-NPC knowledge exchange.
/// Matches VOICE_RANGE_TILES in conversation.rs (D-018 Medium = 8 tiles).
const VOICE_RANGE_TILES: u32 = 8;
// ---------------------------------------------------------------------------
// Trust tier (NPC-to-NPC, D-080)
// ---------------------------------------------------------------------------
/// Trust tier for NPC-to-NPC knowledge transfer.
/// Derived from `RelationshipEdge.trust` (i8 in -10..+10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NpcTransferTier {
/// trust < 0: no knowledge flows.
None,
/// trust 0..=2: Active facts at KnowsOf+ confidence only.
Surface,
/// trust 3..=6: Active facts at any confidence + entity observations.
Real,
/// trust 7..=10: All Active entries (facts at any confidence + all entities).
Secret,
}
/// Map a raw trust score to the NPC transfer tier (D-080 boundaries).
fn trust_to_tier(trust: i8) -> NpcTransferTier {
if trust < 0 {
NpcTransferTier::None
} else if trust >= 7 {
NpcTransferTier::Secret
} else if trust >= 3 {
NpcTransferTier::Real
} else {
NpcTransferTier::Surface
}
}
// ---------------------------------------------------------------------------
// Transfer candidate pool
// ---------------------------------------------------------------------------
/// Tagged entry in the transfer candidate pool.
/// Used to sort facts and entity entries by recency before drawing.
enum TransferCandidate {
Fact {
id: FactId,
confidence: KnowledgeConfidence,
acquired_tick: u64,
},
Entity {
id: StableId,
ek: EntityKnowledge,
},
}
impl TransferCandidate {
/// Sort key: most recently updated entries are preferred.
fn sort_key(&self) -> u64 {
match self {
Self::Fact { acquired_tick, .. } => *acquired_tick,
Self::Entity { ek, .. } => ek.last_updated_tick,
}
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// System: transfer NPC knowledge at conversation start (D-080).
///
/// Fires once per conversation (triggered by `Added<NpcConversation>`).
/// Reads the trust level between the two NPCs from `RelationshipGraph` and
/// transfers a sample (13 entries) of the speaker's eligible KG entries to
/// the listener's KG with `ToldBy` source and confidence capped at `KnowsOf`.
///
/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge
/// about both NPCs at `Suspects` confidence (`Heard` source).
///
/// System ordering: after(run_npc_conversations).
#[allow(clippy::too_many_arguments)]
pub fn transfer_npc_knowledge(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
relationship_graph: Res<RelationshipGraph>,
mut event_queue: ResMut<KnowledgeEventQueue>,
// NPCs that just started a conversation — Added fires once per conversation.
new_conv_query: Query<
(Entity, &NpcConversation, &TilePosition, Option<&StableEntityId>),
(With<Npc>, With<ActiveSim>, Added<NpcConversation>),
>,
// Read-only StableEntityId on NPC partner (distinct query, no KG conflict).
partner_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
// Mutable KG access — get_many_mut for dual-entity borrow safety.
mut kg_query: Query<&mut KnowledgeGraph>,
// Player position for overhear radius check.
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
) {
let tick = time.tick;
for (speaker_entity, conv, speaker_pos, speaker_sid_opt) in new_conv_query.iter() {
let partner_entity = conv.partner;
// --- Resolve stable IDs ---
let speaker_sid = match speaker_sid_opt.map(|s| s.0) {
Some(sid) => sid,
None => {
tracing::debug!(
"NPC transfer: speaker {:?} has no StableEntityId, skipping",
speaker_entity
);
continue;
}
};
let partner_sid = match partner_sid_query
.get(partner_entity)
.ok()
.and_then(|opt| opt.map(|s| s.0))
{
Some(sid) => sid,
None => {
tracing::debug!(
"NPC transfer: partner {:?} has no StableEntityId, skipping",
partner_entity
);
continue;
}
};
// --- Trust level → transfer tier ---
//
// Use the speaker's trust in the listener: the speaker decides what to share
// based on how much they trust this particular person. Defaults to 0 (Surface)
// when no relationship edge exists — strangers can still overhear ambient facts.
let trust = relationship_graph
.get_relationship(&speaker_sid, &partner_sid)
.map(|e| e.trust)
.unwrap_or(0);
let tier = trust_to_tier(trust);
if tier == NpcTransferTier::None {
tracing::debug!(
"NPC transfer: trust={} (None tier) between {:?} ↔ {:?}, skipping",
trust,
speaker_entity,
partner_entity
);
// Still do player overhear (NPCs are audibly talking even if not exchanging info)
emit_player_overhear_grants(
&player_query,
speaker_pos,
tick,
speaker_sid,
partner_sid,
&mut event_queue,
);
continue;
}
// --- Dual-mutable KG access ---
// Transfer is one-directional per conversation tick: speaker → partner.
// If both participants are Active NPCs, each fires as "speaker" in
// separate conversation pairs (run_npc_conversations creates symmetric
// pairs), so both directions are covered across two iterations.
let Ok([speaker_kg, mut partner_kg]) =
kg_query.get_many_mut([speaker_entity, partner_entity])
else {
tracing::debug!(
"NPC transfer: couldn't get KGs for {:?} / {:?}, skipping",
speaker_entity,
partner_entity
);
continue;
};
// --- Build candidate pool from speaker's KG ---
let mut candidates: Vec<TransferCandidate> = Vec::new();
// Facts: always eligible (filtered by tier and disclosure_blocked)
for (fid, fk) in speaker_kg.facts.iter() {
if fk.disclosure_blocked || fk.state != KnowledgeState::Active {
continue;
}
let eligible = match tier {
NpcTransferTier::Surface => fk.confidence >= KnowledgeConfidence::KnowsOf,
NpcTransferTier::Real | NpcTransferTier::Secret => true,
NpcTransferTier::None => unreachable!("None tier handled above"),
};
if eligible {
candidates.push(TransferCandidate::Fact {
id: fid.clone(),
confidence: fk.confidence,
acquired_tick: fk.acquired_tick,
});
}
}
// Entity observations: Real and Secret tiers only
if tier == NpcTransferTier::Real || tier == NpcTransferTier::Secret {
for (sid, ek) in speaker_kg.entities.iter() {
if ek.state != KnowledgeState::Active {
continue;
}
candidates.push(TransferCandidate::Entity {
id: *sid,
ek: ek.clone(),
});
}
}
if candidates.is_empty() {
tracing::debug!(
"NPC transfer: no eligible entries in speaker {:?} KG at {:?} tier",
speaker_entity,
tier
);
emit_player_overhear_grants(
&player_query,
speaker_pos,
tick,
speaker_sid,
partner_sid,
&mut event_queue,
);
continue;
}
// Sort by most recently updated (deterministic: descending tick, stable by BTreeMap key order)
candidates.sort_by(|a, b| b.sort_key().cmp(&a.sort_key()));
// Take top 13 entries by recency (random count, deterministic selection).
// The random element is HOW MANY facts transfer, not WHICH ones.
let count = rng.rng.random_range(1u32..=3u32) as usize;
let count = count.min(candidates.len());
tracing::debug!(
"NPC transfer: {:?} → {:?}, tier={:?}, trust={}, drawing {}/{}",
speaker_entity,
partner_entity,
tier,
trust,
count,
candidates.len(),
);
// --- Apply transfers to partner's KG ---
for candidate in candidates.into_iter().take(count) {
match candidate {
TransferCandidate::Fact { id, confidence, .. } => {
// Confidence cap: speaker's knowledge degrades to at most KnowsOf.
let capped = confidence.min(KnowledgeConfidence::KnowsOf);
// Upgrade-only: never downgrade existing knowledge.
let should_write = partner_kg
.facts
.get(&id)
.map(|existing| existing.confidence < capped)
.unwrap_or(true);
if should_write {
partner_kg.facts.insert(
id.clone(),
FactKnowledge {
confidence: capped,
source: KnowledgeSource::ToldBy {
source_id: speaker_sid,
tick,
},
state: KnowledgeState::Active,
acquired_tick: tick,
disclosure_blocked: false,
},
);
tracing::debug!(
"NPC transfer: fact {:?} at {:?} → {:?}",
id,
capped,
partner_entity
);
}
}
TransferCandidate::Entity { id, ek } => {
// Confidence cap: at most KnowsOf.
let capped = ek.confidence.min(KnowledgeConfidence::KnowsOf);
// Preserve existing relationship state if the partner already knows this entity.
let (should_write, existing_relationship) =
match partner_kg.entities.get(&id) {
None => (true, RelationshipState::Unknown),
Some(existing) => {
(existing.confidence < capped, existing.relationship)
}
};
if should_write {
partner_kg.entities.insert(
id,
EntityKnowledge {
last_known_position: ek.last_known_position,
last_observed_tick: ek.last_observed_tick,
last_updated_tick: tick,
confidence: capped,
source: KnowledgeSource::ToldBy {
source_id: speaker_sid,
tick,
},
state: KnowledgeState::Active,
relationship: existing_relationship,
known_attributes: ek.known_attributes.clone(),
contradicted_claim: None,
},
);
tracing::debug!(
"NPC transfer: entity {:?} at {:?} → {:?}",
id,
capped,
partner_entity
);
}
}
}
}
// --- Player overhear grants ---
emit_player_overhear_grants(
&player_query,
speaker_pos,
tick,
speaker_sid,
partner_sid,
&mut event_queue,
);
}
}
/// Emit `Heard` entity grants to all players within VOICE_RANGE_TILES of a conversation.
///
/// Called regardless of transfer tier — even if the NPCs aren't sharing information,
/// the player can still learn that these two entities exist from overhearing them talk.
fn emit_player_overhear_grants(
player_query: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
speaker_pos: &TilePosition,
tick: u64,
speaker_sid: StableId,
partner_sid: StableId,
event_queue: &mut KnowledgeEventQueue,
) {
for (player_entity, player_pos) in player_query.iter() {
let distance = speaker_pos
.manhattan_distance(player_pos)
.unwrap_or(u32::MAX);
if distance <= VOICE_RANGE_TILES {
// Player overhears both participants — learns they exist at Suspects level.
for npc_sid in [speaker_sid, partner_sid] {
event_queue.push(KnowledgeEvent {
observer: player_entity,
tick,
event_type: KnowledgeEventType::KnowledgeGranted {
grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant {
target_id: npc_sid,
attributes: BTreeMap::new(),
confidence: KnowledgeConfidence::Suspects,
}),
source: KnowledgeSource::Heard {
tick,
range: SoundRange::Medium,
},
},
});
}
tracing::debug!(
"Player {:?} overhears conversation at distance {} tiles (D-080)",
player_entity,
distance
);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy_app::prelude::*;
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeState, StableId};
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
use crate::npc::RelationshipKind;
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
fn build_test_world() -> App {
let mut app = App::new();
app.init_resource::<SimulationTime>();
app.insert_resource(SimRng::new(42));
app.init_resource::<EntityRegistry>();
app.init_resource::<RelationshipGraph>();
app.init_resource::<KnowledgeEventQueue>();
app.add_systems(Update, transfer_npc_knowledge);
app
}
/// Spawn a minimal NPC entity with the required components.
/// The StableEntityId component is what the transfer system reads — no registry needed.
fn spawn_npc(world: &mut World, sid: StableId, kg: KnowledgeGraph) -> Entity {
world
.spawn((
crate::npc::Npc,
ActiveSim,
StableEntityId(sid),
TilePosition { x: 0, y: 0, z: 0 },
kg,
))
.id()
}
fn make_fact_kg(fact_id: &str, confidence: KnowledgeConfidence) -> KnowledgeGraph {
let mut kg = KnowledgeGraph::new();
kg.facts.insert(
FactId(fact_id.to_string()),
FactKnowledge {
confidence,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 1,
disclosure_blocked: false,
},
);
kg
}
// --- Trust tier mapping ---
#[test]
fn trust_none_below_zero() {
assert_eq!(trust_to_tier(-1), NpcTransferTier::None);
assert_eq!(trust_to_tier(-10), NpcTransferTier::None);
}
#[test]
fn trust_surface_zero_to_two() {
assert_eq!(trust_to_tier(0), NpcTransferTier::Surface);
assert_eq!(trust_to_tier(2), NpcTransferTier::Surface);
}
#[test]
fn trust_real_three_to_six() {
assert_eq!(trust_to_tier(3), NpcTransferTier::Real);
assert_eq!(trust_to_tier(6), NpcTransferTier::Real);
}
#[test]
fn trust_secret_seven_plus() {
assert_eq!(trust_to_tier(7), NpcTransferTier::Secret);
assert_eq!(trust_to_tier(10), NpcTransferTier::Secret);
}
// --- Confidence cap ---
#[test]
fn confidence_cap_downgrades_details_to_knows_of() {
let capped = KnowledgeConfidence::KnowsDetails.min(KnowledgeConfidence::KnowsOf);
assert_eq!(capped, KnowledgeConfidence::KnowsOf);
}
#[test]
fn confidence_cap_preserves_lower_confidence() {
let capped = KnowledgeConfidence::Suspects.min(KnowledgeConfidence::KnowsOf);
assert_eq!(capped, KnowledgeConfidence::Suspects);
}
// --- Surface tier: KnowsOf+ facts only ---
#[test]
fn surface_tier_transfers_knows_of_fact() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
let kg_b = KnowledgeGraph::new();
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
// Trust = 1 → Surface tier
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 1,
history: vec![],
last_interaction_tick: 0,
},
);
}
// Start conversation — tick 0, so started_tick == 0
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
// Partner should now know the fact
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
assert!(
partner_kg.knows_fact(&FactId("test.fact".to_string())),
"partner should know test.fact after surface-tier transfer"
);
}
#[test]
fn surface_tier_blocks_suspects_fact() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
// Suspects confidence — below KnowsOf threshold for Surface tier
let kg_a = make_fact_kg("test.secret", KnowledgeConfidence::Suspects);
let kg_b = KnowledgeGraph::new();
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 1,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
assert!(
!partner_kg.knows_fact(&FactId("test.secret".to_string())),
"Suspects fact should not transfer at Surface tier"
);
}
#[test]
fn none_tier_does_not_transfer() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
let kg_b = KnowledgeGraph::new();
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: -1, // None tier
history: vec![],
last_interaction_tick: 0,
},
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
assert!(
!partner_kg.knows_fact(&FactId("test.fact".to_string())),
"No transfer should occur at None tier"
);
}
#[test]
fn disclosure_blocked_fact_never_transfers() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
// KnowledgeGraph with disclosure_blocked fact
let mut kg_a = KnowledgeGraph::new();
kg_a.facts.insert(
FactId("secret.blocked".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsDetails,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 1,
disclosure_blocked: true, // blocks NPC transfer
},
);
let kg_b = KnowledgeGraph::new();
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 10, // Max trust — still blocked
history: vec![],
last_interaction_tick: 0,
},
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
assert!(
!partner_kg.knows_fact(&FactId("secret.blocked".to_string())),
"disclosure_blocked fact must never transfer regardless of trust"
);
}
#[test]
fn confidence_cap_applied_on_transfer() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
// KnowsDetails — should be capped to KnowsOf after transfer
let kg_a = make_fact_kg("test.detail", KnowledgeConfidence::KnowsDetails);
let kg_b = KnowledgeGraph::new();
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 8, // Secret tier
history: vec![],
last_interaction_tick: 0,
},
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
let fact = partner_kg
.facts
.get(&FactId("test.detail".to_string()))
.expect("fact should be transferred");
assert_eq!(
fact.confidence,
KnowledgeConfidence::KnowsOf,
"transferred confidence must be capped at KnowsOf"
);
}
#[test]
fn upgrade_only_never_downgrades_existing_knowledge() {
let mut app = build_test_world();
let sid_a = StableId(1);
let sid_b = StableId(2);
// Speaker knows fact at KnowsOf
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
// Partner already knows fact at KnowsDetails (higher than speaker)
let mut kg_b = KnowledgeGraph::new();
kg_b.facts.insert(
FactId("test.fact".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsDetails,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
);
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
{
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
rel.set_relationship(
sid_a,
sid_b,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 5,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
let partner_kg = app
.world()
.entity(entity_b)
.get::<KnowledgeGraph>()
.expect("partner has KG");
let fact = partner_kg
.facts
.get(&FactId("test.fact".to_string()))
.expect("fact exists");
assert_eq!(
fact.confidence,
KnowledgeConfidence::KnowsDetails,
"existing higher confidence must not be downgraded"
);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Point of Interest data model (#148).
//!
//! POIs are discoverable world locations: quest-relevant places, hidden
//! areas, landmarks, vendors, etc. They integrate with the knowledge
//! graph via `FactId("poi.*")` namespace per D-079.
//!
//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted`
//! with `Fact` variant to grant POI facts to observers.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::knowledge::types::FactId;
use crate::simulation::movement::TilePosition;
/// Category of point of interest. Determines client-side icon and color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PoiCategory {
/// Named location (dock, bar, office, residential block).
Location,
/// Vendor or service provider (fixer, medic, data broker).
Service,
/// Quest-relevant target (drop point, meeting place, evidence site).
QuestTarget,
/// Hidden area (secret passage, concealed cache, restricted zone).
Hidden,
/// Navigation landmark visible from a distance.
Landmark,
}
/// How a POI was placed in the world (content provenance).
///
/// Distinct from visibility rules: discovery_source tracks *why* the POI
/// exists; visibility tracks *how* it can be found.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PoiDiscoverySource {
/// Part of the map template — always present on this map.
MapTemplate,
/// Procedurally generated at world creation.
Procedural,
/// Created by a quest or storyline event at runtime.
QuestGenerated,
/// Revealed by NPC testimony via knowledge grant.
NpcRevealed,
}
/// Rules governing when an observer can discover this POI.
///
/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge
/// graph. The discovery system (#149) evaluates these rules each tick
/// for POIs not yet known to the observer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PoiVisibility {
/// Discoverable when within line of sight (standard LOS rules).
LineOfSight,
/// Discoverable only within a specific tile range (Manhattan distance).
Proximity { range: u32 },
/// Not discoverable by observation. Requires a `KnowledgeGranted`
/// event from dialogue, evidence, or NPC testimony.
KnowledgeOnly,
/// Discoverable by LOS, but only if the observer already knows a
/// prerequisite fact. Example: a hidden door visible only if the
/// observer knows `"quest.secret_passage_hint"`.
RequiresFact { fact_id: String },
}
/// Point of Interest ECS component (#148).
///
/// Attached to world entities that represent discoverable locations.
/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added
/// to their `KnowledgeGraph` via the discovery system (#149).
///
/// BTreeMap ordering note: POI entities use `StableEntityId` like all
/// other entities. The `poi_id` string is for the fact namespace only.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct PointOfInterest {
/// Unique identifier within the `poi.*` fact namespace.
/// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`.
/// Must be unique across all POIs in the world.
pub poi_id: String,
/// Display name shown to the player after discovery.
pub name: String,
/// World position of the POI (center tile).
pub position: TilePosition,
/// Category for client-side rendering (icon, minimap marker).
pub category: PoiCategory,
/// Content provenance — how this POI was placed in the world.
pub discovery_source: PoiDiscoverySource,
/// Rules for when/how an observer can discover this POI.
pub visibility: PoiVisibility,
}
impl PointOfInterest {
/// Generate the `FactId` for this POI in the knowledge graph.
/// Format: `"poi.{poi_id}"` per D-079 namespace convention.
pub fn fact_id(&self) -> FactId {
FactId(format!("poi.{}", self.poi_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest {
PointOfInterest {
poi_id: id.to_string(),
name: format!("Test POI {}", id),
position: TilePosition::new(10, 20, 0),
category,
discovery_source: PoiDiscoverySource::MapTemplate,
visibility,
}
}
#[test]
fn fact_id_uses_poi_namespace() {
let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight);
assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string()));
}
#[test]
fn fact_id_format_is_deterministic() {
let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
assert_eq!(poi1.fact_id(), poi2.fact_id());
}
#[test]
fn different_poi_ids_produce_different_fact_ids() {
let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight);
let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight);
assert_ne!(poi1.fact_id(), poi2.fact_id());
}
#[test]
fn proximity_visibility_stores_range() {
let poi = make_poi(
"hidden_cache",
PoiCategory::Hidden,
PoiVisibility::Proximity { range: 5 },
);
match poi.visibility {
PoiVisibility::Proximity { range } => assert_eq!(range, 5),
_ => panic!("Expected Proximity visibility"),
}
}
#[test]
fn requires_fact_visibility_stores_fact_id() {
let poi = make_poi(
"secret_door",
PoiCategory::Hidden,
PoiVisibility::RequiresFact {
fact_id: "quest.secret_passage_hint".to_string(),
},
);
match &poi.visibility {
PoiVisibility::RequiresFact { fact_id } => {
assert_eq!(fact_id, "quest.secret_passage_hint");
}
_ => panic!("Expected RequiresFact visibility"),
}
}
#[test]
fn poi_categories_are_distinct() {
assert_ne!(PoiCategory::Location, PoiCategory::Service);
assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden);
assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark);
}
#[test]
fn poi_discovery_sources_are_distinct() {
assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural);
assert_ne!(
PoiDiscoverySource::QuestGenerated,
PoiDiscoverySource::NpcRevealed
);
}
#[test]
fn poi_serialization_roundtrip() {
let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight);
let serialized = serde_yaml::to_string(&poi).expect("serialize");
let deserialized: PointOfInterest =
serde_yaml::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized.poi_id, "med_bay");
assert_eq!(deserialized.category, PoiCategory::Service);
}
}
+426
View File
@@ -0,0 +1,426 @@
//! POI discovery system (#149).
//!
//! Detects when the player observer discovers a Point of Interest and
//! grants the corresponding `FactId("poi.*")` to their knowledge graph.
//!
//! Discovery methods handled here:
//! - Physical discovery (LOS, proximity) — checked each tick
//!
//! Discovery methods handled elsewhere:
//! - Character background — inserted at spawn time by content system
//! - NPC tips / research — via `KnowledgeGranted` event (#546)
use bevy_ecs::prelude::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::{
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
};
use crate::perception::query::VisibilityGeometry;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::poi::{PoiVisibility, PointOfInterest};
use crate::simulation::time::SimulationTime;
/// Event emitted when the player discovers a POI.
///
/// Other systems (monologue, minimap update, storyteller) can react to
/// this event. Consumed and cleared each tick.
#[derive(Debug, Clone)]
pub struct PoiDiscoveredEvent {
/// The `poi_id` string of the discovered POI.
pub poi_id: String,
/// Display name for monologue/UI use.
pub name: String,
/// Tick when discovered.
pub tick: u64,
}
/// Resource: queue of POI discovery events from the current tick.
#[derive(Resource, Default)]
pub struct PoiDiscoveryEventQueue {
events: Vec<PoiDiscoveredEvent>,
}
impl PoiDiscoveryEventQueue {
pub fn push(&mut self, event: PoiDiscoveredEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<PoiDiscoveredEvent> {
std::mem::take(&mut self.events)
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// System: check for POI physical discovery by the player observer.
///
/// Runs after visibility geometry is computed. For each undiscovered POI,
/// checks visibility rules against the observer's position and known facts.
/// Discovered POIs are added as `FactId("poi.*")` facts to the observer's
/// KnowledgeGraph with `DirectObservation` source.
pub fn discover_pois(
time: Res<SimulationTime>,
geometry: Res<VisibilityGeometry>,
mut discovery_queue: ResMut<PoiDiscoveryEventQueue>,
poi_query: Query<&PointOfInterest>,
mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With<PlayerCharacter>>,
) {
let Ok((observer_pos, mut kg)) = observer_query.single_mut() else {
return;
};
for poi in poi_query.iter() {
let fact_id = poi.fact_id();
// Skip already-known POIs
if kg.knows_fact(&fact_id) {
continue;
}
if can_discover(observer_pos, &geometry, &kg, poi) {
// Direct KG write — bypasses the KnowledgeGranted event queue.
// Justified for LOS-based physical discovery: the observer sees
// the POI directly, no intermediary grant source. This is a D-079
// carve-out; NPC tips and research-based POI discovery (Sprint 18)
// will use the event queue path.
kg.facts.insert(
fact_id,
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::DirectObservation { tick: time.tick },
state: KnowledgeState::Active,
acquired_tick: time.tick,
disclosure_blocked: false,
},
);
discovery_queue.push(PoiDiscoveredEvent {
poi_id: poi.poi_id.clone(),
name: poi.name.clone(),
tick: time.tick,
});
tracing::info!(
poi_id = %poi.poi_id,
name = %poi.name,
tick = time.tick,
"Player discovered POI"
);
}
}
}
/// Evaluate whether an observer can discover a POI based on its visibility rules.
fn can_discover(
observer_pos: &TilePosition,
geometry: &VisibilityGeometry,
kg: &KnowledgeGraph,
poi: &PointOfInterest,
) -> bool {
match &poi.visibility {
PoiVisibility::LineOfSight => {
poi.position.z == geometry.observer_z
&& geometry
.visible_positions
.contains(&(poi.position.x, poi.position.y))
}
PoiVisibility::Proximity { range } => observer_pos
.manhattan_distance(&poi.position)
.is_some_and(|d| d <= *range),
PoiVisibility::KnowledgeOnly => {
// Not discoverable by physical observation.
// Requires KnowledgeGranted event from dialogue/evidence.
false
}
PoiVisibility::RequiresFact { fact_id } => {
// Must know the prerequisite fact AND see the POI in LOS.
kg.knows_fact(&FactId(fact_id.clone()))
&& poi.position.z == geometry.observer_z
&& geometry
.visible_positions
.contains(&(poi.position.x, poi.position.y))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::poi::{PoiCategory, PoiDiscoverySource};
use std::collections::BTreeSet;
fn make_poi(
id: &str,
position: TilePosition,
visibility: PoiVisibility,
) -> PointOfInterest {
PointOfInterest {
poi_id: id.to_string(),
name: format!("Test {}", id),
position,
category: PoiCategory::Location,
discovery_source: PoiDiscoverySource::MapTemplate,
visibility,
}
}
fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry {
VisibilityGeometry {
visible_tiles: vec![],
visible_positions: visible.iter().copied().collect::<BTreeSet<_>>(),
sector_lookup: Default::default(),
observer_z: z,
}
}
// --- can_discover tests ---
#[test]
fn los_poi_discovered_when_in_visible_positions() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
let geometry = make_geometry(&[(10, 5)], 0);
let kg = KnowledgeGraph::new();
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn los_poi_not_discovered_when_not_visible() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set
let kg = KnowledgeGraph::new();
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn los_poi_not_discovered_on_different_z() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight);
let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1
let kg = KnowledgeGraph::new();
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn proximity_poi_discovered_within_range() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"cache",
TilePosition::new(7, 5, 0),
PoiVisibility::Proximity { range: 3 },
);
let geometry = make_geometry(&[], 0);
let kg = KnowledgeGraph::new();
// Manhattan distance = 2, range = 3 → discovered
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn proximity_poi_not_discovered_outside_range() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"cache",
TilePosition::new(10, 5, 0),
PoiVisibility::Proximity { range: 3 },
);
let geometry = make_geometry(&[], 0);
let kg = KnowledgeGraph::new();
// Manhattan distance = 5, range = 3 → not discovered
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn proximity_poi_not_discovered_different_z() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"cache",
TilePosition::new(5, 6, 1), // different z
PoiVisibility::Proximity { range: 3 },
);
let geometry = make_geometry(&[], 0);
let kg = KnowledgeGraph::new();
// manhattan_distance returns None for different z
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn knowledge_only_never_discovered_physically() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"secret",
TilePosition::new(5, 5, 0), // same tile
PoiVisibility::KnowledgeOnly,
);
let geometry = make_geometry(&[(5, 5)], 0);
let kg = KnowledgeGraph::new();
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn requires_fact_discovered_when_fact_known_and_visible() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"hidden_door",
TilePosition::new(8, 5, 0),
PoiVisibility::RequiresFact {
fact_id: "quest.secret_hint".to_string(),
},
);
let geometry = make_geometry(&[(8, 5)], 0);
let kg = KnowledgeGraph::with_background(vec![(
FactId("quest.secret_hint".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn requires_fact_not_discovered_without_fact() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"hidden_door",
TilePosition::new(8, 5, 0),
PoiVisibility::RequiresFact {
fact_id: "quest.secret_hint".to_string(),
},
);
let geometry = make_geometry(&[(8, 5)], 0);
let kg = KnowledgeGraph::new(); // no facts
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
#[test]
fn requires_fact_not_discovered_when_not_visible() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi(
"hidden_door",
TilePosition::new(8, 5, 0),
PoiVisibility::RequiresFact {
fact_id: "quest.secret_hint".to_string(),
},
);
let geometry = make_geometry(&[], 0); // not visible
let kg = KnowledgeGraph::with_background(vec![(
FactId("quest.secret_hint".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
}
// --- System integration test ---
#[test]
fn discover_pois_system_grants_fact() {
use bevy_ecs::world::World;
let mut world = World::new();
// Resources
let mut time = SimulationTime::default();
time.tick = 50;
world.insert_resource(time);
world.insert_resource(make_geometry(&[(10, 5)], 0));
world.insert_resource(PoiDiscoveryEventQueue::default());
// Player observer
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// POI entity
world.spawn(make_poi(
"docking_bay",
TilePosition::new(10, 5, 0),
PoiVisibility::LineOfSight,
));
// Run system
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(discover_pois);
schedule.run(&mut world);
// Verify: player now knows the POI fact
let mut query = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
let kg = query.single(&world).expect("player should exist");
let fact_id = FactId("poi.docking_bay".to_string());
assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay");
assert_eq!(
kg.facts.get(&fact_id).unwrap().confidence,
KnowledgeConfidence::KnowsOf
);
// Verify: discovery event was emitted
let queue = world.resource::<PoiDiscoveryEventQueue>();
assert_eq!(queue.events.len(), 1);
assert_eq!(queue.events[0].poi_id, "docking_bay");
assert_eq!(queue.events[0].tick, 50);
}
#[test]
fn discover_pois_system_skips_already_known() {
use bevy_ecs::world::World;
let mut world = World::new();
let mut time = SimulationTime::default();
time.tick = 100;
world.insert_resource(time);
world.insert_resource(make_geometry(&[(10, 5)], 0));
world.insert_resource(PoiDiscoveryEventQueue::default());
// Player already knows this POI
let kg = KnowledgeGraph::with_background(vec![(
FactId("poi.docking_bay".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
world.spawn(make_poi(
"docking_bay",
TilePosition::new(10, 5, 0),
PoiVisibility::LineOfSight,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(discover_pois);
schedule.run(&mut world);
// No new events — already known
let queue = world.resource::<PoiDiscoveryEventQueue>();
assert!(queue.is_empty(), "No discovery event for already-known POI");
}
}
+2
View File
@@ -214,6 +214,7 @@ fn verify_template_role_slots() {
fn spawn_npc_from_content_store() {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
// Create a minimal content store with one test NPC
let mut store = ContentStore::default();
@@ -333,6 +334,7 @@ fn spawn_real_content_with_relationships_and_secrets() {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
+223
View File
@@ -1364,6 +1364,229 @@ fn v10_payload_deserializes_into_v11_struct() {
);
}
// ---------------------------------------------------------------------------
// #232: Protocol versioning scheme tests
// ---------------------------------------------------------------------------
/// A snapshot serialized without newer optional fields (simulating an older server)
/// must deserialize with serde defaults — core migration pattern (#232).
///
/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify
/// they fill in as their zero/None values on deserialization.
#[test]
fn serde_default_fields_fill_in_when_missing_from_wire() {
// JSON with only the required fields (simulating a minimal old snapshot).
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
// are all `#[serde(default)]` — they must default to None/empty when absent.
let minimal_json = serde_json::json!({
"version": 13,
"tick": 42,
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": "Full"
},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [{
"entity_id": 1,
"x": 5.0,
"y": 5.0,
"z": 0,
"kind": "Npc",
"visibility": "Forward",
"relationship": "Unknown",
"observation": "Visible"
// "tell_state" intentionally absent
}],
"visible_tiles": [],
"nearby_interactions": [],
"current_monologue": null,
"pending_recognitions": [],
"dialogue_response": null,
"blocked_entities": [],
"scan_events": [],
"sound_events": [],
"conversation_events": [],
"conversation_ended": [],
"follow_state": null,
"rng_seed": null
});
let decoded: ObserverSnapshot =
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
// Version and required fields present
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
// `#[serde(default, skip_serializing_if = "Option::is_none")]` field
// defaults to None when absent from the wire
assert_eq!(
decoded.entities[0].tell_state, None,
"tell_state must default to None when absent from wire"
);
assert_eq!(
decoded.rng_seed, None,
"rng_seed must default to None when absent from wire"
);
assert!(
decoded.follow_state.is_none(),
"follow_state must default to None when absent from wire"
);
}
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
/// the version field after deserialization (#232 compatibility checking).
#[test]
fn snapshot_version_mismatch_is_detectable() {
let mut snapshot = test_snapshot(0, vec![]);
let future_version: u8 = PROTOCOL_VERSION + 1;
snapshot.version = future_version;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
// The version field faithfully preserves the value — caller detects mismatch
assert_eq!(
decoded.version, future_version,
"version field must survive round-trip unchanged"
);
assert_ne!(
decoded.version, PROTOCOL_VERSION,
"client should detect this as a version mismatch"
);
}
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
/// A snapshot with tell_state=None produces fewer bytes than one with
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
#[test]
fn tell_state_none_is_omitted_from_wire() {
let entity_no_tell = VisibleEntity {
entity_id: 1,
x: 0.0,
y: 0.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
};
let entity_with_tell = VisibleEntity {
tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous),
..entity_no_tell.clone()
};
let bytes_no_tell =
rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state");
let bytes_with_tell =
rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state");
assert!(
bytes_no_tell.len() < bytes_with_tell.len(),
"tell_state=None should produce fewer bytes (skip_serializing_if contract)"
);
}
/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity.
/// Closing coverage gap for v13 tell_state field (#90, D-024).
#[test]
fn all_tell_category_variants_roundtrip() {
use settled_reach_server::npc::tell_state::TellCategory;
let categories = [
TellCategory::Nervous,
TellCategory::Angry,
TellCategory::Friendly,
TellCategory::Guarded,
TellCategory::RoutineDeviation,
];
for category in categories {
let entity = VisibleEntity {
entity_id: 1,
x: 3.0,
y: 4.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: Some(category),
};
let bytes = rmp_serde::to_vec_named(&entity).expect("serialize");
let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.tell_state,
Some(category),
"TellCategory::{:?} did not survive round-trip",
category
);
}
}
/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232).
/// Closes a coverage gap — not all VerbKind variants were previously verified.
#[test]
fn all_verb_kind_variants_roundtrip_v232() {
let all_verbs = [
VerbKind::ExamineNpc,
VerbKind::Talk,
VerbKind::Observe,
VerbKind::Read,
VerbKind::Open,
VerbKind::Close,
VerbKind::Search,
VerbKind::Use,
VerbKind::Take,
VerbKind::Sit,
VerbKind::Follow,
VerbKind::Confront,
VerbKind::ExamineObject,
];
for kind in all_verbs {
let interaction = NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind,
label: "Test".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: false,
};
let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize");
let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.verbs[0].kind, kind,
"VerbKind::{:?} did not survive round-trip",
kind
);
}
}
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
/// This guards against accidental widening of the version type.
#[test]
fn protocol_version_fits_in_u8() {
// u8 max is 255 — enough for ~242 more protocol iterations.
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
assert!(
PROTOCOL_VERSION <= 200,
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
PROTOCOL_VERSION
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]