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:
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -738,6 +740,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 +2281,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user