From df81b24cd1301a5114e9298c0cc72d63990579a1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 16:00:20 +0100 Subject: [PATCH] feat(simulation): sprint anomaly double-take monologue (#428, D-055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/bridge/mod.rs | 4 +- server/src/main.rs | 10 +- server/src/perception/observer/mod.rs | 25 +- server/src/simulation/monologue.rs | 426 ++++++++++++++++++++++++++ 4 files changed, 459 insertions(+), 6 deletions(-) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 5c3dd298e..98663e6fa 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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), diff --git a/server/src/main.rs b/server/src/main.rs index 7eb3c6f1b..0301e2f61 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index f3f6c8e38..cd80ec780 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, registry: Res, 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, >, all_entities: Query<( @@ -65,7 +65,7 @@ pub fn compute_observer_snapshot( inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { - 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(), diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4e2421542..3f53c8b19 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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, +} + +#[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 { + 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, + mut rng: ResMut, + mut query: Query< + (&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState), + With, + >, +) { + 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::(); + 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::().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::().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::().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::().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::().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::().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::().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" + ); + } }