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>
This commit is contained in:
@@ -86,8 +86,12 @@ pub fn process_player_input(
|
||||
time.tick_rate = rate;
|
||||
tracing::debug!("Tick rate set to {:?} by player input", rate);
|
||||
}
|
||||
PlayerAction::Interact => {
|
||||
tracing::trace!("Interact action — no-op for Sprint 1");
|
||||
PlayerAction::Interact { target_entity_id, verb } => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
@@ -131,7 +135,7 @@ mod tests {
|
||||
});
|
||||
queue.push(PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::Interact,
|
||||
action: PlayerAction::Interact { target_entity_id: None, verb: None },
|
||||
});
|
||||
let inputs = queue.drain_for_tick(3);
|
||||
assert_eq!(inputs.len(), 2);
|
||||
|
||||
@@ -6,6 +6,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
pub mod monologue;
|
||||
pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user