# Conflicts: # client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack # client/tests/fixtures/msgpack/snapshot_empty.msgpack # client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack # client/tests/fixtures/msgpack/snapshot_one_npc.msgpack # client/tests/fixtures/msgpack/snapshot_player.msgpack # client/tests/fixtures/msgpack/snapshot_v2_full.msgpack # server/Cargo.toml # server/src/bridge/text_renderer.rs # server/src/bridge/types.rs # server/src/perception/observer/mod.rs # server/src/simulation/path_follow.rs # server/tests/bridge_ipc.rs # server/tests/bridge_tcp.rs # server/tests/gen_fixtures.rs # server/tests/serialization.rs
228 lines
7.0 KiB
Rust
228 lines
7.0 KiB
Rust
//! 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};
|
|
use crate::simulation::tier::ActiveSim;
|
|
|
|
/// 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.
|
|
///
|
|
/// Scoped to `ActiveSim` NPCs — only entities in the Active tier execute
|
|
/// path movement each tick (D-026, #94). Background/StateSaved NPCs do not
|
|
/// process path steps.
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub fn follow_paths(
|
|
mut commands: Commands,
|
|
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With<Npc>, With<ActiveSim>)>,
|
|
) {
|
|
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::<ComputedPath>();
|
|
tracing::trace!("Entity {:?}: path complete", entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// System: clean up PathBlocked markers after one tick.
|
|
pub fn cleanup_path_blocked(mut commands: Commands, query: Query<Entity, With<PathBlocked>>) {
|
|
for entity in query.iter() {
|
|
commands.entity(entity).remove::<PathBlocked>();
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
super::ActiveSim, // system requires With<ActiveSim> (#94)
|
|
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::<MoveIntent>(entity).unwrap();
|
|
assert_eq!(intent.target, TilePosition::new(1, 0, 0));
|
|
// Path advanced to index 1
|
|
let path = world.get::<ComputedPath>(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,
|
|
super::ActiveSim, // system requires With<ActiveSim> (#94)
|
|
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::<ComputedPath>(entity).is_none());
|
|
// But MoveIntent was still created
|
|
assert!(world.get::<MoveIntent>(entity).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn movement_speed_throttles() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
|
|
let entity = world
|
|
.spawn((
|
|
Npc,
|
|
super::ActiveSim, // system requires With<ActiveSim> (#94)
|
|
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::<MoveIntent>(entity).is_none());
|
|
|
|
// Tick 2: no step (2/3)
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(entity).is_none());
|
|
|
|
// Tick 3: step! (3/3)
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(entity).is_some());
|
|
assert_eq!(
|
|
world.get::<MoveIntent>(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::<MoveIntent>(entity).is_none());
|
|
// Path unchanged
|
|
assert_eq!(world.get::<ComputedPath>(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::<PathBlocked>(entity).is_none());
|
|
}
|
|
}
|