//! 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}; use crate::npc::{DailyRoutine, Npc}; use crate::simulation::movement::TilePosition; use crate::simulation::pathfinding::{ComputedPath, PathRequest}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{DayPhase, SimulationTime}; // --------------------------------------------------------------------------- // ActivityState component (#101) // --------------------------------------------------------------------------- /// Tracks the activity an NPC is currently performing at their routine location. /// /// Set by `enter_activity` when an NPC: /// 1. Has no active `ComputedPath` or `PathRequest` (finished walking) /// 2. Is at the location specified by their `DailyRoutine` for the current phase /// /// Cleared on phase transitions (replaced with new activity or removed). /// Feeds `TellTrigger::DuringActivity` and D-028 Layer 2 situation matching. #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct ActivityState { /// Activity name from `RoutineEntry.activity` (e.g., "Work", "Bar", "Sleep"). pub activity: String, /// The day phase this activity belongs to. pub phase: DayPhase, /// Tick when the NPC arrived and started this activity. pub started_tick: u64, } /// Resource tracking the previous day phase for transition detection. #[derive(Resource, Debug, Clone)] pub struct PreviousDayPhase { pub phase: DayPhase, pub day: u64, } impl Default for PreviousDayPhase { fn default() -> Self { Self { phase: DayPhase::Morning, day: 0, } } } /// System: detect day-phase transitions and issue PathRequests for NPC routines. /// Runs after advance_tick so the current phase is up-to-date. /// /// Scoped to `ActiveSim` NPCs — only active-tier NPCs receive routine-based /// PathRequests on phase transitions (D-026, #94). pub fn check_phase_transition( time: Res, mut previous: ResMut, mut commands: Commands, npcs: Query<(Entity, &TilePosition, &DailyRoutine), (With, With)>, ) { let current_phase = time.day_phase(); let current_day = time.day(); if current_phase == previous.phase && current_day == previous.day { return; } tracing::debug!( "Day phase transition: {:?} -> {:?} (day {} -> {})", previous.phase, current_phase, previous.day, current_day ); previous.phase = current_phase; previous.day = current_day; for (entity, current_pos, routine) in npcs.iter() { // Clear stale activity on phase transition — will be re-evaluated by enter_activity commands.entity(entity).remove::(); if let Some(expected_location) = routine.expected_location(current_phase) { if *current_pos != expected_location { commands.entity(entity).insert(PathRequest { goal: expected_location, }); tracing::trace!( "Entity {:?}: routine path request to {:?} for {:?}", entity, expected_location, current_phase ); } } } } // --------------------------------------------------------------------------- // System: enter_activity (#101) // --------------------------------------------------------------------------- /// Set ActivityState when an NPC has arrived at their routine destination. /// /// Runs after movement validation. Checks NPCs that: /// - Have a DailyRoutine and ActiveSim tier /// - Are NOT currently pathfinding (no ComputedPath or PathRequest) /// - Are at the location specified for the current day phase /// - Don't already have the correct ActivityState for the current phase /// /// When conditions are met, inserts an ActivityState component. When an NPC /// has a stale activity from a previous phase and isn't at the new phase's /// destination, the stale activity is removed. /// /// System ordering: after validate_movement, before compute_observer_snapshot. pub fn enter_activity( mut commands: Commands, time: Res, npcs: Query< (Entity, &TilePosition, &DailyRoutine, Option<&ActivityState>), ( With, With, Without, Without, ), >, ) { let current_phase = time.day_phase(); for (entity, pos, routine, activity_opt) in npcs.iter() { // Already performing the correct activity for this phase if let Some(activity) = activity_opt { if activity.phase == current_phase { continue; } } // Check if at routine destination for current phase if let Some(entry) = routine.entry_for_phase(current_phase) { if *pos == entry.location { commands.entity(entity).insert(ActivityState { activity: entry.activity.clone(), phase: current_phase, started_tick: time.tick, }); tracing::trace!( "Entity {:?}: entered activity '{}' for {:?}", entity, entry.activity, current_phase, ); } else { // Not at destination yet — remove stale activity commands.entity(entity).remove::(); } } else { // No routine entry for this phase — remove stale activity commands.entity(entity).remove::(); } } } // --------------------------------------------------------------------------- // 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::*; use crate::npc::RoutineEntry; use crate::simulation::time::{MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE}; fn setup_world() -> bevy_ecs::world::World { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); world.init_resource::(); world } #[test] fn phase_transition_generates_path_request() { let mut world = setup_world(); let afternoon_loc = TilePosition::new(10, 10, 0); let entity = world .spawn(( Npc, crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(5, 5, 0), // Not at afternoon location DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: afternoon_loc, activity: "Work".into(), }], description: "Test".into(), }, )) .id(); // Advance time to Afternoon boundary world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); let request = world.get::(entity).unwrap(); assert_eq!(request.goal, afternoon_loc); } #[test] fn no_transition_no_request() { let mut world = setup_world(); let entity = 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(), }, )) .id(); // Time still at Morning (tick 0), same as PreviousDayPhase default let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); assert!(world.get::(entity).is_none()); } #[test] fn npc_already_at_destination_no_request() { let mut world = setup_world(); let loc = TilePosition::new(10, 10, 0); let entity = world .spawn(( Npc, ActiveSim, loc, // Already at afternoon location DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: loc, activity: "Work".into(), }], description: "Test".into(), }, )) .id(); world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); assert!(world.get::(entity).is_none()); } #[test] fn npc_without_routine_entry_ignored() { let mut world = setup_world(); 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(), }, )) .id(); // Transition to Afternoon, but NPC only has Morning entry world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); assert!(world.get::(entity).is_none()); } #[test] fn day_rollover_triggers_morning_routine() { let mut world = setup_world(); // Start at Night let night_tick = 1080 * TICKS_PER_GAME_MINUTE; world.resource_mut::().tick = night_tick; world.resource_mut::().phase = DayPhase::Night; world.resource_mut::().day = 0; let morning_loc = TilePosition::new(3, 3, 0); let entity = world .spawn(( Npc, crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(20, 20, 0), DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Morning, location: morning_loc, activity: "Wake up".into(), }], description: "Test".into(), }, )) .id(); // Advance to next day's Morning (day 1, tick 0 of new day) world.resource_mut::().tick = 1440 * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); let request = world.get::(entity).unwrap(); assert_eq!(request.goal, morning_loc); } // -- enter_activity tests (#101) ------------------------------------------ #[test] fn npc_at_routine_destination_gets_activity_state() { let mut world = setup_world(); // Time = Afternoon 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, // Already at afternoon destination DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: loc, activity: "Work".into(), }], description: "Test".into(), }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); let state = world.get::(entity).unwrap(); assert_eq!(state.activity, "Work"); assert_eq!(state.phase, DayPhase::Afternoon); assert_eq!( state.started_tick, MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE ); } #[test] fn npc_not_at_destination_no_activity_state() { let mut world = setup_world(); world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let entity = world .spawn(( Npc, ActiveSim, TilePosition::new(5, 5, 0), // NOT at afternoon location DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: TilePosition::new(10, 10, 0), activity: "Work".into(), }], description: "Test".into(), }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); assert!(world.get::(entity).is_none()); } #[test] fn npc_with_computed_path_excluded() { let mut world = setup_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 destination but still has a path DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: loc, activity: "Work".into(), }], description: "Test".into(), }, ComputedPath { steps: vec![], current_index: 0, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); assert!( world.get::(entity).is_none(), "NPC with ComputedPath should not get ActivityState" ); } #[test] fn npc_with_path_request_excluded() { let mut world = setup_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, DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Afternoon, location: loc, activity: "Work".into(), }], description: "Test".into(), }, PathRequest { goal: loc }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); assert!( world.get::(entity).is_none(), "NPC with PathRequest should not get ActivityState" ); } #[test] fn existing_activity_same_phase_not_overwritten() { let mut world = setup_world(); let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; world.resource_mut::().tick = tick + 100; let loc = TilePosition::new(10, 10, 0); let entity = 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: tick, // Set earlier }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); let state = world.get::(entity).unwrap(); assert_eq!( state.started_tick, tick, "started_tick should be preserved, not updated" ); } #[test] fn stale_activity_replaced_on_phase_change() { let mut world = setup_world(); // Time = Evening (after Afternoon) let evening_tick = 2 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; world.resource_mut::().tick = evening_tick; let evening_loc = TilePosition::new(20, 20, 0); let entity = world .spawn(( Npc, ActiveSim, evening_loc, // Already at evening location DailyRoutine { entries: vec![ RoutineEntry { phase: DayPhase::Afternoon, location: TilePosition::new(10, 10, 0), activity: "Work".into(), }, RoutineEntry { phase: DayPhase::Evening, location: evening_loc, activity: "Bar".into(), }, ], description: "Test".into(), }, // Stale activity from previous phase ActivityState { activity: "Work".into(), phase: DayPhase::Afternoon, started_tick: 1000, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); let state = world.get::(entity).unwrap(); assert_eq!(state.activity, "Bar"); assert_eq!(state.phase, DayPhase::Evening); assert_eq!(state.started_tick, evening_tick); } #[test] fn no_routine_for_phase_clears_stale_activity() { let mut world = setup_world(); // Time = Night let night_tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; world.resource_mut::().tick = night_tick; let entity = world .spawn(( Npc, ActiveSim, TilePosition::new(10, 10, 0), DailyRoutine { entries: vec![RoutineEntry { phase: DayPhase::Evening, location: TilePosition::new(10, 10, 0), activity: "Bar".into(), }], description: "Test".into(), }, // Stale activity from Evening, no Night entry ActivityState { activity: "Bar".into(), phase: DayPhase::Evening, started_tick: 1000, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(enter_activity); schedule.run(&mut world); world.flush(); assert!( world.get::(entity).is_none(), "Stale activity should be cleared when no routine entry for current phase" ); } #[test] fn phase_transition_clears_activity_state() { let mut world = setup_world(); let loc = TilePosition::new(10, 10, 0); let entity = world .spawn(( Npc, ActiveSim, loc, DailyRoutine { entries: vec![ RoutineEntry { phase: DayPhase::Morning, location: loc, activity: "Work".into(), }, RoutineEntry { phase: DayPhase::Afternoon, location: TilePosition::new(20, 20, 0), activity: "Lunch".into(), }, ], description: "Test".into(), }, ActivityState { activity: "Work".into(), phase: DayPhase::Morning, started_tick: 0, }, )) .id(); // Trigger phase transition to Afternoon world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); schedule.run(&mut world); world.flush(); // ActivityState should be cleared by phase transition assert!( world.get::(entity).is_none(), "Phase transition should clear ActivityState" ); // 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); } }