// Internal monologue trigger system (#414) // // Selects monologue lines from loaded content pools based on trigger conditions. // v0.1: enter_location (on first tick) + time_idle (periodic when player hasn't moved). // Lines are written to MonologueBuffer for inclusion in ObserverSnapshot. // // Sprint anomaly monologue (#428, D-055): // When sprinting past a Contradicted entity, a delayed "double-take" monologue // fires retroactively. Detection in observer pipeline, processing here. use std::collections::BTreeSet; use bevy_ecs::prelude::*; use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry}; use crate::perception::interpretation::ObservationTrigger; use crate::simulation::conversation::NpcName; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::storyteller::EngagementRecord; /// Display duration for monologue text on client (seconds). const DISPLAY_DURATION: f32 = 5.0; /// Tick delay before a sprint anomaly monologue fires (#428, D-055). /// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds. /// Tunable: adjust based on actual client frame rate. pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90; /// Hardcoded v0.1 recognition monologue lines (#451, D-060). /// Fire DURING cognitive delay (when grey blob appears). Future: move to /// content pools with trigger="observe_anomaly" + character match. const RECOGNITION_LINES: &[(&str, &str)] = &[ ("recognition_01", "Wait \u{2014} I know that walk."), ( "recognition_02", "Those footsteps... I've heard that pattern before.", ), ("recognition_03", "Something about that silhouette..."), ]; /// Hardcoded v0.1 sprint anomaly "double-take" lines. /// Future: move to content pools with trigger="sprint_anomaly". const ANOMALY_LINES: &[(&str, &str)] = &[ ( "sprint_anomaly_01", "Wait \u{2014} something wasn't right back there.", ), ( "sprint_anomaly_02", "Hold on. That face... why were they there?", ), ( "sprint_anomaly_03", "Something's off. That wasn't where they should be.", ), ]; // --------------------------------------------------------------------------- // Event-driven monologue triggers (#119, D-035) // --------------------------------------------------------------------------- /// Hardcoded v0.1 observe_npc monologue lines. /// Fire when a new entity enters the player's field of view. /// Future: move to content pools with trigger="observe_npc". const OBSERVE_NPC_LINES: &[(&str, &str)] = &[ ("observe_npc_01", "New face. Haven't seen them before."), ("observe_npc_02", "Someone I don't recognize."), ("observe_npc_03", "Who's that? They weren't here earlier."), ]; /// Hardcoded v0.1 hear_sound monologue lines. /// Fire when the player hears a non-routine sound (Machinery, Alert). /// Future: move to content pools with trigger="hear_sound". const HEAR_SOUND_LINES: &[(&str, &str)] = &[ ("hear_sound_01", "What was that?"), ( "hear_sound_02", "That sound \u{2014} not the usual background.", ), ("hear_sound_03", "Something just happened nearby."), ]; /// Hardcoded v0.1 witness_interaction monologue lines. /// Fire when the player overhears an NPC-to-NPC conversation (D-078). /// Future: move to content pools with trigger="witness_interaction". const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[ ("witness_01", "Interesting. Wonder what that was about."), ("witness_02", "I should remember what they just said."), ("witness_03", "They didn't know I was listening."), ]; /// Hardcoded v0.1 post_conversation monologue lines. /// Fire after a player-NPC dialogue concludes (walk-away or natural end). /// Future: move to content pools with trigger="post_conversation". const POST_CONVERSATION_LINES: &[(&str, &str)] = &[ ("post_conv_01", "More questions than answers."), ("post_conv_02", "I'll have to think about what they said."), ( "post_conv_03", "Something about that exchange didn't sit right.", ), ]; /// Resource: signals that a player-NPC dialogue completed this tick. /// Pushed by process_walk_away (D-064); drained by trigger_event_monologue. #[derive(Resource, Debug, Default)] pub struct PostConversationQueue { entries: Vec, } impl PostConversationQueue { pub fn push(&mut self, npc: bevy_ecs::entity::Entity) { self.entries.push(npc); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.entries) } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } /// Tracks monologue state for cooldown and trigger detection. /// Attached to the PlayerCharacter entity. #[derive(Component, Debug)] pub struct MonologueState { /// Tick when the last monologue was fired. pub last_fired_tick: u64, /// Player position on the previous tick (for movement detection). pub last_position: Option<(i32, i32)>, /// Ticks since the player last moved (for time_idle trigger). pub idle_ticks: u64, /// Whether the enter_location monologue has fired this session. pub entered: bool, /// IDs of lines already shown (dedup within session). pub shown_ids: BTreeSet, /// Character type for pool filtering. Set from CharacterArchetype (#587). pub character: String, /// Tick of the last observation event we reacted to (#119, observe_npc). /// Observation events arrive one tick after the snapshot that caused them, /// so we track which tick's events we've already processed. pub last_observation_tick: u64, } impl Default for MonologueState { fn default() -> Self { Self { last_fired_tick: 0, last_position: None, idle_ticks: 0, entered: false, shown_ids: BTreeSet::new(), // Default to detective; overridden by CharacterArchetype at spawn (#587) character: "detective".to_string(), last_observation_tick: 0, } } } /// Buffer holding the monologue event to include in the next snapshot. /// `take()` drains the buffer (consumed once per snapshot). #[derive(Component, Debug, Default)] pub struct MonologueBuffer { event: Option, } impl MonologueBuffer { /// Drain and return the monologue event, leaving the buffer empty. pub fn take(&mut self) -> Option { self.event.take() } /// Set a monologue event, replacing any pending event. /// Used by confrontation response (#520, D-063) to emit a monologue spike. pub fn set(&mut self, event: MonologueEvent) { self.event = Some(event); } } /// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055). /// /// When sprinting past a Contradicted entity, the observer pipeline detects /// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing /// system fires a retroactive monologue ("Wait — was that...?"). /// /// At most one anomaly is pending at a time (first-in wins). #[derive(Component, Debug, Default)] pub struct SprintAnomalyQueue { pending: Option, } #[derive(Debug, Clone)] struct SprintAnomalyEntry { entity_id: u64, detected_tick: u64, } impl SprintAnomalyQueue { /// Queue an anomaly if none is pending. /// First-in wins: subsequent anomalies are ignored until the current one fires. pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) { if self.pending.is_none() { self.pending = Some(SprintAnomalyEntry { entity_id, detected_tick: tick, }); } } /// Take the pending anomaly if the delay has elapsed. /// Returns the entity_id that triggered the anomaly. pub fn take_ready(&mut self, current_tick: u64) -> Option { if let Some(entry) = &self.pending { if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS { let entity_id = entry.entity_id; self.pending = None; return Some(entity_id); } } None } /// Whether an anomaly is pending (detected but not yet fired). pub fn has_pending(&self) -> bool { self.pending.is_some() } } /// Process delayed sprint anomaly monologues (#428, D-055). /// /// Checks SprintAnomalyQueue for entries past the delay threshold and fires /// a "double-take" monologue. Bypasses normal monologue cooldown since sprint /// anomalies are event-driven, not periodic. Updates last_fired_tick so /// subsequent normal monologue respects cooldown after the anomaly fires. /// /// System ordering: after trigger_monologue, before compute_observer_snapshot. pub fn process_sprint_anomaly_monologue( time: Res, mut rng: ResMut, mut query: Query< ( &mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState, ), With, >, ) { let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else { return; }; // Don't override existing monologue from trigger_monologue if buffer.event.is_some() { return; } if let Some(_entity_id) = queue.take_ready(time.tick) { let index = rng.rng.random_range(0..ANOMALY_LINES.len()); let (id, text) = ANOMALY_LINES[index]; buffer.event = Some(MonologueEvent { id: id.to_string(), text: text.to_string(), duration_seconds: DISPLAY_DURATION, }); // Update last_fired_tick so normal monologue respects cooldown state.last_fired_tick = time.tick; tracing::debug!( "Sprint anomaly monologue fired: id={}, tick={}", id, time.tick ); } } /// Recognition monologue trigger (#451, D-060). /// /// Fires DURING cognitive delay, not after — "the monologue IS the recognition." /// When a new entity enters fog (PendingRecognition queued by emit_observation_events), /// this system fires a recognition monologue on the next tick. /// /// Priority: anomalous entities (AnomalyMarker) get first pick. Only one /// recognition monologue fires per tick. Bypasses normal monologue cooldown /// (event-driven), but updates last_fired_tick for normal cooldown tracking. /// /// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue. pub fn trigger_recognition_monologue( time: Res, mut rng: ResMut, mut query: Query< ( &mut crate::perception::cognitive_delay::CognitiveDelay, &mut MonologueBuffer, &mut MonologueState, ), With, >, anomaly_markers: Query<(), With>, ) { let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else { return; }; // Don't override existing monologue from trigger_monologue if buffer.event.is_some() { return; } // No pending recognitions → nothing to do if cognitive_delay.is_empty() { return; } // Find the first unfired pending recognition. Prioritize anomalous entities. let pending = cognitive_delay.pending_mut(); let target_idx = { // First pass: anomalous + unfired let anomaly_idx = pending .iter() .position(|p| !p.monologue_fired && anomaly_markers.get(p.target).is_ok()); if let Some(idx) = anomaly_idx { Some(idx) } else { // Second pass: any unfired pending.iter().position(|p| !p.monologue_fired) } }; let Some(idx) = target_idx else { return; }; let i = rng.rng.random_range(0..RECOGNITION_LINES.len()); let (id, text) = ( RECOGNITION_LINES[i].0.to_string(), RECOGNITION_LINES[i].1.to_string(), ); buffer.event = Some(MonologueEvent { id: id.clone(), text, duration_seconds: DISPLAY_DURATION, }); state.shown_ids.insert(id.clone()); state.last_fired_tick = time.tick; // Mark this pending recognition as having fired its monologue pending[idx].monologue_fired = true; tracing::debug!( "Recognition monologue fired: id={}, tick={}, target_stable_id={}", id, time.tick, pending[idx].stable_id.0, ); } // --------------------------------------------------------------------------- // Shared content pool selection (#119) // --------------------------------------------------------------------------- /// Select from hardcoded fallback lines for the given trigger type. fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) { let lines = match trigger { "observe_npc" => OBSERVE_NPC_LINES, "hear_sound" => HEAR_SOUND_LINES, "witness_interaction" => WITNESS_INTERACTION_LINES, "post_conversation" => POST_CONVERSATION_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()) } /// Tile range for a SoundRange classification (D-018). fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 { use crate::knowledge::types::SoundRange; match range { SoundRange::Close => 3, SoundRange::Medium => 8, SoundRange::Long => 15, } } // --------------------------------------------------------------------------- // Event-driven monologue trigger system (#119, D-035) // --------------------------------------------------------------------------- /// Event-driven monologue trigger system (#119, D-035). /// /// Checks observation events, sound events, overheard conversations, and /// completed dialogues for monologue-worthy triggers. Fires at most one /// monologue per tick. Event-driven — no cooldown gate. Updates /// `last_fired_tick` so v0.2 periodic triggers can respect the recency window. /// /// Priority order (first match wins): /// 1. observe_npc (new entity spotted — uses previous-tick observation events) /// 2. hear_sound (non-routine sound: Machinery, Alert) /// 3. witness_interaction (overheard NPC-to-NPC conversation, D-078) /// 4. post_conversation (player-NPC dialogue concluded) /// /// System ordering: after all event producers + recognition/anomaly monologue /// systems, before compute_observer_snapshot. #[allow(clippy::too_many_arguments)] pub fn trigger_event_monologue( time: Res, mut rng: ResMut, observation_queue: Option>, sound_queue: Option>, mut post_conv_queue: ResMut, mut query: Query< ( &TilePosition, &mut MonologueState, &mut MonologueBuffer, Option<&crate::simulation::conversation::ConversationEventBuffer>, ), With, >, registry: Option>, mut engagement_query: Query<&mut EngagementRecord>, ) { // Drain post_conversation queue unconditionally — consumed this tick. // Saved for NPC attribution (engagement tracking #570) and trigger detection. let post_conv_npcs: Vec = post_conv_queue.drain(); let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else { return; }; // Don't override existing monologue from higher-priority systems if buffer.event.is_some() { return; } // Save previous observation tick before update — needed for NPC attribution (#570) let previous_observation_tick = state.last_observation_tick; // Determine which trigger to fire (priority order) let trigger = if observation_queue .as_ref() .map(|q| has_observe_npc_event(q, &state)) .unwrap_or(false) { Some("observe_npc") } else if sound_queue .as_ref() .map(|q| has_hear_sound_event(q, player_pos)) .unwrap_or(false) { Some("hear_sound") } else if conv_buffer_opt .map(|b| !b.events.is_empty()) .unwrap_or(false) { Some("witness_interaction") } else if !post_conv_npcs.is_empty() { Some("post_conversation") } else { None }; // Update observation tracking regardless of whether we fire if let Some(ref obs_queue) = observation_queue { if !obs_queue.is_empty() { if let Some(max_tick) = obs_queue.iter().map(|e| e.tick).max() { if max_tick > state.last_observation_tick { state.last_observation_tick = max_tick; } } } } let Some(trigger) = trigger else { return }; let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng); 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!( "Event monologue fired: trigger={}, id={}, tick={}", trigger, id, time.tick ); // Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs. // Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific. match trigger { "observe_npc" => { // Attribute to all NPCs whose NewEntity event triggered this monologue if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, ®istry) { for event in obs_q.iter() { if event.tick > previous_observation_tick { if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger { if let Some(npc_entity) = reg.to_entity(sid) { if let Ok(mut record) = engagement_query.get_mut(npc_entity) { record.monologue_trigger_count += 1; } } } } } } } "post_conversation" => { // Attribute to the NPC(s) whose conversation just ended for npc in &post_conv_npcs { if let Ok(mut record) = engagement_query.get_mut(*npc) { record.monologue_trigger_count += 1; } } } _ => {} // hear_sound, witness_interaction: no NPC-specific attribution } } /// Check if any NewEntity observation events exist that we haven't processed. fn has_observe_npc_event( queue: &crate::perception::interpretation::ObservationEventQueue, state: &MonologueState, ) -> bool { queue.iter().any(|e| { e.tick > state.last_observation_tick && matches!( &e.trigger, crate::perception::interpretation::ObservationTrigger::NewEntity { .. } ) }) } /// Check if any non-routine sound events are within hearing range. /// Only Machinery and Alert sounds trigger monologue (Footstep, Voice, Ambient /// are routine and would spam the player). fn has_hear_sound_event( queue: &crate::simulation::sound::SoundEventQueue, player_pos: &TilePosition, ) -> bool { use crate::simulation::sound::SoundEventKind; queue.events.iter().any(|e| { let interesting = matches!(e.kind, SoundEventKind::Machinery | SoundEventKind::Alert); if !interesting { return false; } if e.z != player_pos.z { return false; } let dx = (e.x as i32 - player_pos.x).unsigned_abs(); let dy = (e.y as i32 - player_pos.y).unsigned_abs(); let distance = dx + dy; distance <= sound_range_tiles(&e.range) }) } /// Monologue trigger system — **stub**. /// /// v0.1 content pool triggers were removed (#655). This system remains /// registered in the schedule as a sequencing anchor: `trigger_recognition_monologue` /// and `process_sprint_anomaly_monologue` are ordered `.after(trigger_monologue)`. /// Remove this stub when those systems' ordering constraints are refactored. pub fn trigger_monologue( _time: Res, _rng: ResMut, _query: Query< (&TilePosition, &mut MonologueState, &mut MonologueBuffer), With, >, ) { // Intentional no-op — kept as schedule ordering anchor. See doc comment. } // --------------------------------------------------------------------------- // 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::*; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use bevy_ecs::world::World; // ----------------------------------------------------------------------- // SprintAnomalyQueue unit tests (#428, D-055) // ----------------------------------------------------------------------- #[test] fn anomaly_queue_default_is_empty() { let queue = SprintAnomalyQueue::default(); assert!(!queue.has_pending()); } #[test] fn anomaly_queue_push_stores_entry() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); assert!(queue.has_pending()); } #[test] fn anomaly_queue_first_in_wins() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); queue.push_anomaly(99, 101); // Should be ignored assert!(queue.has_pending()); // The first anomaly (entity 42) should be the one that fires let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS); assert_eq!(result, Some(42)); } #[test] fn anomaly_queue_take_ready_before_delay() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); // Not enough delay yet let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1); assert_eq!(result, None); assert!(queue.has_pending()); // Still pending } #[test] fn anomaly_queue_take_ready_at_delay() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); // Exactly at delay threshold let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS); assert_eq!(result, Some(42)); assert!(!queue.has_pending()); // Consumed } #[test] fn anomaly_queue_take_ready_clears_entry() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS); // Second take should return None let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10); assert_eq!(result, None); } #[test] fn anomaly_queue_can_push_after_take() { let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 100); let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS); assert!(!queue.has_pending()); // Push a new anomaly after the first was consumed queue.push_anomaly(99, 300); assert!(queue.has_pending()); let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS); assert_eq!(result, Some(99)); } // ----------------------------------------------------------------------- // process_sprint_anomaly_monologue system tests (#428, D-055) // ----------------------------------------------------------------------- fn setup_anomaly_world() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); world } #[test] fn anomaly_monologue_fires_after_delay() { let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); // Queued at tick 0 world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), queue, )); // Advance past delay world.resource_mut::().tick = ANOMALY_DELAY_TICKS; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); schedule.run(&mut world); let mut query = world.query::<&MonologueBuffer>(); let buffer = query.single(&world).unwrap(); assert!(buffer.event.is_some()); let event = buffer.event.as_ref().unwrap(); assert!(event.id.starts_with("sprint_anomaly_")); } #[test] fn anomaly_monologue_not_before_delay() { let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), queue, )); // Still within delay world.resource_mut::().tick = ANOMALY_DELAY_TICKS - 1; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); schedule.run(&mut world); let mut query = world.query::<&MonologueBuffer>(); let buffer = query.single(&world).unwrap(); assert!(buffer.event.is_none()); } #[test] fn anomaly_monologue_does_not_override_existing() { let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); // Pre-fill the monologue buffer (as if trigger_monologue already wrote) let mut buffer = MonologueBuffer::default(); buffer.event = Some(MonologueEvent { id: "existing_line".to_string(), text: "I should keep this.".to_string(), duration_seconds: 5.0, }); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), buffer, queue, )); world.resource_mut::().tick = ANOMALY_DELAY_TICKS; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); schedule.run(&mut world); // Should still have the original line, not the anomaly line let mut query = world.query::<&MonologueBuffer>(); let buffer = query.single(&world).unwrap(); assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line"); // Queue should still be pending (not consumed) let mut q = world.query::<&SprintAnomalyQueue>(); assert!(q.single(&world).unwrap().has_pending()); } #[test] fn anomaly_monologue_updates_last_fired_tick() { let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), queue, )); world.resource_mut::().tick = ANOMALY_DELAY_TICKS; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); schedule.run(&mut world); let mut query = world.query::<&MonologueState>(); let state = query.single(&world).unwrap(); assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS); } #[test] fn anomaly_monologue_clears_queue_after_fire() { let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), queue, )); world.resource_mut::().tick = ANOMALY_DELAY_TICKS; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); schedule.run(&mut world); let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!(!queue.has_pending()); } #[test] fn anomaly_monologue_no_crash_without_queue() { // Backward compat: entities without SprintAnomalyQueue don't crash let mut world = setup_anomaly_world(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); // Should not panic schedule.run(&mut world); } #[test] fn anomaly_delay_constant_is_90_ticks() { // D-055 spec: ~1.5 real seconds at 60fps → 90 ticks assert_eq!(ANOMALY_DELAY_TICKS, 90); } #[test] fn anomaly_lines_all_valid() { // All hardcoded v0.1 lines should have id prefix and non-empty text assert!(!ANOMALY_LINES.is_empty()); for (id, text) in ANOMALY_LINES { assert!( id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id ); assert!(!text.is_empty(), "text for {} should be non-empty", id); } } // ----------------------------------------------------------------------- // trigger_recognition_monologue tests (#451, D-060) // ----------------------------------------------------------------------- use crate::knowledge::types::StableId; use crate::perception::cognitive_delay::{ CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS, }; fn setup_recognition_world() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); world } #[test] fn recognition_monologue_fires_for_pending_recognition() { let mut world = setup_recognition_world(); let target = world.spawn_empty().id(); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); world.spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), cd, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buffer = buf_query.single(&world).unwrap(); assert!( buffer.event.is_some(), "recognition monologue should fire for pending recognition" ); let event = buffer.event.as_ref().unwrap(); assert!( event.id.starts_with("recognition_"), "should use hardcoded recognition lines (no content pool)" ); } #[test] fn recognition_monologue_does_not_fire_twice() { let mut world = setup_recognition_world(); let target = world.spawn_empty().id(); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); let player = world .spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), cd, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); // First tick: fires schedule.run(&mut world); assert!( world .query::<&MonologueBuffer>() .single(&world) .unwrap() .event .is_some(), "first tick should fire" ); // Consume the buffer world.get_mut::(player).unwrap().take(); // Second tick: should NOT fire (monologue_fired = true) schedule.run(&mut world); assert!( world .query::<&MonologueBuffer>() .single(&world) .unwrap() .event .is_none(), "second tick should not fire (already fired for this recognition)" ); } #[test] fn recognition_monologue_does_not_override_existing_buffer() { let mut world = setup_recognition_world(); let target = world.spawn_empty().id(); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); // Pre-fill MonologueBuffer (e.g., from trigger_monologue) let mut buffer = MonologueBuffer::default(); buffer.event = Some(MonologueEvent { id: "existing_line".to_string(), text: "Already have something to say.".to_string(), duration_seconds: 5.0, }); world.spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), buffer, cd, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buffer = buf_query.single(&world).unwrap(); assert_eq!( buffer.event.as_ref().unwrap().id, "existing_line", "should not override existing monologue" ); // monologue_fired should still be false (wasn't consumed) let mut cd_query = world.query::<&CognitiveDelay>(); let cd = cd_query.single(&world).unwrap(); assert!( !cd.pending()[0].monologue_fired, "should not mark as fired when buffer was full" ); } #[test] fn recognition_monologue_no_pending_is_noop() { let mut world = setup_recognition_world(); world.spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), CognitiveDelay::default(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); assert!( buf_query.single(&world).unwrap().event.is_none(), "no pending recognitions → no monologue" ); } #[test] fn recognition_monologue_prioritizes_anomalous_entities() { let mut world = setup_recognition_world(); let normal_target = world.spawn_empty().id(); let anomalous_target = world.spawn(crate::perception::anomaly::AnomalyMarker).id(); let mut cd = CognitiveDelay::default(); // Normal entity added first cd.push(PendingRecognition { target: normal_target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); // Anomalous entity added second cd.push(PendingRecognition { target: anomalous_target, stable_id: StableId(2), position: TilePosition::new(8, 8, 0), delay_until_tick: NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); let player = world .spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), cd, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); schedule.run(&mut world); // Monologue should fire for anomalous entity (idx 1), not normal (idx 0) let cd = world.get::(player).unwrap(); assert!( !cd.pending()[0].monologue_fired, "normal entity should NOT be fired first" ); assert!( cd.pending()[1].monologue_fired, "anomalous entity should be fired first (priority)" ); } #[test] fn recognition_monologue_updates_last_fired_tick() { let mut world = setup_recognition_world(); world.resource_mut::().tick = 50; let target = world.spawn_empty().id(); let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: StableId(1), position: TilePosition::new(5, 5, 0), delay_until_tick: 50 + NORMAL_DELAY_TICKS, trigger: RecognitionTrigger::Normal, monologue_fired: false, }); world.spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), cd, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_recognition_monologue); schedule.run(&mut world); let mut state_query = world.query::<&MonologueState>(); assert_eq!( state_query.single(&world).unwrap().last_fired_tick, 50, "last_fired_tick should be updated for normal cooldown tracking" ); } #[test] fn recognition_lines_all_valid() { assert!(!RECOGNITION_LINES.is_empty()); for (id, text) in RECOGNITION_LINES { assert!( id.starts_with("recognition_"), "id={} should start with recognition_", id ); assert!(!text.is_empty(), "text for {} should be non-empty", id); } } #[test] fn anomaly_full_cycle_detect_then_fire() { // Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90 let mut world = setup_anomaly_world(); let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(42, 0); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), queue, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_sprint_anomaly_monologue); // Tick 89: still within delay — should NOT fire world.resource_mut::().tick = ANOMALY_DELAY_TICKS - 1; schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); assert!( buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay" ); let mut q_query = world.query::<&SprintAnomalyQueue>(); assert!( q_query.single(&world).unwrap().has_pending(), "still pending before delay" ); // Tick 90: delay elapsed — should fire world.resource_mut::().tick = ANOMALY_DELAY_TICKS; schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buffer = buf_query.single(&world).unwrap(); assert!(buffer.event.is_some(), "should fire at delay threshold"); let event = buffer.event.as_ref().unwrap(); assert!(event.id.starts_with("sprint_anomaly_")); assert_eq!(event.duration_seconds, DISPLAY_DURATION); // Queue should be cleared let mut q_query = world.query::<&SprintAnomalyQueue>(); assert!( !q_query.single(&world).unwrap().has_pending(), "queue cleared after fire" ); // last_fired_tick should be updated let mut state_query = world.query::<&MonologueState>(); assert_eq!( state_query.single(&world).unwrap().last_fired_tick, ANOMALY_DELAY_TICKS, "last_fired_tick updated for cooldown" ); } // ----------------------------------------------------------------------- // trigger_event_monologue tests (#119, D-035) // ----------------------------------------------------------------------- use crate::perception::interpretation::{ ObservationEvent, ObservationEventQueue, ObservationTrigger, }; use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue}; fn setup_event_world() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } fn spawn_event_player(world: &mut World) -> Entity { world .spawn(( PlayerCharacter, TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), ConversationEventBuffer::default(), )) .id() } fn run_event_system(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_event_monologue); schedule.run(world); } #[test] fn observe_npc_fires_on_new_entity_event() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Push a NewEntity observation event at tick 1 (player starts at tick 0) world.resource_mut::().tick = 5; let _target_entity = world.spawn_empty().id(); world .resource_mut::() .push(ObservationEvent { tick: 1, trigger: ObservationTrigger::NewEntity { entity: StableId(42), location: TilePosition::new(12, 12, 0), }, observer: player, }); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_some(), "observe_npc should fire for NewEntity event" ); let event = buf.event.as_ref().unwrap(); assert!( event.id.starts_with("observe_npc_"), "should use observe_npc fallback lines, got: {}", event.id ); } #[test] fn observe_npc_ignores_already_processed_events() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Set last_observation_tick so events at tick 1 are already processed world .get_mut::(player) .unwrap() .last_observation_tick = 5; world .resource_mut::() .push(ObservationEvent { tick: 3, // older than last_observation_tick trigger: ObservationTrigger::NewEntity { entity: StableId(42), location: TilePosition::new(12, 12, 0), }, observer: player, }); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_none(), "should not fire for already-processed observation events" ); } #[test] fn hear_sound_fires_on_machinery_in_range() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(12, 10, 0), // distance 2 from player at (10,10) SoundEventKind::Machinery, 0.8, crate::knowledge::types::SoundRange::Medium, None, )); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_some(), "hear_sound should fire for Machinery sound in range" ); assert!(buf.event.as_ref().unwrap().id.starts_with("hear_sound_")); } #[test] fn hear_sound_ignores_footstep() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(11, 10, 0), SoundEventKind::Footstep, 0.5, crate::knowledge::types::SoundRange::Close, None, )); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!( buf.event.is_none(), "Footstep sounds should not trigger monologue" ); } #[test] fn hear_sound_ignores_out_of_range() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); // Machinery sound at distance 20 with Close range (3 tiles) world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(30, 10, 0), // distance 20 from (10,10) SoundEventKind::Machinery, 0.8, crate::knowledge::types::SoundRange::Close, None, )); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!( buf.event.is_none(), "sounds beyond range should not trigger monologue" ); } #[test] fn witness_interaction_fires_on_conversation_event() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Pre-fill ConversationEventBuffer with an overheard conversation world .get_mut::(player) .unwrap() .events .push(crate::simulation::conversation::ConversationEvent { occluded_line: "Keep your head down today.".to_string(), speaker_id: 100, target_id: 101, speaker_name: "Worker".to_string(), target_name: "Courier".to_string(), speaker_color_index: 0, target_color_index: 1, }); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_some(), "witness_interaction should fire when conversation overheard" ); assert!(buf.event.as_ref().unwrap().id.starts_with("witness_")); } #[test] fn post_conversation_fires_on_queue_entry() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); let npc = world.spawn_empty().id(); world.resource_mut::().push(npc); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_some(), "post_conversation should fire when queue has entries" ); assert!(buf.event.as_ref().unwrap().id.starts_with("post_conv_")); } #[test] fn post_conversation_queue_drained_even_when_buffer_full() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); let npc = world.spawn_empty().id(); // Pre-fill buffer (another system wrote first) world.get_mut::(player).unwrap().event = Some(MonologueEvent { id: "existing".to_string(), text: "Already have something.".to_string(), duration_seconds: 5.0, }); world.resource_mut::().push(npc); run_event_system(&mut world); // Buffer should still have the original event let buf = world.get::(player).unwrap(); assert_eq!(buf.event.as_ref().unwrap().id, "existing"); // Queue should be drained even though we didn't fire assert!( world.resource::().is_empty(), "queue must be drained even when buffer is full" ); } #[test] fn existing_buffer_not_overridden_by_event_trigger() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Pre-fill buffer world.get_mut::(player).unwrap().event = Some(MonologueEvent { id: "prior_line".to_string(), text: "I was already thinking.".to_string(), duration_seconds: 5.0, }); // Push observation event that would normally fire world .resource_mut::() .push(ObservationEvent { tick: 1, trigger: ObservationTrigger::NewEntity { entity: StableId(99), location: TilePosition::new(12, 12, 0), }, observer: player, }); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert_eq!( buf.event.as_ref().unwrap().id, "prior_line", "event trigger should not override existing monologue" ); } #[test] fn priority_observe_npc_over_hear_sound() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Both triggers present: observe_npc should win world .resource_mut::() .push(ObservationEvent { tick: 1, trigger: ObservationTrigger::NewEntity { entity: StableId(42), location: TilePosition::new(12, 12, 0), }, observer: player, }); world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(11, 10, 0), SoundEventKind::Machinery, 0.8, crate::knowledge::types::SoundRange::Medium, None, )); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.as_ref().unwrap().id.starts_with("observe_npc_"), "observe_npc should have priority over hear_sound" ); } #[test] fn priority_hear_sound_over_witness_interaction() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); // Sound event world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(11, 10, 0), SoundEventKind::Alert, 1.0, crate::knowledge::types::SoundRange::Medium, None, )); // Conversation event world .get_mut::(player) .unwrap() .events .push(crate::simulation::conversation::ConversationEvent { occluded_line: "Test".to_string(), speaker_id: 100, target_id: 101, speaker_name: "A".to_string(), target_name: "B".to_string(), speaker_color_index: 0, target_color_index: 1, }); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.as_ref().unwrap().id.starts_with("hear_sound_"), "hear_sound should have priority over witness_interaction" ); } #[test] fn event_trigger_updates_last_fired_tick() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); world.resource_mut::().tick = 42; let npc = world.spawn_empty().id(); world.resource_mut::().push(npc); run_event_system(&mut world); let state = world.get::(player).unwrap(); assert_eq!( state.last_fired_tick, 42, "event trigger should update last_fired_tick" ); } #[test] fn event_trigger_records_shown_id() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); let npc = world.spawn_empty().id(); world.resource_mut::().push(npc); run_event_system(&mut world); let buf = world.get::(player).unwrap(); let fired_id = buf.event.as_ref().unwrap().id.clone(); let state = world.get::(player).unwrap(); assert!( state.shown_ids.contains(&fired_id), "fired line ID should be recorded in shown_ids" ); } #[test] fn no_events_produces_no_monologue() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!(buf.event.is_none(), "no events should produce no monologue"); } #[test] fn hear_sound_alert_fires() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(10, 11, 0), // distance 1 SoundEventKind::Alert, 1.0, crate::knowledge::types::SoundRange::Long, None, )); run_event_system(&mut world); let buf = world.get::(player).unwrap(); assert!( buf.event.is_some(), "Alert sounds should trigger hear_sound monologue" ); } #[test] fn hear_sound_ignores_different_z_level() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); // Sound on z=1, player on z=0 world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(10, 11, 1), // same xy but different z SoundEventKind::Machinery, 0.8, crate::knowledge::types::SoundRange::Medium, None, )); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!( buf.event.is_none(), "sounds on different z-level should not trigger monologue" ); } #[test] fn hardcoded_lines_all_valid() { for lines in &[ OBSERVE_NPC_LINES, HEAR_SOUND_LINES, WITNESS_INTERACTION_LINES, POST_CONVERSATION_LINES, ] { assert!(!lines.is_empty()); for (id, text) in *lines { assert!(!id.is_empty(), "line id should not be empty"); assert!(!text.is_empty(), "text for {} should be non-empty", id); } } } // ----------------------------------------------------------------------- // Constant assertions // ----------------------------------------------------------------------- // ----------------------------------------------------------------------- // hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep) // ----------------------------------------------------------------------- #[test] fn hear_sound_ignores_voice_kind() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); // Voice sound in range — should NOT trigger (routine background noise) world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(11, 10, 0), // distance 1 SoundEventKind::Voice, 0.7, crate::knowledge::types::SoundRange::Medium, None, )); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!( buf.event.is_none(), "Voice sounds are routine and must NOT trigger hear_sound monologue" ); } #[test] fn hear_sound_ignores_ambient_kind() { let mut world = setup_event_world(); let _player = spawn_event_player(&mut world); // Ambient sound in range — should NOT trigger (background atmosphere) world .resource_mut::() .events .push(SoundEvent::at( &TilePosition::new(10, 12, 0), // distance 2 SoundEventKind::Ambient, 0.9, crate::knowledge::types::SoundRange::Long, None, )); run_event_system(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); let buf = buf_query.single(&world).unwrap(); assert!( buf.event.is_none(), "Ambient sounds are routine and must NOT trigger hear_sound monologue" ); } #[test] fn observation_tick_tracking_updated() { let mut world = setup_event_world(); let player = spawn_event_player(&mut world); world .resource_mut::() .push(ObservationEvent { tick: 7, trigger: ObservationTrigger::NewEntity { entity: StableId(42), location: TilePosition::new(12, 12, 0), }, observer: player, }); run_event_system(&mut world); let state = world.get::(player).unwrap(); assert_eq!( state.last_observation_tick, 7, "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::events::{process_knowledge_events, KnowledgeEvent}; use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::{ EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, RelationshipState, StableId, }; use crate::knowledge::{ EntityRegistry, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, }; 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" ); } }