feat(simulation): sprint anomaly double-take monologue (#428, D-055)

When sprinting past a Contradicted entity, queue a delayed retroactive
monologue that fires after ~1.5s: "Wait — something wasn't right back
there." SprintAnomalyQueue component with first-in-wins semantics.
Detection in observer pipeline, processing in monologue system.
Wire MovementProfile, Stance, and SprintAnomalyQueue into player spawn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:00:20 +01:00
co-authored by Claude Opus 4.6
parent 1d76100f5d
commit df81b24cd1
4 changed files with 459 additions and 6 deletions
+426
View File
@@ -3,6 +3,10 @@
// 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 bevy_ecs::prelude::*;
use rand::Rng;
@@ -24,6 +28,19 @@ const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
/// Tick delay before a sprint anomaly monologue fires (#428, D-055).
/// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds.
/// Tunable: adjust based on actual client frame rate.
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
/// Future: move to content pools with trigger="sprint_anomaly".
const ANOMALY_LINES: &[(&str, &str)] = &[
("sprint_anomaly_01", "Wait \u{2014} something wasn't right back there."),
("sprint_anomaly_02", "Hold on. That face... why were they there?"),
("sprint_anomaly_03", "Something's off. That wasn't where they should be."),
];
/// Tracks monologue state for cooldown and trigger detection.
/// Attached to the PlayerCharacter entity.
#[derive(Component, Debug)]
@@ -70,6 +87,101 @@ impl MonologueBuffer {
}
}
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
///
/// When sprinting past a Contradicted entity, the observer pipeline detects
/// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing
/// system fires a retroactive monologue ("Wait — was that...?").
///
/// At most one anomaly is pending at a time (first-in wins).
#[derive(Component, Debug, Default)]
pub struct SprintAnomalyQueue {
pending: Option<SprintAnomalyEntry>,
}
#[derive(Debug, Clone)]
struct SprintAnomalyEntry {
entity_id: u64,
detected_tick: u64,
}
impl SprintAnomalyQueue {
/// Queue an anomaly if none is pending.
/// First-in wins: subsequent anomalies are ignored until the current one fires.
pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) {
if self.pending.is_none() {
self.pending = Some(SprintAnomalyEntry {
entity_id,
detected_tick: tick,
});
}
}
/// Take the pending anomaly if the delay has elapsed.
/// Returns the entity_id that triggered the anomaly.
pub fn take_ready(&mut self, current_tick: u64) -> Option<u64> {
if let Some(entry) = &self.pending {
if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS {
let entity_id = entry.entity_id;
self.pending = None;
return Some(entity_id);
}
}
None
}
/// Whether an anomaly is pending (detected but not yet fired).
pub fn has_pending(&self) -> bool {
self.pending.is_some()
}
}
/// Process delayed sprint anomaly monologues (#428, D-055).
///
/// Checks SprintAnomalyQueue for entries past the delay threshold and fires
/// a "double-take" monologue. Bypasses normal monologue cooldown since sprint
/// anomalies are event-driven, not periodic. Updates last_fired_tick so
/// subsequent normal monologue respects cooldown after the anomaly fires.
///
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
pub fn process_sprint_anomaly_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState),
With<PlayerCharacter>,
>,
) {
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
return;
};
// Don't override existing monologue from trigger_monologue
if buffer.event.is_some() {
return;
}
if let Some(_entity_id) = queue.take_ready(time.tick) {
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
let (id, text) = ANOMALY_LINES[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
// Update last_fired_tick so normal monologue respects cooldown
state.last_fired_tick = time.tick;
tracing::debug!(
"Sprint anomaly monologue fired: id={}, tick={}",
id,
time.tick
);
}
}
/// Monologue trigger system.
///
/// Runs each tick. Checks trigger conditions against loaded content pools
@@ -318,4 +430,318 @@ mod tests {
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
#[test]
fn anomaly_queue_default_is_empty() {
let queue = SprintAnomalyQueue::default();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_queue_push_stores_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
assert!(queue.has_pending());
}
#[test]
fn anomaly_queue_first_in_wins() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
queue.push_anomaly(99, 101); // Should be ignored
assert!(queue.has_pending());
// The first anomaly (entity 42) should be the one that fires
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
}
#[test]
fn anomaly_queue_take_ready_before_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Not enough delay yet
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1);
assert_eq!(result, None);
assert!(queue.has_pending()); // Still pending
}
#[test]
fn anomaly_queue_take_ready_at_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Exactly at delay threshold
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
assert!(!queue.has_pending()); // Consumed
}
#[test]
fn anomaly_queue_take_ready_clears_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
// Second take should return None
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10);
assert_eq!(result, None);
}
#[test]
fn anomaly_queue_can_push_after_take() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert!(!queue.has_pending());
// Push a new anomaly after the first was consumed
queue.push_anomaly(99, 300);
assert!(queue.has_pending());
let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(99));
}
// -----------------------------------------------------------------------
// process_sprint_anomaly_monologue system tests (#428, D-055)
// -----------------------------------------------------------------------
fn setup_anomaly_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world
}
#[test]
fn anomaly_monologue_fires_after_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0); // Queued at tick 0
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Advance past delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
}
#[test]
fn anomaly_monologue_not_before_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Still within delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn anomaly_monologue_does_not_override_existing() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
// Pre-fill the monologue buffer (as if trigger_monologue already wrote)
let mut buffer = MonologueBuffer::default();
buffer.event = Some(MonologueEvent {
id: "existing_line".to_string(),
text: "I should keep this.".to_string(),
duration_seconds: 5.0,
});
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
buffer,
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
// Should still have the original line, not the anomaly line
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line");
// Queue should still be pending (not consumed)
let mut q = world.query::<&SprintAnomalyQueue>();
assert!(q.single(&world).unwrap().has_pending());
}
#[test]
fn anomaly_monologue_updates_last_fired_tick() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueState>();
let state = query.single(&world).unwrap();
assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS);
}
#[test]
fn anomaly_monologue_clears_queue_after_fire() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_monologue_no_crash_without_queue() {
// Backward compat: entities without SprintAnomalyQueue don't crash
let mut world = setup_anomaly_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Should not panic
schedule.run(&mut world);
}
#[test]
fn anomaly_delay_constant_is_90_ticks() {
// D-055 spec: ~1.5 real seconds at 60fps → 90 ticks
assert_eq!(ANOMALY_DELAY_TICKS, 90);
}
#[test]
fn anomaly_lines_all_valid() {
// All hardcoded v0.1 lines should have id prefix and non-empty text
assert!(!ANOMALY_LINES.is_empty());
for (id, text) in ANOMALY_LINES {
assert!(id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id);
assert!(!text.is_empty(), "text for {} should be non-empty", id);
}
}
#[test]
fn anomaly_full_cycle_detect_then_fire() {
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Tick 89: still within delay — should NOT fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
assert!(buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay");
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(q_query.single(&world).unwrap().has_pending(), "still pending before delay");
// Tick 90: delay elapsed — should fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(buffer.event.is_some(), "should fire at delay threshold");
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
assert_eq!(event.duration_seconds, DISPLAY_DURATION);
// Queue should be cleared
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(!q_query.single(&world).unwrap().has_pending(), "queue cleared after fire");
// last_fired_tick should be updated
let mut state_query = world.query::<&MonologueState>();
assert_eq!(
state_query.single(&world).unwrap().last_fired_tick,
ANOMALY_DELAY_TICKS,
"last_fired_tick updated for cooldown"
);
}
}