Files
settled-reach/server/src/simulation/monologue.rs
T
jpmschweitzerandClaude Opus 4.6 3c106aa2d0 feat(simulation): complete interaction and monologue pipelines (#413, #414, #415)
Three fixes to make the gameplay loop functional end-to-end:

- Add Interactable component to NPC spawn so E-prompt detection works
- Build monologue trigger system (enter_location + time_idle) with
  MonologueBuffer/MonologueState components, wire through ObserverSnapshot
  as current_monologue field, decode on client and display via HUD
- Change PlayerAction::Interact from unit to struct variant carrying
  optional target_entity_id and verb fields

Bumps protocol version from 4 to 5. Regenerates MessagePack fixtures.
All 200 tests pass (170 unit + 30 integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:23:47 +01:00

322 lines
10 KiB
Rust

// 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<String>,
/// 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<MonologueEvent>,
}
impl MonologueBuffer {
/// Drain and return the monologue event, leaving the buffer empty.
pub fn take(&mut self) -> Option<MonologueEvent> {
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<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
With<PlayerCharacter>,
>,
) {
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::<SimulationTime>();
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::<SimulationTime>().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::<SimulationTime>().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");
}
}