Files
settled-reach/server/src/simulation/path_follow.rs
T
jpmschweitzer 4bdcc8c3fa Merge remote-tracking branch 'origin/ci'
# 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
2026-02-19 15:35:56 +01:00

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());
}
}