From f31be6f86653d6080b938ebea55fbe7ca6afdf9a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:37 +0100 Subject: [PATCH] feat(simulation): add routine deviation detection (#243) RoutineDeviationEvent emitted when NPC breaks daily routine: wrong location for day phase or wrong activity at correct location. CurrentlyDeviating marker prevents duplicate events per episode. Respects pathfinding-in-progress (no false positives). Feeds observation event generator for observe_anomaly monologue triggers. Primary detective mechanic per D-027 criterion 4. Co-Authored-By: Claude Opus 4.6 --- server/src/npc/routine.rs | 650 +++++++++++++++++++++++++++++++++++++- 1 file changed, 649 insertions(+), 1 deletion(-) diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index cbd4ace1d..8c25c75ea 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -1,11 +1,19 @@ -//! Daily routine system (#88, #101). +//! Daily routine system (#88, #101, #243). //! //! Detects day-phase transitions (D-031) and issues PathRequests for NPCs //! whose DailyRoutine has a location for the new phase. Tracks NPC activity //! state when they arrive at their routine destination (#101). //! +//! Routine deviation detection (#243): each tick, compares Active-tier NPCs' +//! current position and activity against their expected routine. Emits +//! `RoutineDeviationEvent` when an NPC deviates from their schedule. This is +//! the primary server-side detective mechanic per D-027 criterion 4. +//! //! Pipeline: phase transition → PathRequest → pathfinder → path_follow → //! NPC arrives → enter_activity sets ActivityState. +//! +//! Deviation pipeline: enter_activity runs → detect_routine_deviation compares +//! position/activity against schedule → emits RoutineDeviationEvent if mismatch. use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; @@ -173,6 +181,191 @@ pub fn enter_activity( } } +// --------------------------------------------------------------------------- +// Routine deviation detection (#243) +// --------------------------------------------------------------------------- + +/// Type of routine deviation detected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum RoutineDeviationType { + /// NPC is not at their expected location for the current day phase, + /// and is not currently travelling there (no PathRequest or ComputedPath). + /// Primary absence detection case — feeds the detective mechanic (D-027). + WrongLocation, + /// NPC is at their expected location for the current phase, but is + /// performing a different activity (or ActivityState is absent). + WrongActivity, +} + +/// Event: an NPC has deviated from their scheduled routine. +/// +/// Emitted once per deviation episode (not every tick while deviated). +/// Feeds `observe_anomaly` monologue triggers (#119, this sprint). +/// Primary detective mechanic per D-027 criterion 4. +#[derive(Debug, Clone)] +pub struct RoutineDeviationEvent { + /// The NPC entity that deviated. + pub entity: Entity, + /// Type of deviation detected. + pub deviation_type: RoutineDeviationType, + /// Current day phase when deviation was detected. + pub phase: DayPhase, + /// Tick when deviation was first detected. + pub tick: u64, + /// Where the NPC should be (from their DailyRoutine). + pub expected_location: TilePosition, + /// Where the NPC actually is. + pub actual_location: TilePosition, + /// Activity NPC should be performing. + pub expected_activity: String, + /// Activity NPC is actually performing (None if ActivityState absent). + pub actual_activity: Option, +} + +/// Resource: queue of routine deviation events. +/// +/// Drained by the observation event generator (#239) which routes them to +/// `observe_anomaly` monologue triggers (#119). +#[derive(Resource, Default)] +pub struct RoutineDeviationEventQueue { + pub events: Vec, +} + +impl RoutineDeviationEventQueue { + pub fn push(&mut self, event: RoutineDeviationEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Marker: NPC is currently deviating from their routine. +/// +/// Inserted by `detect_routine_deviation` on first deviation detection. +/// Removed when NPC returns to their routine. +/// Guards against duplicate events on consecutive deviation ticks. +#[derive(Component, Debug, Clone, Copy)] +pub struct CurrentlyDeviating; + +/// System: detect when Active-tier NPCs deviate from their scheduled routine. +/// +/// Runs after `enter_activity` (so ActivityState is current). For each Active +/// NPC with a `DailyRoutine` entry for the current phase: +/// - If NPC is not at expected location AND not pathfinding: `WrongLocation`. +/// - If NPC is at expected location but activity is wrong/absent: `WrongActivity`. +/// - If on-schedule: clear `CurrentlyDeviating` marker. +/// +/// NPCs with no routine entry for the current phase are not monitored. +/// Scoped to `ActiveSim` — Background-tier NPCs are not monitored (D-026). +pub fn detect_routine_deviation( + mut commands: Commands, + time: Res, + mut queue: ResMut, + npcs: Query< + ( + Entity, + &TilePosition, + &DailyRoutine, + Option<&ActivityState>, + Option<&PathRequest>, + Option<&ComputedPath>, + Option<&CurrentlyDeviating>, + ), + (With, With), + >, +) { + let phase = time.day_phase(); + let tick = time.tick; + + for (entity, pos, routine, activity_opt, path_req, computed_path, deviating_opt) in + npcs.iter() + { + let Some(entry) = routine.entry_for_phase(phase) else { + // No routine entry for this phase — nothing to deviate from. + // Clear any stale deviation marker from a previous phase. + if deviating_opt.is_some() { + commands.entity(entity).remove::(); + } + continue; + }; + + let is_pathfinding = path_req.is_some() || computed_path.is_some(); + + // Determine deviation type + let deviation = if *pos != entry.location { + if is_pathfinding { + // Still travelling to destination — not deviated yet. + if deviating_opt.is_some() { + commands.entity(entity).remove::(); + } + continue; + } + // Not at expected location, not en route → WrongLocation + Some(RoutineDeviationType::WrongLocation) + } else { + // At expected location — check activity + let correct = match activity_opt { + Some(state) => state.activity == entry.activity && state.phase == phase, + None => false, // No ActivityState when at location → WrongActivity + }; + if correct { + None // On schedule + } else { + Some(RoutineDeviationType::WrongActivity) + } + }; + + match (deviation, deviating_opt) { + (Some(dev_type), None) => { + // New deviation — insert marker and emit event + commands.entity(entity).insert(CurrentlyDeviating); + queue.push(RoutineDeviationEvent { + entity, + deviation_type: dev_type, + phase, + tick, + expected_location: entry.location, + actual_location: *pos, + expected_activity: entry.activity.clone(), + actual_activity: activity_opt.map(|a| a.activity.clone()), + }); + tracing::debug!( + "Entity {:?}: routine deviation {:?} at phase {:?} tick {}", + entity, + dev_type, + phase, + tick + ); + } + (None, Some(_)) => { + // Returned to routine — clear marker + commands.entity(entity).remove::(); + tracing::debug!( + "Entity {:?}: returned to routine at phase {:?} tick {}", + entity, + phase, + tick + ); + } + // (Some, Some): still deviated — no duplicate event + // (None, None): on schedule — no action + _ => {} + } + } +} + +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -657,4 +850,459 @@ mod tests { // PathRequest should be set for the new phase location assert!(world.get::(entity).is_some()); } + + // -- detect_routine_deviation tests (#243) -------------------------------- + + fn setup_deviation_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + fn run_deviation_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(detect_routine_deviation); + schedule.run(world); + world.flush(); + } + + #[test] + fn deviation_event_emitted_wrong_location() { + let mut world = setup_deviation_world(); + // Time = Afternoon + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), // Wrong location, not pathfinding + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + // No PathRequest, no ComputedPath — NPC is just absent + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "should emit one deviation event"); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongLocation); + assert_eq!(evt.expected_location, expected_loc); + assert_eq!(evt.actual_location, TilePosition::new(5, 5, 0)); + assert_eq!(evt.expected_activity, "Work"); + assert!(evt.actual_activity.is_none()); + } + + #[test] + fn no_deviation_when_at_correct_location_and_activity() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + loc, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE, + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!(queue.is_empty(), "on-schedule NPC should not emit a deviation event"); + } + + #[test] + fn no_deviation_when_pathfinding_to_destination() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), // Not there yet + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + PathRequest { goal: expected_loc }, // En route + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "NPC with PathRequest is en route — not yet deviated" + ); + } + + #[test] + fn no_deviation_when_computed_path_active() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ComputedPath { + steps: vec![TilePosition::new(6, 5, 0)], + current_index: 0, + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "NPC with ComputedPath is walking — not yet deviated" + ); + } + + #[test] + fn deviation_event_emitted_wrong_activity() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // At correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Idle".into(), // Wrong activity + phase: DayPhase::Afternoon, + started_tick: 100, + }, + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity); + assert_eq!(evt.expected_activity, "Work"); + assert_eq!(evt.actual_activity.as_deref(), Some("Idle")); + } + + #[test] + fn wrong_activity_when_activity_state_absent_at_location() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // At correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + // No ActivityState — NPC is at location but hasn't settled + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity); + assert!(evt.actual_activity.is_none()); + } + + #[test] + fn no_duplicate_events_while_deviated() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + CurrentlyDeviating, // Already marked as deviating + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "no duplicate deviation event while already marked as deviating" + ); + } + + #[test] + fn deviation_marker_cleared_when_npc_returns_to_routine() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // Now at correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: 100, + }, + CurrentlyDeviating, // Was deviating, now returned + )) + .id(); + + run_deviation_system(&mut world); + + assert!( + world.get::(entity).is_none(), + "CurrentlyDeviating marker should be removed when NPC returns to routine" + ); + let queue = world.resource::(); + assert!(queue.is_empty(), "no event on deviation resolution"); + } + + #[test] + fn no_routine_for_phase_clears_deviation_marker() { + let mut world = setup_deviation_world(); + // Night phase — NPC has no routine entry + world.resource_mut::().tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(5, 5, 0), + activity: "Sleep".into(), + }], + description: "Test".into(), + }, + CurrentlyDeviating, // Stale marker from previous phase + )) + .id(); + + run_deviation_system(&mut world); + + // No entry for Night → marker cleared, no event + assert!( + world.get::(entity).is_none(), + "stale deviation marker cleared when NPC has no routine for current phase" + ); + let queue = world.resource::(); + assert!(queue.is_empty()); + } + + #[test] + fn background_npc_not_monitored_for_deviation() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + world.spawn(( + Npc, + crate::simulation::tier::BackgroundSim, + TilePosition::new(5, 5, 0), // Wrong location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "Background-tier NPCs must not generate deviation events" + ); + } + + #[test] + fn multiple_npcs_deviated_independently() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + + // NPC 1: wrong location (will deviate) + let e1 = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(1, 1, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // NPC 2: on schedule (no deviation) + let loc2 = TilePosition::new(20, 20, 0); + world.spawn(( + Npc, + ActiveSim, + loc2, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc2, + activity: "Bar".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Bar".into(), + phase: DayPhase::Afternoon, + started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE, + }, + )); + + // NPC 3: wrong activity (will deviate) + let loc3 = TilePosition::new(30, 30, 0); + let e3 = world + .spawn(( + Npc, + ActiveSim, + loc3, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc3, + activity: "Inspect".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Loiter".into(), // Wrong + phase: DayPhase::Afternoon, + started_tick: 100, + }, + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 2, "two NPCs should deviate independently"); + + let deviated: Vec = queue.events.iter().map(|e| e.entity).collect(); + assert!(deviated.contains(&e1)); + assert!(deviated.contains(&e3)); + } + + #[test] + fn deviation_event_records_current_tick_and_phase() { + let mut world = setup_deviation_world(); + let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE + 42; + world.resource_mut::().tick = tick; + + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + let evt = &queue.events[0]; + assert_eq!(evt.tick, tick); + assert_eq!(evt.phase, DayPhase::Afternoon); + } }