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
+3 -1
View File
@@ -161,10 +161,12 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::trigger_monologue
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::process_sprint_anomaly_monologue
.after(crate::simulation::monologue::trigger_monologue),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::trigger_monologue)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+8 -2
View File
@@ -15,9 +15,10 @@ use settled_reach_server::npc::{
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
@@ -63,7 +64,8 @@ fn main() {
let mut registry = EntityRegistry::new(0);
// Player at (16,16)
// Player at (16,16) — smuggler archetype (#418, D-053)
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
@@ -74,6 +76,10 @@ fn main() {
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
+22 -3
View File
@@ -16,7 +16,7 @@ use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::MonologueBuffer;
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
@@ -53,7 +53,7 @@ pub fn compute_observer_snapshot(
geometry: Res<VisibilityGeometry>,
registry: Res<EntityRegistry>,
mut observer_query: Query<
(Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>),
(Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>),
With<PlayerCharacter>,
>,
all_entities: Query<(
@@ -65,7 +65,7 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) =
let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) =
observer_query.single_mut()
else {
return;
@@ -101,6 +101,25 @@ pub fn compute_observer_snapshot(
&mut entities,
);
// Sprint anomaly detection (#428, D-055)
// When sprinting, scan visible entities for Contradicted KG state.
// Queue the first match for delayed "double-take" monologue.
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
if let Some(anomaly_queue) = anomaly_queue_opt.as_mut() {
if !anomaly_queue.has_pending() {
for &wire_id in &visible_ids {
let stable_id = StableId(wire_id);
if let Some(knowledge) = observer_kg.entity_knowledge(&stable_id) {
if knowledge.state == KnowledgeState::Contradicted {
anomaly_queue.push_anomaly(wire_id, time.tick);
break; // First-in wins
}
}
}
}
}
}
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
+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"
);
}
}