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>
322 lines
10 KiB
Rust
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");
|
|
}
|
|
}
|