diff --git a/server/Cargo.lock b/server/Cargo.lock index 4bba5b658..f397f2e21 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -378,6 +378,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "deprecate-until" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704" +dependencies = [ + "proc-macro2", + "quote", + "semver", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -595,6 +607,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "integer-sqrt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" +dependencies = [ + "num-traits", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -701,6 +722,20 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pathfinding" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ac35caa284c08f3721fb33c2741b5f763decaf42d080c8a6a722154347017e" +dependencies = [ + "deprecate-until", + "indexmap", + "integer-sqrt", + "num-traits", + "rustc-hash", + "thiserror", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -846,6 +881,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -904,6 +945,7 @@ dependencies = [ "bevy_app", "bevy_ecs", "bincode", + "pathfinding", "rand", "rand_chacha", "rmp-serde", diff --git a/server/Cargo.toml b/server/Cargo.toml index 6afe63cbd..5bf983a03 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -11,6 +11,7 @@ rmp-serde = "1" bincode = "1" rand = "0.9" rand_chacha = "0.9" +pathfinding = "4" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index e82407744..937b3384e 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -6,6 +6,8 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; pub mod movement; +pub mod path_follow; +pub mod pathfinding; pub mod rng; pub mod tier; pub mod time; @@ -24,8 +26,11 @@ impl Plugin for SimulationPlugin { Update, ( input::process_player_input, - movement::validate_movement.after(input::process_player_input), - time::advance_tick.after(movement::validate_movement), + pathfinding::compute_paths.after(input::process_player_input), + path_follow::follow_paths.after(pathfinding::compute_paths), + movement::validate_movement.after(path_follow::follow_paths), + path_follow::cleanup_path_blocked.after(movement::validate_movement), + time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs new file mode 100644 index 000000000..2e0d6bd88 --- /dev/null +++ b/server/src/simulation/path_follow.rs @@ -0,0 +1,221 @@ +//! 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()); + } +} diff --git a/server/src/simulation/pathfinding.rs b/server/src/simulation/pathfinding.rs new file mode 100644 index 000000000..b4e53d319 --- /dev/null +++ b/server/src/simulation/pathfinding.rs @@ -0,0 +1,284 @@ +//! Tile-based A* pathfinding (#237). +//! +//! Computes paths over the WalkabilityMap using the `pathfinding` crate. +//! NPCs request paths via PathRequest component; the compute_paths system +//! resolves them into ComputedPath (success) or PathBlocked (no route). + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::simulation::movement::{TilePosition, WalkabilityMap}; + +/// Component requesting a path from current position to a goal. +/// Consumed by the compute_paths system each tick. +#[derive(Component, Debug, Clone)] +pub struct PathRequest { + pub goal: TilePosition, +} + +/// Component holding a computed path. +/// Steps run from start (exclusive) to goal (inclusive). +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct ComputedPath { + pub steps: Vec, + pub current_index: usize, +} + +impl ComputedPath { + /// Get the next step in the path, or None if finished. + pub fn next_step(&self) -> Option<&TilePosition> { + self.steps.get(self.current_index) + } + + /// Advance to the next step. Returns true if there are more steps. + pub fn advance(&mut self) -> bool { + if self.current_index < self.steps.len() { + self.current_index += 1; + } + self.current_index < self.steps.len() + } + + /// Whether the path has been fully traversed. + pub fn is_complete(&self) -> bool { + self.current_index >= self.steps.len() + } + + /// Remaining steps count. + pub fn remaining(&self) -> usize { + self.steps.len().saturating_sub(self.current_index) + } +} + +/// Marker component: pathfinding failed, no route exists. +#[derive(Component, Debug, Clone)] +pub struct PathBlocked; + +/// System: compute paths for entities with PathRequest components. +/// Uses A* over the WalkabilityMap with cardinal movement (4 neighbors). +/// Removes PathRequest and inserts ComputedPath or PathBlocked. +pub fn compute_paths( + mut commands: Commands, + walkability: Option>, + queries: Query<(Entity, &TilePosition, &PathRequest)>, +) { + let Some(walkability) = walkability else { + // No map loaded — consume requests and mark blocked + for (entity, _, _) in queries.iter() { + commands.entity(entity).remove::(); + commands.entity(entity).insert(PathBlocked); + } + return; + }; + + for (entity, current_pos, request) in queries.iter() { + commands.entity(entity).remove::(); + + if *current_pos == request.goal { + commands.entity(entity).insert(ComputedPath { + steps: Vec::new(), + current_index: 0, + }); + continue; + } + + let goal = request.goal; + let result = pathfinding::directed::astar::astar( + current_pos, + |pos| { + pos.cardinal_neighbors() + .into_iter() + .filter(|neighbor| walkability.can_move_to(neighbor)) + .map(|neighbor| (neighbor, 1u32)) + }, + |pos| pos.manhattan_distance(&goal).unwrap_or(u32::MAX), + |pos| *pos == goal, + ); + + match result { + Some((path, _cost)) => { + // path includes start position; skip it + let steps: Vec = path.into_iter().skip(1).collect(); + tracing::trace!("Entity {:?}: path to {:?}, {} steps", entity, goal, steps.len()); + commands.entity(entity).insert(ComputedPath { + steps, + current_index: 0, + }); + } + None => { + tracing::trace!("Entity {:?}: no path to {:?}", entity, goal); + commands.entity(entity).insert(PathBlocked); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_to_adjacent_tile() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + PathRequest { + goal: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + assert!(world.get::(entity).is_none()); + let path = world.get::(entity).unwrap(); + assert_eq!(path.steps, vec![TilePosition::new(5, 4, 0)]); + assert_eq!(path.current_index, 0); + } + + #[test] + fn path_around_wall() { + let mut world = bevy_ecs::world::World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + // Wall at (5,4) blocks direct north + map.set_walkable(&TilePosition::new(5, 4, 0), false); + world.insert_resource(map); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + PathRequest { + goal: TilePosition::new(5, 3, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + let path = world.get::(entity).unwrap(); + assert!(!path.steps.is_empty()); + // Path should end at goal + assert_eq!(*path.steps.last().unwrap(), TilePosition::new(5, 3, 0)); + // Path should not go through the wall + assert!(!path.steps.contains(&TilePosition::new(5, 4, 0))); + } + + #[test] + fn path_to_same_position() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + PathRequest { + goal: TilePosition::new(5, 5, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + let path = world.get::(entity).unwrap(); + assert!(path.steps.is_empty()); + assert!(path.is_complete()); + } + + #[test] + fn path_blocked_no_route() { + let mut world = bevy_ecs::world::World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + // Surround goal with walls + let goal = TilePosition::new(5, 3, 0); + for neighbor in goal.cardinal_neighbors() { + map.set_walkable(&neighbor, false); + } + world.insert_resource(map); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + PathRequest { goal }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + assert!(world.get::(entity).is_none()); + assert!(world.get::(entity).is_none()); + assert!(world.get::(entity).is_some()); + } + + #[test] + fn computed_path_navigation() { + let mut path = ComputedPath { + steps: vec![ + TilePosition::new(1, 0, 0), + TilePosition::new(2, 0, 0), + TilePosition::new(3, 0, 0), + ], + current_index: 0, + }; + + assert_eq!(path.remaining(), 3); + assert!(!path.is_complete()); + + assert_eq!(*path.next_step().unwrap(), TilePosition::new(1, 0, 0)); + assert!(path.advance()); // -> index 1 + assert_eq!(*path.next_step().unwrap(), TilePosition::new(2, 0, 0)); + assert!(path.advance()); // -> index 2 + assert_eq!(*path.next_step().unwrap(), TilePosition::new(3, 0, 0)); + assert!(!path.advance()); // -> index 3, no more steps + assert!(path.is_complete()); + assert!(path.next_step().is_none()); + assert_eq!(path.remaining(), 0); + } + + #[test] + fn path_deterministic() { + let map = { + let mut m = WalkabilityMap::new(20, 20, 1); + // Add some walls to make routing interesting + for y in 3..8 { + m.set_walkable(&TilePosition::new(5, y, 0), false); + } + m + }; + + // Run pathfinding twice with same setup + let mut results = Vec::new(); + for _ in 0..2 { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(map.clone()); + world.spawn(( + TilePosition::new(4, 5, 0), + PathRequest { + goal: TilePosition::new(6, 5, 0), + }, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + let mut paths: Vec<_> = world + .query::<&ComputedPath>() + .iter(&world) + .map(|p| p.steps.clone()) + .collect(); + results.push(paths.pop().unwrap()); + } + + assert_eq!(results[0], results[1], "pathfinding must be deterministic"); + } +}