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>
2142 lines
73 KiB
Rust
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, ®istry) {
|
|
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"
|
|
);
|
|
}
|
|
}
|