// 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. 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; /// 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: Vec, /// 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: Vec::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() } } /// 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_id, district) in &content.0.districts { 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_id, district) in &content.0.districts { 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.push(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"); } }