//! Daily routine system (#88). //! //! Detects day-phase transitions (D-031) and issues PathRequests for NPCs //! whose DailyRoutine has a location for the new phase. use bevy_ecs::prelude::*; use crate::npc::{DailyRoutine, Npc}; use crate::simulation::movement::TilePosition; use crate::simulation::pathfinding::PathRequest; use crate::simulation::time::{DayPhase, SimulationTime}; /// 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. pub fn check_phase_transition( time: Res, mut previous: ResMut, mut commands: Commands, npcs: Query<(Entity, &TilePosition, &DailyRoutine), 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() { 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 ); } } } } #[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, 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, 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, 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, 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, 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); } }