Formatting-only changes across server source and test files. No logic changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1219 lines
40 KiB
Rust
1219 lines
40 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.
|
|
//
|
|
// Sprint anomaly monologue (#428, D-055):
|
|
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
|
|
// fires retroactively. Detection in observer pipeline, processing here.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
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;
|
|
|
|
/// Tick delay before a sprint anomaly monologue fires (#428, D-055).
|
|
/// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds.
|
|
/// Tunable: adjust based on actual client frame rate.
|
|
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
|
|
|
|
/// Hardcoded v0.1 recognition monologue lines (#451, D-060).
|
|
/// Fire DURING cognitive delay (when grey blob appears). Future: move to
|
|
/// content pools with trigger="observe_anomaly" + character match.
|
|
const RECOGNITION_LINES: &[(&str, &str)] = &[
|
|
("recognition_01", "Wait \u{2014} I know that walk."),
|
|
(
|
|
"recognition_02",
|
|
"Those footsteps... I've heard that pattern before.",
|
|
),
|
|
("recognition_03", "Something about that silhouette..."),
|
|
];
|
|
|
|
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
|
|
/// Future: move to content pools with trigger="sprint_anomaly".
|
|
const ANOMALY_LINES: &[(&str, &str)] = &[
|
|
(
|
|
"sprint_anomaly_01",
|
|
"Wait \u{2014} something wasn't right back there.",
|
|
),
|
|
(
|
|
"sprint_anomaly_02",
|
|
"Hold on. That face... why were they there?",
|
|
),
|
|
(
|
|
"sprint_anomaly_03",
|
|
"Something's off. That wasn't where they should be.",
|
|
),
|
|
];
|
|
|
|
/// 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: HashSet<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: HashSet::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()
|
|
}
|
|
}
|
|
|
|
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
|
|
///
|
|
/// When sprinting past a Contradicted entity, the observer pipeline detects
|
|
/// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing
|
|
/// system fires a retroactive monologue ("Wait — was that...?").
|
|
///
|
|
/// At most one anomaly is pending at a time (first-in wins).
|
|
#[derive(Component, Debug, Default)]
|
|
pub struct SprintAnomalyQueue {
|
|
pending: Option<SprintAnomalyEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SprintAnomalyEntry {
|
|
entity_id: u64,
|
|
detected_tick: u64,
|
|
}
|
|
|
|
impl SprintAnomalyQueue {
|
|
/// Queue an anomaly if none is pending.
|
|
/// First-in wins: subsequent anomalies are ignored until the current one fires.
|
|
pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) {
|
|
if self.pending.is_none() {
|
|
self.pending = Some(SprintAnomalyEntry {
|
|
entity_id,
|
|
detected_tick: tick,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Take the pending anomaly if the delay has elapsed.
|
|
/// Returns the entity_id that triggered the anomaly.
|
|
pub fn take_ready(&mut self, current_tick: u64) -> Option<u64> {
|
|
if let Some(entry) = &self.pending {
|
|
if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS {
|
|
let entity_id = entry.entity_id;
|
|
self.pending = None;
|
|
return Some(entity_id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether an anomaly is pending (detected but not yet fired).
|
|
pub fn has_pending(&self) -> bool {
|
|
self.pending.is_some()
|
|
}
|
|
}
|
|
|
|
/// Process delayed sprint anomaly monologues (#428, D-055).
|
|
///
|
|
/// Checks SprintAnomalyQueue for entries past the delay threshold and fires
|
|
/// a "double-take" monologue. Bypasses normal monologue cooldown since sprint
|
|
/// anomalies are event-driven, not periodic. Updates last_fired_tick so
|
|
/// subsequent normal monologue respects cooldown after the anomaly fires.
|
|
///
|
|
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
|
|
pub fn process_sprint_anomaly_monologue(
|
|
time: Res<SimulationTime>,
|
|
mut rng: ResMut<SimRng>,
|
|
mut query: Query<
|
|
(
|
|
&mut SprintAnomalyQueue,
|
|
&mut MonologueBuffer,
|
|
&mut MonologueState,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
) {
|
|
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
|
|
return;
|
|
};
|
|
|
|
// Don't override existing monologue from trigger_monologue
|
|
if buffer.event.is_some() {
|
|
return;
|
|
}
|
|
|
|
if let Some(_entity_id) = queue.take_ready(time.tick) {
|
|
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
|
|
let (id, text) = ANOMALY_LINES[index];
|
|
|
|
buffer.event = Some(MonologueEvent {
|
|
id: id.to_string(),
|
|
text: text.to_string(),
|
|
duration_seconds: DISPLAY_DURATION,
|
|
});
|
|
|
|
// Update last_fired_tick so normal monologue respects cooldown
|
|
state.last_fired_tick = time.tick;
|
|
|
|
tracing::debug!(
|
|
"Sprint anomaly monologue fired: id={}, tick={}",
|
|
id,
|
|
time.tick
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Recognition monologue trigger (#451, D-060).
|
|
///
|
|
/// Fires DURING cognitive delay, not after — "the monologue IS the recognition."
|
|
/// When a new entity enters fog (PendingRecognition queued by emit_observation_events),
|
|
/// this system fires a recognition monologue on the next tick.
|
|
///
|
|
/// Priority: anomalous entities (AnomalyMarker) get first pick. Only one
|
|
/// recognition monologue fires per tick. Bypasses normal monologue cooldown
|
|
/// (event-driven), but updates last_fired_tick for normal cooldown tracking.
|
|
///
|
|
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
|
|
pub fn trigger_recognition_monologue(
|
|
time: Res<SimulationTime>,
|
|
content: Option<Res<ContentStoreResource>>,
|
|
mut rng: ResMut<SimRng>,
|
|
mut query: Query<
|
|
(
|
|
&mut crate::perception::cognitive_delay::CognitiveDelay,
|
|
&mut MonologueBuffer,
|
|
&mut MonologueState,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
|
) {
|
|
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
|
|
return;
|
|
};
|
|
|
|
// Don't override existing monologue from trigger_monologue
|
|
if buffer.event.is_some() {
|
|
return;
|
|
}
|
|
|
|
// No pending recognitions → nothing to do
|
|
if cognitive_delay.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Find the first unfired pending recognition. Prioritize anomalous entities.
|
|
let pending = cognitive_delay.pending_mut();
|
|
let target_idx = {
|
|
// First pass: anomalous + unfired
|
|
let anomaly_idx = pending
|
|
.iter()
|
|
.position(|p| !p.monologue_fired && anomaly_markers.get(p.target).is_ok());
|
|
if let Some(idx) = anomaly_idx {
|
|
Some(idx)
|
|
} else {
|
|
// Second pass: any unfired
|
|
pending.iter().position(|p| !p.monologue_fired)
|
|
}
|
|
};
|
|
|
|
let Some(idx) = target_idx else {
|
|
return;
|
|
};
|
|
|
|
// Try content pools for observe_anomaly trigger lines
|
|
let line = if let Some(ref content) = content {
|
|
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 != "observe_anomaly" {
|
|
continue;
|
|
}
|
|
if state.shown_ids.contains(&line.id) {
|
|
continue;
|
|
}
|
|
candidates.push((&line.id, &line.text));
|
|
}
|
|
}
|
|
}
|
|
|
|
if candidates.is_empty() {
|
|
// Fallback: allow repeats from content pools
|
|
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 != "observe_anomaly" {
|
|
continue;
|
|
}
|
|
candidates.push((&line.id, &line.text));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !candidates.is_empty() {
|
|
let i = rng.rng.random_range(0..candidates.len());
|
|
Some((candidates[i].0.to_string(), candidates[i].1.to_string()))
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Use content pool line or hardcoded fallback
|
|
let (id, text) = if let Some((id, text)) = line {
|
|
(id, text)
|
|
} else {
|
|
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
|
(
|
|
RECOGNITION_LINES[i].0.to_string(),
|
|
RECOGNITION_LINES[i].1.to_string(),
|
|
)
|
|
};
|
|
|
|
buffer.event = Some(MonologueEvent {
|
|
id: id.clone(),
|
|
text,
|
|
duration_seconds: DISPLAY_DURATION,
|
|
});
|
|
|
|
state.shown_ids.insert(id.clone());
|
|
state.last_fired_tick = time.tick;
|
|
|
|
// Mark this pending recognition as having fired its monologue
|
|
pending[idx].monologue_fired = true;
|
|
|
|
tracing::debug!(
|
|
"Recognition monologue fired: id={}, tick={}, target_stable_id={}",
|
|
id,
|
|
time.tick,
|
|
pending[idx].stable_id.0,
|
|
);
|
|
}
|
|
|
|
/// 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 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() {
|
|
// All lines for this trigger have been shown; 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;
|
|
}
|
|
|
|
// 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.insert(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");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SprintAnomalyQueue unit tests (#428, D-055)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn anomaly_queue_default_is_empty() {
|
|
let queue = SprintAnomalyQueue::default();
|
|
assert!(!queue.has_pending());
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_push_stores_entry() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
assert!(queue.has_pending());
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_first_in_wins() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
queue.push_anomaly(99, 101); // Should be ignored
|
|
assert!(queue.has_pending());
|
|
|
|
// The first anomaly (entity 42) should be the one that fires
|
|
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
|
|
assert_eq!(result, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_take_ready_before_delay() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
|
|
// Not enough delay yet
|
|
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1);
|
|
assert_eq!(result, None);
|
|
assert!(queue.has_pending()); // Still pending
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_take_ready_at_delay() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
|
|
// Exactly at delay threshold
|
|
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
|
|
assert_eq!(result, Some(42));
|
|
assert!(!queue.has_pending()); // Consumed
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_take_ready_clears_entry() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
|
|
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
|
|
// Second take should return None
|
|
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10);
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_queue_can_push_after_take() {
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 100);
|
|
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
|
|
assert!(!queue.has_pending());
|
|
|
|
// Push a new anomaly after the first was consumed
|
|
queue.push_anomaly(99, 300);
|
|
assert!(queue.has_pending());
|
|
let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS);
|
|
assert_eq!(result, Some(99));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// process_sprint_anomaly_monologue system tests (#428, D-055)
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn setup_anomaly_world() -> World {
|
|
let mut world = World::new();
|
|
world.init_resource::<SimulationTime>();
|
|
world.insert_resource(SimRng::new(42));
|
|
world
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_fires_after_delay() {
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0); // Queued at tick 0
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
queue,
|
|
));
|
|
|
|
// Advance past delay
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_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!(event.id.starts_with("sprint_anomaly_"));
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_not_before_delay() {
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0);
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
queue,
|
|
));
|
|
|
|
// Still within delay
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
schedule.run(&mut world);
|
|
|
|
let mut query = world.query::<&MonologueBuffer>();
|
|
let buffer = query.single(&world).unwrap();
|
|
assert!(buffer.event.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_does_not_override_existing() {
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0);
|
|
|
|
// Pre-fill the monologue buffer (as if trigger_monologue already wrote)
|
|
let mut buffer = MonologueBuffer::default();
|
|
buffer.event = Some(MonologueEvent {
|
|
id: "existing_line".to_string(),
|
|
text: "I should keep this.".to_string(),
|
|
duration_seconds: 5.0,
|
|
});
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
buffer,
|
|
queue,
|
|
));
|
|
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
schedule.run(&mut world);
|
|
|
|
// Should still have the original line, not the anomaly line
|
|
let mut query = world.query::<&MonologueBuffer>();
|
|
let buffer = query.single(&world).unwrap();
|
|
assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line");
|
|
|
|
// Queue should still be pending (not consumed)
|
|
let mut q = world.query::<&SprintAnomalyQueue>();
|
|
assert!(q.single(&world).unwrap().has_pending());
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_updates_last_fired_tick() {
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0);
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
queue,
|
|
));
|
|
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
schedule.run(&mut world);
|
|
|
|
let mut query = world.query::<&MonologueState>();
|
|
let state = query.single(&world).unwrap();
|
|
assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS);
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_clears_queue_after_fire() {
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0);
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
queue,
|
|
));
|
|
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
schedule.run(&mut world);
|
|
|
|
let mut query = world.query::<&SprintAnomalyQueue>();
|
|
let queue = query.single(&world).unwrap();
|
|
assert!(!queue.has_pending());
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_monologue_no_crash_without_queue() {
|
|
// Backward compat: entities without SprintAnomalyQueue don't crash
|
|
let mut world = setup_anomaly_world();
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
// Should not panic
|
|
schedule.run(&mut world);
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_delay_constant_is_90_ticks() {
|
|
// D-055 spec: ~1.5 real seconds at 60fps → 90 ticks
|
|
assert_eq!(ANOMALY_DELAY_TICKS, 90);
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_lines_all_valid() {
|
|
// All hardcoded v0.1 lines should have id prefix and non-empty text
|
|
assert!(!ANOMALY_LINES.is_empty());
|
|
for (id, text) in ANOMALY_LINES {
|
|
assert!(
|
|
id.starts_with("sprint_anomaly_"),
|
|
"id={} should start with sprint_anomaly_",
|
|
id
|
|
);
|
|
assert!(!text.is_empty(), "text for {} should be non-empty", id);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// trigger_recognition_monologue tests (#451, D-060)
|
|
// -----------------------------------------------------------------------
|
|
|
|
use crate::knowledge::types::StableId;
|
|
use crate::perception::cognitive_delay::{
|
|
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
|
|
};
|
|
|
|
fn setup_recognition_world() -> World {
|
|
let mut world = World::new();
|
|
world.init_resource::<SimulationTime>();
|
|
world.insert_resource(SimRng::new(42));
|
|
world
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_fires_for_pending_recognition() {
|
|
let mut world = setup_recognition_world();
|
|
|
|
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 for pending recognition"
|
|
);
|
|
let event = buffer.event.as_ref().unwrap();
|
|
assert!(
|
|
event.id.starts_with("recognition_"),
|
|
"should use hardcoded recognition lines (no content pool)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_does_not_fire_twice() {
|
|
let mut world = setup_recognition_world();
|
|
|
|
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,
|
|
});
|
|
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(10, 10, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
cd,
|
|
))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(trigger_recognition_monologue);
|
|
|
|
// First tick: fires
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world
|
|
.query::<&MonologueBuffer>()
|
|
.single(&world)
|
|
.unwrap()
|
|
.event
|
|
.is_some(),
|
|
"first tick should fire"
|
|
);
|
|
|
|
// Consume the buffer
|
|
world.get_mut::<MonologueBuffer>(player).unwrap().take();
|
|
|
|
// Second tick: should NOT fire (monologue_fired = true)
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world
|
|
.query::<&MonologueBuffer>()
|
|
.single(&world)
|
|
.unwrap()
|
|
.event
|
|
.is_none(),
|
|
"second tick should not fire (already fired for this recognition)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_does_not_override_existing_buffer() {
|
|
let mut world = setup_recognition_world();
|
|
|
|
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,
|
|
});
|
|
|
|
// Pre-fill MonologueBuffer (e.g., from trigger_monologue)
|
|
let mut buffer = MonologueBuffer::default();
|
|
buffer.event = Some(MonologueEvent {
|
|
id: "existing_line".to_string(),
|
|
text: "Already have something to say.".to_string(),
|
|
duration_seconds: 5.0,
|
|
});
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(10, 10, 0),
|
|
MonologueState::default(),
|
|
buffer,
|
|
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_eq!(
|
|
buffer.event.as_ref().unwrap().id,
|
|
"existing_line",
|
|
"should not override existing monologue"
|
|
);
|
|
|
|
// monologue_fired should still be false (wasn't consumed)
|
|
let mut cd_query = world.query::<&CognitiveDelay>();
|
|
let cd = cd_query.single(&world).unwrap();
|
|
assert!(
|
|
!cd.pending()[0].monologue_fired,
|
|
"should not mark as fired when buffer was full"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_no_pending_is_noop() {
|
|
let mut world = setup_recognition_world();
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(10, 10, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
CognitiveDelay::default(),
|
|
));
|
|
|
|
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>();
|
|
assert!(
|
|
buf_query.single(&world).unwrap().event.is_none(),
|
|
"no pending recognitions → no monologue"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_prioritizes_anomalous_entities() {
|
|
let mut world = setup_recognition_world();
|
|
|
|
let normal_target = world.spawn_empty().id();
|
|
let anomalous_target = world.spawn(crate::perception::anomaly::AnomalyMarker).id();
|
|
|
|
let mut cd = CognitiveDelay::default();
|
|
// Normal entity added first
|
|
cd.push(PendingRecognition {
|
|
target: normal_target,
|
|
stable_id: StableId(1),
|
|
position: TilePosition::new(5, 5, 0),
|
|
delay_until_tick: NORMAL_DELAY_TICKS,
|
|
trigger: RecognitionTrigger::Normal,
|
|
monologue_fired: false,
|
|
});
|
|
// Anomalous entity added second
|
|
cd.push(PendingRecognition {
|
|
target: anomalous_target,
|
|
stable_id: StableId(2),
|
|
position: TilePosition::new(8, 8, 0),
|
|
delay_until_tick: NORMAL_DELAY_TICKS,
|
|
trigger: RecognitionTrigger::Normal,
|
|
monologue_fired: false,
|
|
});
|
|
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(10, 10, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
cd,
|
|
))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(trigger_recognition_monologue);
|
|
schedule.run(&mut world);
|
|
|
|
// Monologue should fire for anomalous entity (idx 1), not normal (idx 0)
|
|
let cd = world.get::<CognitiveDelay>(player).unwrap();
|
|
assert!(
|
|
!cd.pending()[0].monologue_fired,
|
|
"normal entity should NOT be fired first"
|
|
);
|
|
assert!(
|
|
cd.pending()[1].monologue_fired,
|
|
"anomalous entity should be fired first (priority)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_monologue_updates_last_fired_tick() {
|
|
let mut world = setup_recognition_world();
|
|
world.resource_mut::<SimulationTime>().tick = 50;
|
|
|
|
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: 50 + 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 state_query = world.query::<&MonologueState>();
|
|
assert_eq!(
|
|
state_query.single(&world).unwrap().last_fired_tick,
|
|
50,
|
|
"last_fired_tick should be updated for normal cooldown tracking"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognition_lines_all_valid() {
|
|
assert!(!RECOGNITION_LINES.is_empty());
|
|
for (id, text) in RECOGNITION_LINES {
|
|
assert!(
|
|
id.starts_with("recognition_"),
|
|
"id={} should start with recognition_",
|
|
id
|
|
);
|
|
assert!(!text.is_empty(), "text for {} should be non-empty", id);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn anomaly_full_cycle_detect_then_fire() {
|
|
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
|
|
let mut world = setup_anomaly_world();
|
|
let mut queue = SprintAnomalyQueue::default();
|
|
queue.push_anomaly(42, 0);
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
MonologueState::default(),
|
|
MonologueBuffer::default(),
|
|
queue,
|
|
));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_sprint_anomaly_monologue);
|
|
|
|
// Tick 89: still within delay — should NOT fire
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
|
|
schedule.run(&mut world);
|
|
|
|
let mut buf_query = world.query::<&MonologueBuffer>();
|
|
assert!(
|
|
buf_query.single(&world).unwrap().event.is_none(),
|
|
"should not fire before delay"
|
|
);
|
|
|
|
let mut q_query = world.query::<&SprintAnomalyQueue>();
|
|
assert!(
|
|
q_query.single(&world).unwrap().has_pending(),
|
|
"still pending before delay"
|
|
);
|
|
|
|
// Tick 90: delay elapsed — should fire
|
|
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
|
|
schedule.run(&mut world);
|
|
|
|
let mut buf_query = world.query::<&MonologueBuffer>();
|
|
let buffer = buf_query.single(&world).unwrap();
|
|
assert!(buffer.event.is_some(), "should fire at delay threshold");
|
|
let event = buffer.event.as_ref().unwrap();
|
|
assert!(event.id.starts_with("sprint_anomaly_"));
|
|
assert_eq!(event.duration_seconds, DISPLAY_DURATION);
|
|
|
|
// Queue should be cleared
|
|
let mut q_query = world.query::<&SprintAnomalyQueue>();
|
|
assert!(
|
|
!q_query.single(&world).unwrap().has_pending(),
|
|
"queue cleared after fire"
|
|
);
|
|
|
|
// last_fired_tick should be updated
|
|
let mut state_query = world.query::<&MonologueState>();
|
|
assert_eq!(
|
|
state_query.single(&world).unwrap().last_fired_tick,
|
|
ANOMALY_DELAY_TICKS,
|
|
"last_fired_tick updated for cooldown"
|
|
);
|
|
}
|
|
}
|