diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 76783ea3d..a09fcaf8f 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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) diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs index 6e74342df..5ed6fd66f 100644 --- a/server/src/npc/tell_state.rs +++ b/server/src/npc/tell_state.rs @@ -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 { // 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, With), >, ) { - 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::(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)" + ); + } } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index b549567c9..966950882 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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 `"#"` 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, + _registry: Res, + mut rng: ResMut, + mut contradiction_queue: ResMut, + _npc_names: Query<&NpcName>, + mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With>, +) { + // _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::(); + world.insert_resource(SimRng::new(42)); + world.insert_resource(ContradictionDetectedQueue::default()); + world.init_resource::(); + 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::().tick = 500; + + // Populate the contradiction queue with pre-resolved names + world.resource_mut::().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::(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::(player).unwrap().set(MonologueEvent { + id: "prior_event".to_string(), + text: "Something already fired.".to_string(), + duration_seconds: 5.0, + }); + + world.resource_mut::().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::(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::().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::().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::().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::(); + world.insert_resource(SimRng::new(7)); + world.init_resource::(); + + // 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::().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::(); + assert!(!cq.is_empty(), "contradiction should be in queue after T2"); + } + + // Verify Sera became PersonOfInterest in player's KG + { + let kg = world.get::(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::().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::().push(event); + } + + // Tick T3: Run process_contradiction_monologue + world.resource_mut::().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::(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::().is_empty(), + "queue should be empty after monologue consumed the event" + ); + } }