feat(simulation): NPC information boundaries and contradiction monologue (#549, #550)

tell_state reads KG for other-entity relationship state (MVP boundary
per D-082). Contradiction monologue fires with pre-resolved entity names,
shifts ToldBy source to PersonOfInterest. Full THE FRIEND arc event chain.
Implements D-082 step 1, D-083 event chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:15:11 +01:00
co-authored by Claude Opus 4.6
parent 7487eb9dbb
commit b86e052511
3 changed files with 618 additions and 5 deletions
+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)"
);
}
}