//! NPC path following system (#238). //! //! Per-tick NPC position updates along computed paths. //! Separate from pathfinding — this is the movement execution system. use bevy_ecs::prelude::*; use crate::npc::Npc; use crate::simulation::movement::MoveIntent; use crate::simulation::pathfinding::{ComputedPath, PathBlocked}; /// Movement speed component. Controls ticks between path steps. /// Default: 1 step per tick. Higher values = slower movement. #[derive(Component, Debug, Clone)] pub struct MovementSpeed { pub ticks_per_step: u32, ticks_since_last_step: u32, } impl Default for MovementSpeed { fn default() -> Self { Self { ticks_per_step: 1, ticks_since_last_step: 0, } } } impl MovementSpeed { pub fn new(ticks_per_step: u32) -> Self { Self { ticks_per_step: ticks_per_step.max(1), ticks_since_last_step: 0, } } /// Returns true if entity should step this tick. fn should_step(&mut self) -> bool { self.ticks_since_last_step += 1; if self.ticks_since_last_step >= self.ticks_per_step { self.ticks_since_last_step = 0; true } else { false } } } /// System: NPC entities with ComputedPath advance along their path. /// Creates MoveIntent for the next step. Removes ComputedPath when complete. pub fn follow_paths( mut commands: Commands, mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With>, ) { for (entity, mut path, speed_opt) in query.iter_mut() { if let Some(mut speed) = speed_opt { if !speed.should_step() { continue; } } if let Some(next_pos) = path.next_step() { commands .entity(entity) .insert(MoveIntent { target: *next_pos }); path.advance(); } if path.is_complete() { commands.entity(entity).remove::(); tracing::trace!("Entity {:?}: path complete", entity); } } } /// System: clean up PathBlocked markers after one tick. pub fn cleanup_path_blocked(mut commands: Commands, query: Query>) { for entity in query.iter() { commands.entity(entity).remove::(); } } #[cfg(test)] mod tests { use super::*; use crate::simulation::movement::TilePosition; use crate::simulation::pathfinding::ComputedPath; #[test] fn npc_follows_path_one_step() { let mut world = bevy_ecs::world::World::new(); let entity = world .spawn(( Npc, TilePosition::new(0, 0, 0), ComputedPath { steps: vec![ TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0), TilePosition::new(3, 0, 0), ], current_index: 0, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(follow_paths); schedule.run(&mut world); // MoveIntent should target step 0 let intent = world.get::(entity).unwrap(); assert_eq!(intent.target, TilePosition::new(1, 0, 0)); // Path advanced to index 1 let path = world.get::(entity).unwrap(); assert_eq!(path.current_index, 1); } #[test] fn npc_path_complete_removes_component() { let mut world = bevy_ecs::world::World::new(); let entity = world .spawn(( Npc, TilePosition::new(2, 0, 0), ComputedPath { steps: vec![TilePosition::new(3, 0, 0)], current_index: 0, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(follow_paths); schedule.run(&mut world); // After consuming the last step, ComputedPath should be removed assert!(world.get::(entity).is_none()); // But MoveIntent was still created assert!(world.get::(entity).is_some()); } #[test] fn movement_speed_throttles() { let mut world = bevy_ecs::world::World::new(); let entity = world .spawn(( Npc, TilePosition::new(0, 0, 0), ComputedPath { steps: vec![ TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0), ], current_index: 0, }, MovementSpeed::new(3), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(follow_paths); // Tick 1: no step (1/3) schedule.run(&mut world); assert!(world.get::(entity).is_none()); // Tick 2: no step (2/3) schedule.run(&mut world); assert!(world.get::(entity).is_none()); // Tick 3: step! (3/3) schedule.run(&mut world); assert!(world.get::(entity).is_some()); assert_eq!( world.get::(entity).unwrap().target, TilePosition::new(1, 0, 0) ); } #[test] fn non_npc_entity_ignored() { let mut world = bevy_ecs::world::World::new(); // Entity without Npc marker let entity = world .spawn(( TilePosition::new(0, 0, 0), ComputedPath { steps: vec![TilePosition::new(1, 0, 0)], current_index: 0, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(follow_paths); schedule.run(&mut world); // Should NOT have MoveIntent since it's not an Npc assert!(world.get::(entity).is_none()); // Path unchanged assert_eq!(world.get::(entity).unwrap().current_index, 0); } #[test] fn cleanup_path_blocked_removes_marker() { let mut world = bevy_ecs::world::World::new(); let entity = world.spawn((Npc, PathBlocked)).id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(cleanup_path_blocked); schedule.run(&mut world); assert!(world.get::(entity).is_none()); } }