diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index f7d3811c6..685262263 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -47,13 +47,17 @@ pub struct PlayerInput { pub action: PlayerAction, } -/// Player action variants +/// Player action variants — 8-directional movement for immersive sim genre expectations #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PlayerAction { MoveNorth, MoveSouth, MoveEast, MoveWest, + MoveNortheast, + MoveNorthwest, + MoveSoutheast, + MoveSouthwest, Interact, UsePerceptionMode(String), Pause, diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index f0f510abe..afdbf0fd4 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -36,9 +36,6 @@ impl TilePosition { /// Returns the four cardinal neighbors (N/S/E/W) on the same z-level. /// Y-down convention: North = y-1, South = y+1, East = x+1, West = x-1. - /// - /// TODO: Diagonal movement (8-directional) for genre expectations. - /// TODO: Chunk boundary awareness for neighbors in different chunks. pub fn cardinal_neighbors(&self) -> [TilePosition; 4] { [ TilePosition::new(self.x, self.y - 1, self.z), // North @@ -48,6 +45,20 @@ impl TilePosition { ] } + /// Returns all 8 neighbors (cardinal + diagonal) on the same z-level. + pub fn all_neighbors(&self) -> [TilePosition; 8] { + [ + TilePosition::new(self.x, self.y - 1, self.z), // North + TilePosition::new(self.x, self.y + 1, self.z), // South + TilePosition::new(self.x + 1, self.y, self.z), // East + TilePosition::new(self.x - 1, self.y, self.z), // West + TilePosition::new(self.x + 1, self.y - 1, self.z), // Northeast + TilePosition::new(self.x - 1, self.y - 1, self.z), // Northwest + TilePosition::new(self.x + 1, self.y + 1, self.z), // Southeast + TilePosition::new(self.x - 1, self.y + 1, self.z), // Southwest + ] + } + /// Convert to f32 coordinates for VisibleEntity wire format (D-020). /// Maps tile center to float position (tile 0 → 0.5, tile 1 → 1.5, etc.) pub fn to_render_coords(&self) -> (f32, f32, i32) { @@ -122,8 +133,6 @@ impl ChunkData { /// Stores walkability per tile in CHUNK_SIZE x CHUNK_SIZE chunks. /// Supports chunk load/unload for future borderless generation. /// Unloaded chunks are treated as unwalkable. -/// -/// TODO: Entity-entity collision (multiple entities on same tile). #[derive(Resource, Debug, Clone)] pub struct WalkabilityMap { chunks: HashMap, @@ -213,37 +222,48 @@ pub struct MoveIntent { } /// System to validate and execute movement intents. -/// Checks walkability map and updates positions for valid moves. +/// Checks walkability map AND entity-entity collision before allowing moves. +/// Processes all intents in a single pass: first collect occupied tiles from +/// entities without intents, then resolve movers in order — first valid claim +/// to a tile wins. /// Always removes MoveIntent component after processing. pub fn validate_movement( mut commands: Commands, walkability: Option>, - mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>, + mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>, + stationary: Query<(Entity, &TilePosition), Without>, ) { let Some(map) = walkability else { tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); - for (entity, _, _) in query.iter() { + for (entity, _, _) in movers.iter() { commands.entity(entity).remove::(); } return; }; - for (entity, intent, mut position) in query.iter_mut() { - if map.can_move_to(&intent.target) { + // Collect tiles occupied by stationary entities (no MoveIntent) + let mut occupied: HashMap = HashMap::new(); + for (entity, pos) in stationary.iter() { + occupied.insert(*pos, entity); + } + + for (entity, intent, mut position) in movers.iter_mut() { + let target = &intent.target; + if !map.can_move_to(target) { + tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target); + } else if occupied.contains_key(target) { + tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target); + } else { tracing::trace!( "Entity {:?} moving from {:?} to {:?}", entity, *position, - intent.target - ); - *position = intent.target; - } else { - tracing::trace!( - "Entity {:?} blocked at {:?}, cannot move to {:?}", - entity, - *position, - intent.target + target ); + // Free old tile, claim new tile + occupied.remove(&*position); + *position = *target; + occupied.insert(*target, entity); } commands.entity(entity).remove::(); } @@ -440,6 +460,88 @@ mod tests { assert!(world.get::(entity).is_none()); } + #[test] + fn all_neighbors_correct() { + let pos = TilePosition::new(5, 5, 0); + let neighbors = pos.all_neighbors(); + + assert_eq!(neighbors[0], TilePosition::new(5, 4, 0)); // North + assert_eq!(neighbors[1], TilePosition::new(5, 6, 0)); // South + assert_eq!(neighbors[2], TilePosition::new(6, 5, 0)); // East + assert_eq!(neighbors[3], TilePosition::new(4, 5, 0)); // West + assert_eq!(neighbors[4], TilePosition::new(6, 4, 0)); // Northeast + assert_eq!(neighbors[5], TilePosition::new(4, 4, 0)); // Northwest + assert_eq!(neighbors[6], TilePosition::new(6, 6, 0)); // Southeast + assert_eq!(neighbors[7], TilePosition::new(4, 6, 0)); // Southwest + } + + #[test] + fn validate_movement_blocks_occupied_tile() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Stationary entity at target tile + world.spawn(TilePosition::new(5, 4, 0)); + + // Mover tries to move into occupied tile + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + // Mover stayed put + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 5, 0) + ); + assert!(world.get::(mover).is_none()); + } + + #[test] + fn validate_movement_two_movers_same_target_first_wins() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity_a = world + .spawn(( + TilePosition::new(5, 4, 0), + MoveIntent { + target: TilePosition::new(5, 5, 0), + }, + )) + .id(); + + let entity_b = world + .spawn(( + TilePosition::new(5, 6, 0), + MoveIntent { + target: TilePosition::new(5, 5, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let pos_a = *world.get::(entity_a).unwrap(); + let pos_b = *world.get::(entity_b).unwrap(); + + // Exactly one should have moved to (5,5), the other stays + let one_moved = + (pos_a == TilePosition::new(5, 5, 0)) ^ (pos_b == TilePosition::new(5, 5, 0)); + assert!(one_moved, "exactly one entity should occupy the target"); + assert_ne!(pos_a, pos_b, "both entities must not share a tile"); + } + #[test] fn validate_movement_blocks_unloaded_chunk() { let mut world = bevy_ecs::world::World::new(); diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 568aa6618..abe61f56a 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -58,6 +58,10 @@ fn all_player_action_variants_roundtrip() { PlayerAction::MoveSouth, PlayerAction::MoveEast, PlayerAction::MoveWest, + PlayerAction::MoveNortheast, + PlayerAction::MoveNorthwest, + PlayerAction::MoveSoutheast, + PlayerAction::MoveSouthwest, PlayerAction::Interact, PlayerAction::UsePerceptionMode("thermal".to_string()), PlayerAction::Pause,