// 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::HashSet; use bevy_ecs::prelude::*; use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::content::ContentStoreResource; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; /// Minimum ticks between monologue lines (prevents spam). /// At 10 ticks/game-minute, 300 ticks = 30 game-minutes. const COOLDOWN_TICKS: u64 = 300; /// Ticks of idle (no movement) before a time_idle monologue fires. /// 100 ticks = 10 game-minutes. const IDLE_THRESHOLD_TICKS: u64 = 100; /// 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.", ), ]; /// 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: HashSet, /// Character type for pool filtering. v0.1: always "detective". pub character: String, } impl Default for MonologueState { fn default() -> Self { Self { last_fired_tick: 0, last_position: None, idle_ticks: 0, entered: false, shown_ids: HashSet::new(), // v0.1: default to detective; character selection sets this character: "detective".to_string(), } } } /// 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() } } /// 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, content: Option>, 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; }; // Try content pools for observe_anomaly trigger lines let line = if let Some(ref content) = content { let character = state.character.as_str(); let mut candidates: Vec<(&str, &str)> = Vec::new(); for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; } for line in &pool.lines { if line.trigger != "observe_anomaly" { continue; } if state.shown_ids.contains(&line.id) { continue; } candidates.push((&line.id, &line.text)); } } } if candidates.is_empty() { // Fallback: allow repeats from content pools for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; } for line in &pool.lines { if line.trigger != "observe_anomaly" { continue; } candidates.push((&line.id, &line.text)); } } } } if !candidates.is_empty() { let i = rng.rng.random_range(0..candidates.len()); Some((candidates[i].0.to_string(), candidates[i].1.to_string())) } else { None } } else { None }; // Use content pool line or hardcoded fallback let (id, text) = if let Some((id, text)) = line { (id, text) } else { let i = rng.rng.random_range(0..RECOGNITION_LINES.len()); ( 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, ); } /// Monologue trigger system. /// /// Runs each tick. Checks trigger conditions against loaded content pools /// and writes a MonologueEvent to MonologueBuffer when a line should fire. /// /// v0.1 triggers: /// - `enter_location`: fires once on first tick (session start) /// - `time_idle`: fires after IDLE_THRESHOLD_TICKS of no player movement pub fn trigger_monologue( time: Res, content: Option>, mut rng: ResMut, mut query: Query< (&TilePosition, &mut MonologueState, &mut MonologueBuffer), With, >, ) { let Some(content) = content else { return }; let Ok((pos, mut state, mut buffer)) = query.single_mut() else { return; }; // Track idle time let current_pos = (pos.x, pos.y); if let Some(last) = state.last_position { if last == current_pos { state.idle_ticks += 1; } else { state.idle_ticks = 0; } } state.last_position = Some(current_pos); // Cooldown check if time.tick > 0 && time.tick - state.last_fired_tick < COOLDOWN_TICKS { return; } // Determine which trigger to attempt let trigger = if !state.entered { state.entered = true; Some("enter_location") } else if state.idle_ticks >= IDLE_THRESHOLD_TICKS { Some("time_idle") } else { None }; let Some(trigger) = trigger else { return }; // Collect candidate lines from all district monologue pools let character = state.character.as_str(); let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text) for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; } for line in &pool.lines { if line.trigger != trigger { continue; } if state.shown_ids.contains(&line.id) { continue; } candidates.push((&line.id, &line.text)); } } } if candidates.is_empty() { // All lines for this trigger have been shown; allow repeats for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; } for line in &pool.lines { if line.trigger != trigger { continue; } candidates.push((&line.id, &line.text)); } } } } if candidates.is_empty() { return; } // Select a random line let index = rng.rng.random_range(0..candidates.len()); let (id, text) = candidates[index]; buffer.event = Some(MonologueEvent { id: id.to_string(), text: text.to_string(), duration_seconds: DISPLAY_DURATION, }); state.shown_ids.insert(id.to_string()); state.last_fired_tick = time.tick; // Reset idle counter so time_idle doesn't fire again immediately state.idle_ticks = 0; tracing::debug!( "Monologue fired: trigger={}, id={}, tick={}", trigger, id, time.tick ); } #[cfg(test)] mod tests { use super::*; use crate::content::loader::{ContentStore, DistrictContent}; use crate::content::types::{MonologueLine, MonologuePool}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use bevy_ecs::world::World; fn setup_world_with_content() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); // Create test monologue content let pool = MonologuePool { character: "detective".to_string(), location: "general".to_string(), lines: vec![ MonologueLine { id: "test_enter_001".to_string(), text: "Sova Transit District. Let's narrow that down.".to_string(), trigger: "enter_location".to_string(), prerequisites: None, priority: None, cooldown: None, tags: vec![], }, MonologueLine { id: "test_idle_001".to_string(), text: "Everyone knows I'm Commission.".to_string(), trigger: "time_idle".to_string(), prerequisites: None, priority: None, cooldown: None, tags: vec![], }, ], }; let mut district = DistrictContent::default(); district.monologue_pools.push(pool); let mut store = ContentStore::default(); store.districts.insert("test".to_string(), district); world.insert_resource(ContentStoreResource(store)); world } #[test] fn enter_location_fires_on_first_tick() { let mut world = setup_world_with_content(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_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_eq!(event.id, "test_enter_001"); } #[test] fn cooldown_prevents_spam() { let mut world = setup_world_with_content(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState::default(), MonologueBuffer::default(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_monologue); // First tick: should fire enter_location schedule.run(&mut world); // Consume the buffer let mut query = world.query::<&mut MonologueBuffer>(); query.single_mut(&mut world).unwrap().take(); // Advance a few ticks (still in cooldown) world.resource_mut::().tick = 10; // Set idle ticks high to try to trigger time_idle let mut state_query = world.query::<&mut MonologueState>(); state_query.single_mut(&mut world).unwrap().idle_ticks = IDLE_THRESHOLD_TICKS + 1; schedule.run(&mut world); // Should NOT fire — cooldown active let mut query = world.query::<&MonologueBuffer>(); let buffer = query.single(&world).unwrap(); assert!(buffer.event.is_none()); } #[test] fn time_idle_fires_after_threshold() { let mut world = setup_world_with_content(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), MonologueState { entered: true, // Skip enter_location last_position: Some((5, 5)), idle_ticks: IDLE_THRESHOLD_TICKS, // At threshold ..Default::default() }, MonologueBuffer::default(), )); // Advance past cooldown world.resource_mut::().tick = COOLDOWN_TICKS + 1; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(trigger_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_eq!(event.id, "test_idle_001"); } // ----------------------------------------------------------------------- // 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" ); } }