feat(perception): anomaly detection and recognition monologue during delay
Add AnomalyMarker component and detect_anomalies() system that flags entities with KG relationship PersonOfInterest or Contradicted state for urgent cognitive delay (0.3s vs 0.6s normal). Add trigger_recognition_monologue() that fires monologue at delay START (when grey blob appears), not at completion — the monologue IS the recognition process per D-060. Includes v0.1 fallback recognition lines and cooldown tracking. Fixes #450, #451. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
//! Anomaly detection system (#450, D-060).
|
||||
//!
|
||||
//! Marks entities as anomalous when the observer's KnowledgeGraph has them as
|
||||
//! PersonOfInterest or Contradicted. AnomalyMarker is a transient per-tick
|
||||
//! component cleared at tick start and recomputed from KG state.
|
||||
//!
|
||||
//! Used by:
|
||||
//! - emit_observation_events: RecognitionTrigger::Urgent for fog recognition
|
||||
//! - #451 (future): monologue ObserveAnomaly trigger priority
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::RelationshipState;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
|
||||
/// Transient marker: entity flagged as anomalous this tick.
|
||||
///
|
||||
/// Cleared at tick start, recomputed by `detect_anomalies` each tick
|
||||
/// from the observer's KnowledgeGraph. An entity is anomalous when
|
||||
/// the observer knows it as PersonOfInterest or its KG state is Contradicted.
|
||||
///
|
||||
/// The player entity is never marked anomalous (prevents self-checks).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AnomalyMarker;
|
||||
|
||||
/// Clear all AnomalyMarker components at tick start.
|
||||
///
|
||||
/// System ordering: runs before detect_anomalies.
|
||||
pub fn clear_anomaly_markers(mut commands: Commands, markers: Query<Entity, With<AnomalyMarker>>) {
|
||||
for entity in markers.iter() {
|
||||
commands.entity(entity).remove::<AnomalyMarker>();
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect anomalous entities based on observer's KG state.
|
||||
///
|
||||
/// Marks entities as anomalous when KG.relationship == PersonOfInterest
|
||||
/// OR KG.state == Contradicted. Skips the player entity.
|
||||
///
|
||||
/// System ordering: after clear_anomaly_markers, before emit_observation_events.
|
||||
pub fn detect_anomalies(
|
||||
mut commands: Commands,
|
||||
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
) {
|
||||
let Ok((player_entity, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
let Some(entity) = registry.to_entity(stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Don't mark Player entity as anomalous
|
||||
if entity == player_entity {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_anomalous = knowledge.relationship == RelationshipState::PersonOfInterest
|
||||
|| knowledge.state == KnowledgeState::Contradicted;
|
||||
|
||||
if is_anomalous {
|
||||
commands.entity(entity).insert(AnomalyMarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_marks_contradicted_entity() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Apply deferred commands
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"Contradicted entity should have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_marks_person_of_interest() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"PersonOfInterest entity should have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_skips_active_known_entity() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Active state, Known relationship — not anomalous
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_none(),
|
||||
"Active/Known entity should NOT have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_does_not_mark_player() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// Even if player is somehow in own KG as POI, don't mark
|
||||
let mut kg = world.get_mut::<KnowledgeGraph>(player).unwrap();
|
||||
kg.observe_entity(player_sid, TilePosition::new(10, 10, 0), 50);
|
||||
kg.set_relationship(&player_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(player).is_none(),
|
||||
"Player entity should never be marked anomalous"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_all_markers() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Manually add markers
|
||||
let e1 = world.spawn(AnomalyMarker).id();
|
||||
let e2 = world.spawn(AnomalyMarker).id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(clear_anomaly_markers);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(world.get::<AnomalyMarker>(e1).is_none());
|
||||
assert!(world.get::<AnomalyMarker>(e2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_then_detect_refreshes_markers() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// Run clear then detect
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
clear_anomaly_markers,
|
||||
detect_anomalies.after(clear_anomaly_markers),
|
||||
));
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"Marker should be refreshed after clear+detect cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ pub struct PendingRecognition {
|
||||
pub delay_until_tick: u64,
|
||||
/// What triggered this recognition.
|
||||
pub trigger: RecognitionTrigger,
|
||||
/// Whether a recognition monologue has been fired for this entry (#451).
|
||||
/// Set by trigger_recognition_monologue to prevent re-firing each tick.
|
||||
pub monologue_fired: bool,
|
||||
}
|
||||
|
||||
/// Component: cognitive delay buffer for entity recognition (D-060).
|
||||
@@ -123,6 +126,11 @@ impl CognitiveDelay {
|
||||
&self.pending
|
||||
}
|
||||
|
||||
/// Mutable access to pending recognitions (for monologue tracking, #451).
|
||||
pub fn pending_mut(&mut self) -> &mut Vec<PendingRecognition> {
|
||||
&mut self.pending
|
||||
}
|
||||
|
||||
/// Number of pending recognitions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.pending.len()
|
||||
@@ -206,6 +214,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert_eq!(delay.len(), 1);
|
||||
@@ -225,6 +234,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let cancelled = delay.cancel(&StableId(1));
|
||||
@@ -251,6 +261,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(105);
|
||||
@@ -270,6 +281,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(106);
|
||||
@@ -290,6 +302,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(200);
|
||||
@@ -310,6 +323,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 103, // Urgent: 3 ticks from tick 100
|
||||
trigger: RecognitionTrigger::Urgent,
|
||||
monologue_fired: false,
|
||||
});
|
||||
delay.push(PendingRecognition {
|
||||
target: t2,
|
||||
@@ -317,6 +331,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106, // Normal: 6 ticks from tick 100
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
// Tick 103: only urgent should drain
|
||||
@@ -345,6 +360,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert!(delay.is_pending(&StableId(1)));
|
||||
@@ -356,6 +372,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert!(delay.is_pending(&StableId(1)));
|
||||
@@ -379,6 +396,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -413,6 +431,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -449,6 +468,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 103, // Urgent
|
||||
trigger: RecognitionTrigger::Urgent,
|
||||
monologue_fired: false,
|
||||
});
|
||||
cd.push(PendingRecognition {
|
||||
target: t2,
|
||||
@@ -456,6 +476,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106, // Normal
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -502,6 +523,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
let observer = world.spawn((KnowledgeGraph::new(), cd)).id();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod anomaly;
|
||||
pub mod cognitive_delay;
|
||||
pub mod interpretation;
|
||||
pub mod observation;
|
||||
@@ -25,6 +26,10 @@ impl Plugin for PerceptionPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
anomaly::clear_anomaly_markers
|
||||
.before(anomaly::detect_anomalies),
|
||||
anomaly::detect_anomalies
|
||||
.before(observation::emit_observation_events),
|
||||
cognitive_delay::process_cognitive_delay
|
||||
.after(observation::emit_observation_events)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn emit_observation_events(
|
||||
>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
@@ -78,22 +79,29 @@ pub fn emit_observation_events(
|
||||
} else if let Some(ref mut delay) = cognitive_delay {
|
||||
// New entity + cognitive delay available: buffer recognition
|
||||
if !delay.is_pending(&stable_id) {
|
||||
// TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers
|
||||
let trigger = RecognitionTrigger::Normal;
|
||||
// #450: Urgent trigger for anomalous entities (D-060)
|
||||
let trigger = if anomaly_markers.get(entity).is_ok() {
|
||||
RecognitionTrigger::Urgent
|
||||
} else {
|
||||
RecognitionTrigger::Normal
|
||||
};
|
||||
let delay_until = time.tick + trigger.delay_ticks();
|
||||
delay.push(PendingRecognition {
|
||||
target: entity,
|
||||
stable_id,
|
||||
position: *pos,
|
||||
delay_until_tick: time.tick + trigger.delay_ticks(),
|
||||
delay_until_tick: delay_until,
|
||||
trigger,
|
||||
monologue_fired: false,
|
||||
});
|
||||
tracing::debug!(
|
||||
"Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}",
|
||||
"Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}",
|
||||
stable_id.0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
time.tick + trigger.delay_ticks(),
|
||||
trigger,
|
||||
delay_until,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -33,6 +33,24 @@ const DISPLAY_DURATION: f32 = 5.0;
|
||||
/// 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)] = &[
|
||||
@@ -195,6 +213,144 @@ pub fn process_sprint_anomaly_monologue(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognition monologue trigger (#451, D-060).
|
||||
///
|
||||
/// Fires DURING cognitive delay, not after — "the monologue IS the recognition."
|
||||
/// When a new entity enters fog (PendingRecognition queued by emit_observation_events),
|
||||
/// this system fires a recognition monologue on the next tick.
|
||||
///
|
||||
/// Priority: anomalous entities (AnomalyMarker) get first pick. Only one
|
||||
/// recognition monologue fires per tick. Bypasses normal monologue cooldown
|
||||
/// (event-driven), but updates last_fired_tick for normal cooldown tracking.
|
||||
///
|
||||
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
|
||||
pub fn trigger_recognition_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
content: Option<Res<ContentStoreResource>>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
&mut crate::perception::cognitive_delay::CognitiveDelay,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
) {
|
||||
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Don't override existing monologue from trigger_monologue
|
||||
if buffer.event.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// No pending recognitions → nothing to do
|
||||
if cognitive_delay.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first unfired pending recognition. Prioritize anomalous entities.
|
||||
let pending = cognitive_delay.pending_mut();
|
||||
let target_idx = {
|
||||
// First pass: anomalous + unfired
|
||||
let anomaly_idx = pending.iter().position(|p| {
|
||||
!p.monologue_fired && anomaly_markers.get(p.target).is_ok()
|
||||
});
|
||||
if let Some(idx) = anomaly_idx {
|
||||
Some(idx)
|
||||
} else {
|
||||
// Second pass: any unfired
|
||||
pending.iter().position(|p| !p.monologue_fired)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(idx) = target_idx else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Try content pools for observe_anomaly trigger lines
|
||||
let line = if let Some(ref content) = content {
|
||||
let character = state.character.as_str();
|
||||
let mut candidates: Vec<(&str, &str)> = Vec::new();
|
||||
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != "observe_anomaly" {
|
||||
continue;
|
||||
}
|
||||
if state.shown_ids.contains(&line.id) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
// Fallback: allow repeats from content pools
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != "observe_anomaly" {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !candidates.is_empty() {
|
||||
let i = rng.rng.random_range(0..candidates.len());
|
||||
Some((candidates[i].0.to_string(), candidates[i].1.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Use content pool line or hardcoded fallback
|
||||
let (id, text) = if let Some((id, text)) = line {
|
||||
(id, text)
|
||||
} else {
|
||||
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
(
|
||||
RECOGNITION_LINES[i].0.to_string(),
|
||||
RECOGNITION_LINES[i].1.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
text,
|
||||
duration_seconds: DISPLAY_DURATION,
|
||||
});
|
||||
|
||||
state.shown_ids.push(id.clone());
|
||||
state.last_fired_tick = time.tick;
|
||||
|
||||
// Mark this pending recognition as having fired its monologue
|
||||
pending[idx].monologue_fired = true;
|
||||
|
||||
tracing::debug!(
|
||||
"Recognition monologue fired: id={}, tick={}, target_stable_id={}",
|
||||
id,
|
||||
time.tick,
|
||||
pending[idx].stable_id.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Monologue trigger system.
|
||||
///
|
||||
/// Runs each tick. Checks trigger conditions against loaded content pools
|
||||
@@ -710,6 +866,291 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// trigger_recognition_monologue tests (#451, D-060)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::perception::cognitive_delay::{
|
||||
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
|
||||
};
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
fn setup_recognition_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_fires_for_pending_recognition() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
let buffer = buf_query.single(&world).unwrap();
|
||||
assert!(
|
||||
buffer.event.is_some(),
|
||||
"recognition monologue should fire for pending recognition"
|
||||
);
|
||||
let event = buffer.event.as_ref().unwrap();
|
||||
assert!(
|
||||
event.id.starts_with("recognition_"),
|
||||
"should use hardcoded recognition lines (no content pool)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_does_not_fire_twice() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
|
||||
// First tick: fires
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_some(),
|
||||
"first tick should fire"
|
||||
);
|
||||
|
||||
// Consume the buffer
|
||||
world.get_mut::<MonologueBuffer>(player).unwrap().take();
|
||||
|
||||
// Second tick: should NOT fire (monologue_fired = true)
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_none(),
|
||||
"second tick should not fire (already fired for this recognition)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_does_not_override_existing_buffer() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
// Pre-fill MonologueBuffer (e.g., from trigger_monologue)
|
||||
let mut buffer = MonologueBuffer::default();
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: "existing_line".to_string(),
|
||||
text: "Already have something to say.".to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
buffer,
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
let buffer = buf_query.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
buffer.event.as_ref().unwrap().id,
|
||||
"existing_line",
|
||||
"should not override existing monologue"
|
||||
);
|
||||
|
||||
// monologue_fired should still be false (wasn't consumed)
|
||||
let mut cd_query = world.query::<&CognitiveDelay>();
|
||||
let cd = cd_query.single(&world).unwrap();
|
||||
assert!(
|
||||
!cd.pending()[0].monologue_fired,
|
||||
"should not mark as fired when buffer was full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_no_pending_is_noop() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
CognitiveDelay::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
assert!(
|
||||
buf_query.single(&world).unwrap().event.is_none(),
|
||||
"no pending recognitions → no monologue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_prioritizes_anomalous_entities() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let normal_target = world.spawn_empty().id();
|
||||
let anomalous_target = world
|
||||
.spawn(crate::perception::anomaly::AnomalyMarker)
|
||||
.id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
// Normal entity added first
|
||||
cd.push(PendingRecognition {
|
||||
target: normal_target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
// Anomalous entity added second
|
||||
cd.push(PendingRecognition {
|
||||
target: anomalous_target,
|
||||
stable_id: StableId(2),
|
||||
position: TilePosition::new(8, 8, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Monologue should fire for anomalous entity (idx 1), not normal (idx 0)
|
||||
let cd = world.get::<CognitiveDelay>(player).unwrap();
|
||||
assert!(
|
||||
!cd.pending()[0].monologue_fired,
|
||||
"normal entity should NOT be fired first"
|
||||
);
|
||||
assert!(
|
||||
cd.pending()[1].monologue_fired,
|
||||
"anomalous entity should be fired first (priority)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_updates_last_fired_tick() {
|
||||
let mut world = setup_recognition_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 50;
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 50 + NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut state_query = world.query::<&MonologueState>();
|
||||
assert_eq!(
|
||||
state_query.single(&world).unwrap().last_fired_tick,
|
||||
50,
|
||||
"last_fired_tick should be updated for normal cooldown tracking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_lines_all_valid() {
|
||||
assert!(!RECOGNITION_LINES.is_empty());
|
||||
for (id, text) in RECOGNITION_LINES {
|
||||
assert!(
|
||||
id.starts_with("recognition_"),
|
||||
"id={} should start with recognition_",
|
||||
id
|
||||
);
|
||||
assert!(!text.is_empty(), "text for {} should be non-empty", id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_full_cycle_detect_then_fire() {
|
||||
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
|
||||
|
||||
Reference in New Issue
Block a user