Files
settled-reach/server/src/simulation/monologue.rs
T
jpmschweitzerandClaude Opus 4.6 cae3d3ab85 refactor(simulation): strip archetype trace + HeritageRoot per cascade (#877, #878)
Sprint 37 dead-code sweep closing out two stale supersession chains:

#877 (D-167, 2026-03-24): Removes HeritageRoot type alias and
ZonePaletteModifier::Heritage variant from server/src/simulation/
generator.rs. The 7 abstract heritage roots were retired in favour of
the corridor cultural system; these two stubs were the only remaining
references.

#878 (D-032 + cascade rule): Strips the entire CharacterArchetype
(Smuggler/Detective) trace from the server. Per lead direction
2026-04-21 and the development cascade (CLAUDE.md), character/NPC/
verb-differentiation/monologue code is Phase 6 detail that should
not exist in code yet. The running archetype trace was pre-cascade
filler, not production — production is only the client's character-
creation UI and insert screens (client follow-up in #882).

Deleted:
- CharacterArchetype enum + StartupMessage.character_archetype field
- archetype_verb_label() + archetype branch of apply_phase2_verb_filter
  (D-057 character-verb differentiation — marked superseded)
- MonologueState.character partitioning
- Gauntlet archetype plumbing (setup_gauntlet no longer takes an archetype)
- server/content/schemas/drama_module.schema.yaml (zero Rust consumers)
- server/content/modules/tier1/smuggling_ring_v0_1.yaml
- server/tests/archetype_monologue.rs (regression guard for the removed system)
- server/tests/v01_integration_playthrough.rs (archetype-dependent)

Decision updates:
- decisions/content.md D-032 supersession rewritten to cite the cascade
  (v0.2 drop invalidated the prior D-117 framing).
- decisions/content.md D-035 tag taxonomy: `character` enum footnote
  updated; field noted as unused, do not reintroduce without a
  confirmed Phase 6 design.
- decisions/perception.md D-057: archetype-verb differentiation marked
  superseded.

Also bundles the types.rs version-field removal from #874 since the
file was already touched here.

Full trace audit in docs/architecture/sprint-37-878-audit.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:48 +02:00

2142 lines
73 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::BTreeSet;
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::npc_components::NpcName;
use crate::simulation::rng::EntityRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// 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.",
),
];
// ---------------------------------------------------------------------------
// Event-driven monologue triggers (#119, D-035)
// ---------------------------------------------------------------------------
/// Hardcoded v0.1 observe_npc monologue lines.
/// Fire when a new entity enters the player's field of view.
/// Future: move to content pools with trigger="observe_npc".
const OBSERVE_NPC_LINES: &[(&str, &str)] = &[
("observe_npc_01", "New face. Haven't seen them before."),
("observe_npc_02", "Someone I don't recognize."),
("observe_npc_03", "Who's that? They weren't here earlier."),
];
/// Hardcoded v0.1 hear_sound monologue lines.
/// Fire when the player hears a non-routine sound (Machinery, Alert).
/// Future: move to content pools with trigger="hear_sound".
const HEAR_SOUND_LINES: &[(&str, &str)] = &[
("hear_sound_01", "What was that?"),
(
"hear_sound_02",
"That sound \u{2014} not the usual background.",
),
("hear_sound_03", "Something just happened nearby."),
];
/// Hardcoded v0.1 post_conversation monologue lines.
/// Fire after a player-NPC dialogue concludes (walk-away or natural end).
/// Future: move to content pools with trigger="post_conversation".
const POST_CONVERSATION_LINES: &[(&str, &str)] = &[
("post_conv_01", "More questions than answers."),
("post_conv_02", "I'll have to think about what they said."),
(
"post_conv_03",
"Something about that exchange didn't sit right.",
),
];
/// Resource: signals that a player-NPC dialogue completed this tick.
/// Pushed by process_walk_away (D-064); drained by trigger_event_monologue.
#[derive(Resource, Debug, Default)]
pub struct PostConversationQueue {
entries: Vec<bevy_ecs::entity::Entity>,
}
impl PostConversationQueue {
pub fn push(&mut self, npc: bevy_ecs::entity::Entity) {
self.entries.push(npc);
}
pub fn drain(&mut self) -> Vec<bevy_ecs::entity::Entity> {
std::mem::take(&mut self.entries)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// 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: BTreeSet<String>,
/// Tick of the last observation event we reacted to (#119, observe_npc).
/// Observation events arrive one tick after the snapshot that caused them,
/// so we track which tick's events we've already processed.
pub last_observation_tick: u64,
}
impl Default for MonologueState {
fn default() -> Self {
Self {
last_fired_tick: 0,
last_position: None,
idle_ticks: 0,
entered: false,
shown_ids: BTreeSet::new(),
last_observation_tick: 0,
}
}
}
/// 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()
}
/// Set a monologue event, replacing any pending event.
/// Used by confrontation response (#520, D-063) to emit a monologue spike.
pub fn set(&mut self, event: MonologueEvent) {
self.event = Some(event);
}
}
/// 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 query: Query<
(
&mut SprintAnomalyQueue,
&mut MonologueBuffer,
&mut MonologueState,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
) {
let Ok((mut queue, mut buffer, mut state, mut entity_rng)) = 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 = entity_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>,
mut query: Query<
(
&mut crate::perception::cognitive_delay::CognitiveDelay,
&mut MonologueBuffer,
&mut MonologueState,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
) {
let Ok((mut cognitive_delay, mut buffer, mut state, mut entity_rng)) = 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;
};
let i = entity_rng.rng.random_range(0..RECOGNITION_LINES.len());
let (id, text) = (
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,
);
}
// ---------------------------------------------------------------------------
// Shared content pool selection (#119)
// ---------------------------------------------------------------------------
/// Select from hardcoded fallback lines for the given trigger type.
fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) {
let lines = match trigger {
"observe_npc" => OBSERVE_NPC_LINES,
"hear_sound" => HEAR_SOUND_LINES,
"post_conversation" => POST_CONVERSATION_LINES,
unknown => {
tracing::warn!(
"select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc",
unknown
);
OBSERVE_NPC_LINES
}
};
let index = rng.random_range(0..lines.len());
(lines[index].0.to_string(), lines[index].1.to_string())
}
/// Tile range for a SoundRange classification (D-018).
fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
use crate::knowledge::types::SoundRange;
match range {
SoundRange::Close => 3,
SoundRange::Medium => 8,
SoundRange::Long => 15,
}
}
// ---------------------------------------------------------------------------
// Event-driven monologue trigger system (#119, D-035)
// ---------------------------------------------------------------------------
/// Event-driven monologue trigger system (#119, D-035).
///
/// Checks observation events, sound events, and completed dialogues for
/// monologue-worthy triggers. Fires at most one
/// monologue per tick. Event-driven — no cooldown gate. Updates
/// `last_fired_tick` so v0.2 periodic triggers can respect the recency window.
///
/// Priority order (first match wins):
/// 1. observe_npc (new entity spotted — uses previous-tick observation events)
/// 2. hear_sound (non-routine sound: Machinery, Alert)
/// 3. post_conversation (player-NPC dialogue concluded)
///
/// System ordering: after all event producers + recognition/anomaly monologue
/// systems, before compute_observer_snapshot.
#[allow(clippy::too_many_arguments)]
pub fn trigger_event_monologue(
time: Res<SimulationTime>,
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
mut post_conv_queue: ResMut<PostConversationQueue>,
mut query: Query<
(
&TilePosition,
&mut MonologueState,
&mut MonologueBuffer,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
registry: Option<Res<EntityRegistry>>,
mut engagement_query: Query<&mut EngagementRecord>,
) {
// Drain post_conversation queue unconditionally — consumed this tick.
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
let Ok((player_pos, mut state, mut buffer, mut entity_rng)) = query.single_mut() else {
return;
};
// Don't override existing monologue from higher-priority systems
if buffer.event.is_some() {
return;
}
// Save previous observation tick before update — needed for NPC attribution (#570)
let previous_observation_tick = state.last_observation_tick;
// Determine which trigger to fire (priority order)
let trigger = if observation_queue
.as_ref()
.map(|q| has_observe_npc_event(q, &state))
.unwrap_or(false)
{
Some("observe_npc")
} else if sound_queue
.as_ref()
.map(|q| has_hear_sound_event(q, player_pos))
.unwrap_or(false)
{
Some("hear_sound")
} else if !post_conv_npcs.is_empty() {
Some("post_conversation")
} else {
None
};
// Update observation tracking regardless of whether we fire
if let Some(ref obs_queue) = observation_queue {
if !obs_queue.is_empty() {
if let Some(max_tick) = obs_queue.iter().map(|e| e.tick).max() {
if max_tick > state.last_observation_tick {
state.last_observation_tick = max_tick;
}
}
}
}
let Some(trigger) = trigger else { return };
let (id, text) = select_hardcoded_fallback(trigger, &mut entity_rng.rng);
buffer.event = Some(MonologueEvent {
id: id.clone(),
text,
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.insert(id.clone());
state.last_fired_tick = time.tick;
tracing::debug!(
"Event monologue fired: trigger={}, id={}, tick={}",
trigger,
id,
time.tick
);
// Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs.
// Only NPC-context triggers are attributed — hear_sound is not NPC-specific.
match trigger {
"observe_npc" => {
// Attribute to all NPCs whose NewEntity event triggered this monologue
if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, &registry) {
for event in obs_q.iter() {
if event.tick > previous_observation_tick {
if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger {
if let Some(npc_entity) = reg.to_entity(sid) {
if let Ok(mut record) = engagement_query.get_mut(npc_entity) {
record.monologue_trigger_count += 1;
}
}
}
}
}
}
}
"post_conversation" => {
// Attribute to the NPC(s) whose conversation just ended
for npc in &post_conv_npcs {
if let Ok(mut record) = engagement_query.get_mut(*npc) {
record.monologue_trigger_count += 1;
}
}
}
_ => {} // hear_sound: no NPC-specific attribution
}
}
/// Check if any NewEntity observation events exist that we haven't processed.
fn has_observe_npc_event(
queue: &crate::perception::interpretation::ObservationEventQueue,
state: &MonologueState,
) -> bool {
queue.iter().any(|e| {
e.tick > state.last_observation_tick
&& matches!(
&e.trigger,
crate::perception::interpretation::ObservationTrigger::NewEntity { .. }
)
})
}
/// Check if any non-routine sound events are within hearing range.
/// Only Machinery and Alert sounds trigger monologue (Footstep, Voice, Ambient
/// are routine and would spam the player).
fn has_hear_sound_event(
queue: &crate::simulation::sound::SoundEventQueue,
player_pos: &TilePosition,
) -> bool {
use crate::simulation::sound::SoundEventKind;
queue.events.iter().any(|e| {
let interesting = matches!(e.kind, SoundEventKind::Machinery | SoundEventKind::Alert);
if !interesting {
return false;
}
if e.z != player_pos.z {
return false;
}
let dx = (e.x as i32 - player_pos.x).unsigned_abs();
let dy = (e.y as i32 - player_pos.y).unsigned_abs();
let distance = dx + dy;
distance <= sound_range_tiles(&e.range)
})
}
/// Monologue trigger system — **stub**.
///
/// v0.1 content pool triggers were removed (#655). This system remains
/// registered in the schedule as a sequencing anchor: `trigger_recognition_monologue`
/// and `process_sprint_anomaly_monologue` are ordered `.after(trigger_monologue)`.
/// Remove this stub when those systems' ordering constraints are refactored.
pub fn trigger_monologue(
_time: Res<SimulationTime>,
_query: Query<(&TilePosition, &MonologueState, &MonologueBuffer), With<PlayerCharacter>>,
) {
// Intentional no-op — kept as schedule ordering anchor. See doc comment.
// Uses shared refs (not &mut) to avoid blocking parallel systems.
}
// ---------------------------------------------------------------------------
// Contradiction monologue trigger (#550, D-083)
// ---------------------------------------------------------------------------
/// Hardcoded v0.1 contradiction monologue template lines.
/// Placeholders: `{source}` = NPC who gave false info, `{subject}` = NPC whose
/// position was contradicted. Hand-authored Sera/Kael lines come from copy team (#552).
const CONTRADICTION_TEMPLATE_LINES: &[&str] = &[
"{source} told me where {subject} would be. They were wrong.",
"Something's off. {source} sent me the wrong way for {subject}.",
"{subject} wasn't where {source} said. Was that a mistake — or a lie?",
];
/// Resolve a display name from a StableId via EntityRegistry + NpcName query.
/// Falls back to `"#<id>"` when the entity is not registered or has no NpcName.
/// Called from tests and available for future triggers needing live name resolution.
#[allow(dead_code)]
pub(crate) fn resolve_name(
stable_id: crate::knowledge::types::StableId,
registry: &EntityRegistry,
names: &Query<&NpcName>,
) -> String {
registry
.to_entity(&stable_id)
.and_then(|e| names.get(e).ok())
.map(|n| n.0.clone())
.unwrap_or_else(|| format!("#{}", stable_id.0))
}
/// Contradiction monologue trigger (#550, D-083).
///
/// Drains ContradictionDetectedQueue once per tick. On first contradiction,
/// fires a monologue line with the pre-resolved source and subject names.
/// Bypasses normal cooldown (event-driven), but updates last_fired_tick.
///
/// Relationship shift (PersonOfInterest) is already done by `process_knowledge_events`
/// before this system runs. This system is a pure consumer of the resolved strings.
///
/// System ordering: after trigger_event_monologue, before compute_observer_snapshot.
pub fn process_contradiction_monologue(
time: Res<SimulationTime>,
_registry: Res<EntityRegistry>,
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
_npc_names: Query<&NpcName>,
mut player_query: Query<
(&mut MonologueBuffer, &mut MonologueState, &mut EntityRng),
With<PlayerCharacter>,
>,
) {
// _registry and _npc_names are available for future triggers needing live name resolution
// via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload.
if contradiction_queue.is_empty() {
return;
}
let Ok((mut buffer, mut state, mut entity_rng)) = player_query.single_mut() else {
contradiction_queue.drain();
return;
};
// Don't override a higher-priority monologue that already fired this tick.
if buffer.event.is_some() {
contradiction_queue.drain();
return;
}
let events = contradiction_queue.drain();
// Process only the first contradiction per tick (first-in wins).
let Some(event) = events.into_iter().next() else {
return;
};
let template_idx = entity_rng
.rng
.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
let text = CONTRADICTION_TEMPLATE_LINES[template_idx]
.replace("{source}", &event.source_display_name)
.replace("{subject}", &event.subject_display_name);
let id = format!("contradiction_{:02}", template_idx + 1);
buffer.event = Some(MonologueEvent {
id: id.clone(),
text,
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.insert(id.clone());
state.last_fired_tick = time.tick;
tracing::debug!(
"Contradiction monologue fired: id={}, source={}, subject={}, tick={}",
id,
event.source_display_name,
event.subject_display_name,
time.tick,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::rng::{EntityRng, SimRng};
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
/// Test helper: create an EntityRng for the player entity in tests.
fn test_entity_rng() -> EntityRng {
EntityRng::from_seed_and_id(42, 0)
}
// -----------------------------------------------------------------------
// 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>();
// SimRng no longer needed — migrated systems use EntityRng
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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>();
// SimRng no longer needed — migrated systems use EntityRng
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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,
test_entity_rng(),
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"
);
}
// -----------------------------------------------------------------------
// trigger_event_monologue tests (#119, D-035)
// -----------------------------------------------------------------------
use crate::perception::interpretation::{
ObservationEvent, ObservationEventQueue, ObservationTrigger,
};
use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue};
fn setup_event_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
// SimRng no longer needed — migrated systems use EntityRng
world.init_resource::<ObservationEventQueue>();
world.init_resource::<SoundEventQueue>();
world.init_resource::<PostConversationQueue>();
world
}
fn spawn_event_player(world: &mut World) -> Entity {
world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
))
.id()
}
fn run_event_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_event_monologue);
schedule.run(world);
}
#[test]
fn observe_npc_fires_on_new_entity_event() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Push a NewEntity observation event at tick 1 (player starts at tick 0)
world.resource_mut::<SimulationTime>().tick = 5;
let _target_entity = world.spawn_empty().id();
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 1,
trigger: ObservationTrigger::NewEntity {
entity: StableId(42),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_some(),
"observe_npc should fire for NewEntity event"
);
let event = buf.event.as_ref().unwrap();
assert!(
event.id.starts_with("observe_npc_"),
"should use observe_npc fallback lines, got: {}",
event.id
);
}
#[test]
fn observe_npc_ignores_already_processed_events() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Set last_observation_tick so events at tick 1 are already processed
world
.get_mut::<MonologueState>(player)
.unwrap()
.last_observation_tick = 5;
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 3, // older than last_observation_tick
trigger: ObservationTrigger::NewEntity {
entity: StableId(42),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_none(),
"should not fire for already-processed observation events"
);
}
#[test]
fn hear_sound_fires_on_machinery_in_range() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(12, 10, 0), // distance 2 from player at (10,10)
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
));
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_some(),
"hear_sound should fire for Machinery sound in range"
);
assert!(buf.event.as_ref().unwrap().id.starts_with("hear_sound_"));
}
#[test]
fn hear_sound_ignores_footstep() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Footstep,
0.5,
crate::knowledge::types::SoundRange::Close,
None,
));
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(
buf.event.is_none(),
"Footstep sounds should not trigger monologue"
);
}
#[test]
fn hear_sound_ignores_out_of_range() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
// Machinery sound at distance 20 with Close range (3 tiles)
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(30, 10, 0), // distance 20 from (10,10)
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Close,
None,
));
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(
buf.event.is_none(),
"sounds beyond range should not trigger monologue"
);
}
#[test]
fn post_conversation_fires_on_queue_entry() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
let npc = world.spawn_empty().id();
world.resource_mut::<PostConversationQueue>().push(npc);
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_some(),
"post_conversation should fire when queue has entries"
);
assert!(buf.event.as_ref().unwrap().id.starts_with("post_conv_"));
}
#[test]
fn post_conversation_queue_drained_even_when_buffer_full() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
let npc = world.spawn_empty().id();
// Pre-fill buffer (another system wrote first)
world.get_mut::<MonologueBuffer>(player).unwrap().event = Some(MonologueEvent {
id: "existing".to_string(),
text: "Already have something.".to_string(),
duration_seconds: 5.0,
});
world.resource_mut::<PostConversationQueue>().push(npc);
run_event_system(&mut world);
// Buffer should still have the original event
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert_eq!(buf.event.as_ref().unwrap().id, "existing");
// Queue should be drained even though we didn't fire
assert!(
world.resource::<PostConversationQueue>().is_empty(),
"queue must be drained even when buffer is full"
);
}
#[test]
fn existing_buffer_not_overridden_by_event_trigger() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Pre-fill buffer
world.get_mut::<MonologueBuffer>(player).unwrap().event = Some(MonologueEvent {
id: "prior_line".to_string(),
text: "I was already thinking.".to_string(),
duration_seconds: 5.0,
});
// Push observation event that would normally fire
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 1,
trigger: ObservationTrigger::NewEntity {
entity: StableId(99),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert_eq!(
buf.event.as_ref().unwrap().id,
"prior_line",
"event trigger should not override existing monologue"
);
}
#[test]
fn priority_observe_npc_over_hear_sound() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Both triggers present: observe_npc should win
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 1,
trigger: ObservationTrigger::NewEntity {
entity: StableId(42),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
));
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.as_ref().unwrap().id.starts_with("observe_npc_"),
"observe_npc should have priority over hear_sound"
);
}
#[test]
fn event_trigger_updates_last_fired_tick() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
world.resource_mut::<PostConversationQueue>().push(npc);
run_event_system(&mut world);
let state = world.get::<MonologueState>(player).unwrap();
assert_eq!(
state.last_fired_tick, 42,
"event trigger should update last_fired_tick"
);
}
#[test]
fn event_trigger_records_shown_id() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
let npc = world.spawn_empty().id();
world.resource_mut::<PostConversationQueue>().push(npc);
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
let fired_id = buf.event.as_ref().unwrap().id.clone();
let state = world.get::<MonologueState>(player).unwrap();
assert!(
state.shown_ids.contains(&fired_id),
"fired line ID should be recorded in shown_ids"
);
}
#[test]
fn no_events_produces_no_monologue() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(buf.event.is_none(), "no events should produce no monologue");
}
#[test]
fn hear_sound_alert_fires() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 11, 0), // distance 1
SoundEventKind::Alert,
1.0,
crate::knowledge::types::SoundRange::Long,
None,
));
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_some(),
"Alert sounds should trigger hear_sound monologue"
);
}
#[test]
fn hear_sound_ignores_different_z_level() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
// Sound on z=1, player on z=0
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 11, 1), // same xy but different z
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
));
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(
buf.event.is_none(),
"sounds on different z-level should not trigger monologue"
);
}
#[test]
fn hardcoded_lines_all_valid() {
for lines in &[OBSERVE_NPC_LINES, HEAR_SOUND_LINES, POST_CONVERSATION_LINES] {
assert!(!lines.is_empty());
for (id, text) in *lines {
assert!(!id.is_empty(), "line id should not be empty");
assert!(!text.is_empty(), "text for {} should be non-empty", id);
}
}
}
// -----------------------------------------------------------------------
// Constant assertions
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep)
// -----------------------------------------------------------------------
#[test]
fn hear_sound_ignores_voice_kind() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
// Voice sound in range — should NOT trigger (routine background noise)
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0), // distance 1
SoundEventKind::Voice,
0.7,
crate::knowledge::types::SoundRange::Medium,
None,
));
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(
buf.event.is_none(),
"Voice sounds are routine and must NOT trigger hear_sound monologue"
);
}
#[test]
fn hear_sound_ignores_ambient_kind() {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
// Ambient sound in range — should NOT trigger (background atmosphere)
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 12, 0), // distance 2
SoundEventKind::Ambient,
0.9,
crate::knowledge::types::SoundRange::Long,
None,
));
run_event_system(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buf = buf_query.single(&world).unwrap();
assert!(
buf.event.is_none(),
"Ambient sounds are routine and must NOT trigger hear_sound monologue"
);
}
#[test]
fn observation_tick_tracking_updated() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 7,
trigger: ObservationTrigger::NewEntity {
entity: StableId(42),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
run_event_system(&mut world);
let state = world.get::<MonologueState>(player).unwrap();
assert_eq!(
state.last_observation_tick, 7,
"last_observation_tick should track highest event tick"
);
}
// -----------------------------------------------------------------------
// process_contradiction_monologue tests (#550, D-083)
// -----------------------------------------------------------------------
fn setup_contradiction_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
// SimRng no longer needed — migrated systems use EntityRng
world.insert_resource(ContradictionDetectedQueue::default());
world.init_resource::<EntityRegistry>();
world
}
fn spawn_contradiction_player(world: &mut bevy_ecs::world::World) -> bevy_ecs::entity::Entity {
world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
))
.id()
}
#[test]
fn contradiction_monologue_fires_with_resolved_names() {
// ContradictionDetectedQueue has an event with pre-resolved names.
// process_contradiction_monologue should fire a monologue line containing both names.
let mut world = setup_contradiction_world();
let player = spawn_contradiction_player(&mut world);
// Advance past tick 0 so last_fired_tick=0 cooldown doesn't block
world.resource_mut::<SimulationTime>().tick = 500;
// Populate the contradiction queue with pre-resolved names
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: player,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(5, 5, 0),
observed_position: TilePosition::new(10, 10, 0),
detected_tick: 500,
},
source_display_name: "Sera".to_string(),
subject_display_name: "Kael".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some(), "contradiction monologue should fire");
let event = buf.event.as_ref().unwrap();
assert!(
event.text.contains("Sera"),
"monologue text should mention the source: got '{}'",
event.text
);
assert!(
event.text.contains("Kael"),
"monologue text should mention the subject: got '{}'",
event.text
);
// ID should be contradiction_01 / _02 / _03
assert!(
event.id.starts_with("contradiction_"),
"monologue id should be contradiction_NN: got '{}'",
event.id
);
}
#[test]
fn contradiction_monologue_does_not_override_existing_buffer() {
// If MonologueBuffer already has an event, contradiction must not clobber it.
let mut world = setup_contradiction_world();
let player = spawn_contradiction_player(&mut world);
// Pre-fill buffer with a higher-priority monologue
world
.get_mut::<MonologueBuffer>(player)
.unwrap()
.set(MonologueEvent {
id: "prior_event".to_string(),
text: "Something already fired.".to_string(),
duration_seconds: 5.0,
});
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: player,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(5, 5, 0),
observed_position: TilePosition::new(10, 10, 0),
detected_tick: 100,
},
source_display_name: "Sera".to_string(),
subject_display_name: "Kael".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world);
// Buffer should still have the prior event
let buf = world.get::<MonologueBuffer>(player).unwrap();
let event = buf.event.as_ref().unwrap();
assert_eq!(
event.id, "prior_event",
"prior monologue should not be overridden"
);
// Queue should have been drained regardless
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be drained even when buffer is occupied"
);
}
#[test]
fn contradiction_monologue_drains_queue_when_no_player() {
// If there's no player entity, the queue must still be drained (no panic).
let mut world = setup_contradiction_world();
// No player spawned
let fake_world_entity = world.spawn_empty().id();
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
observer: fake_world_entity,
target: crate::knowledge::types::StableId(2),
claim: crate::knowledge::types::ContradictionClaim {
told_by: crate::knowledge::types::StableId(1),
told_tick: 100,
claimed_position: TilePosition::new(1, 1, 0),
observed_position: TilePosition::new(5, 5, 0),
detected_tick: 100,
},
source_display_name: "Unknown".to_string(),
subject_display_name: "Unknown".to_string(),
},
);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_contradiction_monologue);
schedule.run(&mut world); // should not panic
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be drained even without a player"
);
let _ = fake_world_entity; // suppress unused warning
}
#[test]
fn the_friend_arc_integration_full_sequence() {
// THE FRIEND arc integration test (#550, D-083).
//
// Tick T1: KnowledgeGranted creates ToldBy entry (Kael at (5,5), told by Sera)
// Tick T2: DirectObservation fires ContradictionDetected (Kael at (10,10))
// → relationship shift: Sera becomes PersonOfInterest in player's KG
// → ContradictionDetectedEvent pushed with resolved names
// Tick T3: process_contradiction_monologue fires monologue with Sera/Kael names
//
// Tests the full D-083 event chain end-to-end.
use crate::knowledge::events::{process_knowledge_events, KnowledgeEvent};
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::{
EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
RelationshipState, StableId,
};
use crate::knowledge::{
EntityRegistry, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use std::collections::BTreeMap;
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(7));
world.init_resource::<ContradictionDetectedQueue>();
// Registry
let mut registry = EntityRegistry::new(0);
// Spawn NPCs with NpcName components
let sera_entity = world.spawn(NpcName("Sera".to_string())).id();
let kael_entity = world
.spawn((NpcName("Kael".to_string()), TilePosition::new(10, 10, 0)))
.id();
let sera_sid = registry.register(sera_entity);
let kael_sid = registry.register(kael_entity);
// Spawn player with KnowledgeGraph
let mut player_kg = KnowledgeGraph::new();
// Tick T1: Pre-populate KG with ToldBy entry — Sera told us Kael is at (5,5)
player_kg.entities.insert(
kael_sid,
EntityKnowledge {
last_known_position: Some(TilePosition::new(5, 5, 0)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: sera_sid,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let player = world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
player_kg,
StableEntityId(StableId(999)),
))
.id();
registry.register(player);
world.insert_resource(registry);
// Tick T2: Push DirectObservation of Kael at (10,10) — contradicts (5,5)
let tick_t2 = 200u64;
world.resource_mut::<SimulationTime>().tick = tick_t2;
let mut ke_queue = KnowledgeEventQueue::default();
ke_queue.push(KnowledgeEvent {
observer: player,
tick: tick_t2,
event_type: KnowledgeEventType::DirectObservation {
target: kael_entity,
position: TilePosition::new(10, 10, 0),
},
});
world.insert_resource(ke_queue);
let mut schedule_t2 = bevy_ecs::schedule::Schedule::default();
schedule_t2.add_systems(process_knowledge_events);
schedule_t2.run(&mut world);
// Verify contradiction was detected and queued
{
let cq = world.resource::<ContradictionDetectedQueue>();
assert!(!cq.is_empty(), "contradiction should be in queue after T2");
}
// Verify Sera became PersonOfInterest in player's KG
{
let kg = world.get::<KnowledgeGraph>(player).unwrap();
let sera_entry = kg.entity_knowledge(&sera_sid);
assert!(
sera_entry.is_some(),
"Sera should have an entry in player's KG after contradiction"
);
assert_eq!(
sera_entry.unwrap().relationship,
RelationshipState::PersonOfInterest,
"Sera should be PersonOfInterest after giving false location info"
);
}
// Verify ContradictionDetectedEvent has correct pre-resolved names
{
// Drain to inspect event contents, then re-push for the monologue consumer.
let mut events = world.resource_mut::<ContradictionDetectedQueue>().drain();
assert_eq!(
events.len(),
1,
"should have exactly one contradiction event"
);
let event = &events[0];
assert_eq!(event.source_display_name, "Sera");
assert_eq!(event.subject_display_name, "Kael");
// Re-push so process_contradiction_monologue can consume it on T3.
let event = events.remove(0);
world
.resource_mut::<ContradictionDetectedQueue>()
.push(event);
}
// Tick T3: Run process_contradiction_monologue
world.resource_mut::<SimulationTime>().tick = 300;
let mut schedule_t3 = bevy_ecs::schedule::Schedule::default();
schedule_t3.add_systems(process_contradiction_monologue);
schedule_t3.run(&mut world);
// Verify monologue fired with both names
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some(), "monologue should fire on T3");
let mono_event = buf.event.as_ref().unwrap();
assert!(
mono_event.text.contains("Sera"),
"monologue text should mention Sera: '{}'",
mono_event.text
);
assert!(
mono_event.text.contains("Kael"),
"monologue text should mention Kael: '{}'",
mono_event.text
);
// Verify queue is drained after monologue fires
assert!(
world.resource::<ContradictionDetectedQueue>().is_empty(),
"queue should be empty after monologue consumed the event"
);
}
}