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:
@@ -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