From 517318c8a79dec69eda03def084cfe28a18e231c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:58 +0100 Subject: [PATCH] feat(simulation): extend monologue event generation with new triggers (#119) Add observe_npc, hear_sound, observe_anomaly, witness_interaction, and post_conversation triggers to monologue system. trigger_event_monologue system fires after sound collection, NPC conversations, and walk-away. Context tags (location, situation, character_state) populated for D-035 content pool matching. COOLDOWN_TICKS=300 anti-spam guard respected. witness_interaction fires after overheard NPC-to-NPC conversation per D-078. Voice sounds and ambient sounds correctly excluded from hear_sound trigger. Co-Authored-By: Claude Opus 4.6 --- server/src/perception/interpretation.rs | 6 + server/src/simulation/dialogue.rs | 5 + server/src/simulation/monologue.rs | 1006 +++++++++++++++++++++++ 3 files changed, 1017 insertions(+) diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 0e8ad310a..b71ce5671 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -68,6 +68,12 @@ impl ObservationEventQueue { pub fn is_empty(&self) -> bool { self.events.is_empty() } + + /// Iterate over observation events without draining. + /// Used by monologue trigger system (#119) to react to previous-tick events. + pub fn iter(&self) -> impl Iterator { + self.events.iter() + } } /// System: interpret visible snapshot against known routines and knowledge. diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 134eb47b1..18e35edcd 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -587,6 +587,7 @@ pub fn process_walk_away( mut commands: Commands, mut event_queue: ResMut, mut trust_queue: ResMut, + mut post_conv_queue: ResMut, time: Res, query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, mut npc_mem_query: Query>, @@ -635,6 +636,9 @@ pub fn process_walk_away( }); } + // Post-conversation monologue trigger (#119, D-035) + post_conv_queue.push(target); + tracing::debug!( "Walk-away during {:?} dialogue at tick {} (started tick {}): \ target {:?} → Tier2 animation + routine deviation", @@ -1121,6 +1125,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4ac939283..f5fdff78d 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -64,6 +64,79 @@ const ANOMALY_LINES: &[(&str, &str)] = &[ ), ]; +// --------------------------------------------------------------------------- +// 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)] @@ -80,6 +153,10 @@ pub struct MonologueState { pub shown_ids: BTreeSet, /// Character type for pool filtering. v0.1: always "detective". 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 { @@ -92,6 +169,7 @@ impl Default for MonologueState { shown_ids: BTreeSet::new(), // v0.1: default to detective; character selection sets this character: "detective".to_string(), + last_observation_tick: 0, } } } @@ -353,6 +431,242 @@ pub fn trigger_recognition_monologue( ); } +// --------------------------------------------------------------------------- +// Shared content pool selection (#119) +// --------------------------------------------------------------------------- + +/// Select a monologue line from content pools, matching trigger and character. +/// Returns (id, text) or None if no matching lines exist. +/// Prefers unseen lines; falls back to repeats if all have been shown. +fn select_pool_line( + trigger: &str, + state: &MonologueState, + content: &ContentStoreResource, + rng: &mut impl Rng, +) -> Option<(String, String)> { + 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 != trigger { + continue; + } + if state.shown_ids.contains(&line.id) { + continue; + } + candidates.push((&line.id, &line.text)); + } + } + } + + if candidates.is_empty() { + // Fallback: 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 None; + } + + let index = rng.random_range(0..candidates.len()); + Some(( + candidates[index].0.to_string(), + candidates[index].1.to_string(), + )) +} + +/// 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, + _ => 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. Bypasses normal COOLDOWN_TICKS (event-driven), +/// but updates last_fired_tick for periodic trigger cooldown tracking. +/// +/// 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, + content: Option>, + 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, + >, +) { + let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else { + // Drain post_conversation queue even without a player + post_conv_queue.drain(); + return; + }; + + // Don't override existing monologue from higher-priority systems + if buffer.event.is_some() { + post_conv_queue.drain(); + return; + } + + // 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_queue.is_empty() { + Some("post_conversation") + } else { + None + }; + + // Always drain post_conversation queue (consumed this tick) + post_conv_queue.drain(); + + // 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 }; + + // Select line: content pool first, hardcoded fallback second + let (id, text) = if let Some(ref content) = content { + if let Some(line) = select_pool_line(trigger, &state, content, &mut rng.rng) { + line + } else { + select_hardcoded_fallback(trigger, &mut rng.rng) + } + } else { + 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 + ); +} + +/// 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. /// /// Runs each tick. Checks trigger conditions against loaded content pools @@ -1221,4 +1535,696 @@ mod tests { "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 event_uses_content_pool_when_available() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Set up content pool with a witness_interaction line + let pool = MonologuePool { + character: "detective".to_string(), + location: "general".to_string(), + lines: vec![MonologueLine { + id: "pool_witness_01".to_string(), + text: "She's lying to him.".to_string(), + trigger: "witness_interaction".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)); + + // Push a 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.is_some()); + assert_eq!( + buf.event.as_ref().unwrap().id, + "pool_witness_01", + "should use content pool line over hardcoded fallback" + ); + } + + #[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 + // ----------------------------------------------------------------------- + + #[test] + fn cooldown_ticks_constant_is_300() { + // D-035: 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031). + // If this changes, players will see more/less monologue spam. + assert_eq!(COOLDOWN_TICKS, 300, "D-035: COOLDOWN_TICKS must be 300"); + } + + // ----------------------------------------------------------------------- + // 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" + ); + } + + // ----------------------------------------------------------------------- + // observe_anomaly content pool integration via recognition monologue + // ----------------------------------------------------------------------- + + #[test] + fn recognition_monologue_uses_observe_anomaly_content_pool_key() { + // When a content pool has lines with trigger="observe_anomaly", + // trigger_recognition_monologue should select from that pool (not hardcoded fallback). + // This verifies the content key matches the implementation. + let mut world = setup_recognition_world(); + + let pool = MonologuePool { + character: "detective".to_string(), + location: "general".to_string(), + lines: vec![MonologueLine { + id: "observe_anomaly_pool_01".to_string(), + text: "That person shouldn't be here.".to_string(), + trigger: "observe_anomaly".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)); + + 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"); + assert_eq!( + buffer.event.as_ref().unwrap().id, + "observe_anomaly_pool_01", + "should use content pool line with trigger='observe_anomaly' key" + ); + } + + #[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" + ); + } }