Files
settled-reach/server/src/simulation/movement.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

1287 lines
43 KiB
Rust

// Tile-based movement and collision system
// Implements Sprint 1 ticket #236: walkability map and movement validation
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
// Extended by #576: TileKind layer per tile (server-authoritative tile classification)
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
// Y-down convention: North = y-1, South = y+1
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::bridge::types::MovementStance;
use crate::knowledge::types::SoundRange;
use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind};
use crate::simulation::stance::Stance;
/// Chunk size in tiles (32x32 per chunk)
pub const CHUNK_SIZE: i32 = 32;
/// Server-side authoritative tile classification (#576, D-012).
///
/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering)
/// but serves a different purpose: simulation logic and tile authoring.
///
/// Tile format for location YAML authoring (#577):
/// - `F` = Floor (walkable, open space)
/// - `W` = Wall (solid obstacle, blocks movement and LOS)
/// - `V` = Void (out-of-bounds / unloaded; treated as blocked)
/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TileKind {
/// Walkable floor tile. Default for populated chunks.
#[default]
Floor,
/// Solid wall tile. Blocks movement and line-of-sight.
Wall,
/// Void / unloaded tile. Out-of-bounds or ungenerated space.
Void,
/// Restricted tile. Blocked for standard movement but accessible
/// to authorised entities (e.g. locked zones, maintenance airlocks).
Restricted,
}
/// Marker component identifying the player-controlled entity.
#[derive(Component, Debug)]
pub struct PlayerCharacter;
/// Posture layer for same-tile occupancy (D-054, #420).
///
/// Multiple entities can share a tile if they occupy different posture layers.
/// Two entities in the same layer on the same tile is a collision.
///
/// Examples: a Standing character can walk past a Seated NPC at a console,
/// a Fixture (terminal) shares a tile with someone Seated at it.
#[derive(
Component,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
Default,
Serialize,
Deserialize,
)]
pub enum TilePresence {
/// Upright position — walking, standing, sprinting. Default for all entities.
#[default]
Standing,
/// Low position — crouching or prone on the ground.
Prone,
/// Seated at furniture, console, or vehicle.
Seated,
/// Immovable world fixture — terminals, furniture, consoles.
/// Occupies its layer permanently.
Fixture,
}
/// Tile position component for grid-based movement.
/// Discrete integer coordinates used in simulation; converted to f32
/// at the bridge boundary for VisibleEntity wire format.
#[derive(
Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
pub struct TilePosition {
pub x: i32,
pub y: i32,
pub z: i32,
}
impl TilePosition {
pub fn new(x: i32, y: i32, z: i32) -> Self {
Self { x, y, z }
}
/// Calculate Manhattan distance to another position.
/// Returns None if positions are on different z-levels.
pub fn manhattan_distance(&self, other: &TilePosition) -> Option<u32> {
if self.z != other.z {
return None;
}
Some(self.x.abs_diff(other.x) + self.y.abs_diff(other.y))
}
/// 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.
pub fn cardinal_neighbors(&self) -> [TilePosition; 4] {
[
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
]
}
/// 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) {
(self.x as f32 + 0.5, self.y as f32 + 0.5, self.z)
}
/// Convert from f32 render coordinates back to tile position (floor).
pub fn from_render_coords(x: f32, y: f32, z: i32) -> Self {
Self {
x: x.floor() as i32,
y: y.floor() as i32,
z,
}
}
/// Get the chunk coordinate this tile belongs to.
pub fn chunk_coord(&self) -> ChunkCoord {
ChunkCoord {
cx: self.x.div_euclid(CHUNK_SIZE),
cy: self.y.div_euclid(CHUNK_SIZE),
z: self.z,
}
}
/// Get the local offset within its chunk.
fn local_offset(&self) -> (i32, i32) {
(self.x.rem_euclid(CHUNK_SIZE), self.y.rem_euclid(CHUNK_SIZE))
}
}
/// Chunk coordinate for chunk-based map storage (D-012).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ChunkCoord {
pub cx: i32,
pub cy: i32,
pub z: i32,
}
/// Per-tile storage cell: walkability flag and tile classification.
/// Private — exposed only through WalkabilityMap's public API.
#[derive(Debug, Clone, Copy)]
struct TileCell {
walkable: bool,
kind: TileKind,
}
/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
/// Extended by #576 to carry TileKind alongside the walkability bool.
#[derive(Debug, Clone)]
struct ChunkData {
tiles: Vec<TileCell>,
}
impl ChunkData {
fn new_walkable() -> Self {
Self {
tiles: vec![
TileCell {
walkable: true,
kind: TileKind::Floor
};
(CHUNK_SIZE * CHUNK_SIZE) as usize
],
}
}
fn new_blocked() -> Self {
Self {
tiles: vec![
TileCell {
walkable: false,
kind: TileKind::Wall
};
(CHUNK_SIZE * CHUNK_SIZE) as usize
],
}
}
fn index(lx: i32, ly: i32) -> usize {
(ly * CHUNK_SIZE + lx) as usize
}
/// Returns the walkability flag for a tile (backward-compatible internal accessor).
fn get(&self, lx: i32, ly: i32) -> bool {
self.tiles[Self::index(lx, ly)].walkable
}
/// Sets only the walkability flag; tile kind is unchanged.
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
self.tiles[Self::index(lx, ly)].walkable = walkable;
}
/// Returns the tile kind for a cell.
fn get_kind(&self, lx: i32, ly: i32) -> TileKind {
self.tiles[Self::index(lx, ly)].kind
}
/// Sets the tile kind for a cell; walkability is unchanged.
fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) {
self.tiles[Self::index(lx, ly)].kind = kind;
}
}
/// Chunk-based walkability map resource (D-012, D-014).
/// 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.
#[derive(Resource, Debug, Clone)]
pub struct WalkabilityMap {
chunks: BTreeMap<ChunkCoord, ChunkData>,
}
impl WalkabilityMap {
/// Create a walkability map covering a rectangular area with all tiles walkable.
/// Generates chunks to cover the specified dimensions on z-level 0..z_levels.
pub fn new(width: i32, height: i32, z_levels: i32) -> Self {
let mut chunks = BTreeMap::new();
let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
for z in 0..z_levels {
for cy in 0..cy_max {
for cx in 0..cx_max {
chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_walkable());
}
}
}
Self { chunks }
}
/// Create a walkability map covering a rectangular area with all tiles blocked.
pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self {
let mut chunks = BTreeMap::new();
let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
for z in 0..z_levels {
for cy in 0..cy_max {
for cx in 0..cx_max {
chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_blocked());
}
}
}
Self { chunks }
}
/// Check if a tile is walkable. Unloaded chunks are treated as unwalkable.
pub fn can_move_to(&self, pos: &TilePosition) -> bool {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
self.chunks
.get(&coord)
.is_some_and(|chunk| chunk.get(lx, ly))
}
/// Set walkability of a tile. Creates the chunk if it doesn't exist.
/// Does not change the tile's TileKind.
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
let chunk = self
.chunks
.entry(coord)
.or_insert_with(ChunkData::new_blocked);
chunk.set(lx, ly, walkable);
}
/// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks.
pub fn tile_kind(&self, pos: &TilePosition) -> TileKind {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
self.chunks
.get(&coord)
.map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly))
}
/// Set the tile kind at a position. Creates the chunk if it doesn't exist.
/// Does not change the tile's walkability.
pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
let chunk = self
.chunks
.entry(coord)
.or_insert_with(ChunkData::new_blocked);
chunk.set_kind(lx, ly, kind);
}
/// Check if a chunk is loaded.
pub fn has_chunk(&self, coord: &ChunkCoord) -> bool {
self.chunks.contains_key(coord)
}
/// Load a chunk (all walkable). Returns false if already loaded.
pub fn load_chunk(&mut self, coord: ChunkCoord) -> bool {
if self.chunks.contains_key(&coord) {
return false;
}
self.chunks.insert(coord, ChunkData::new_walkable());
true
}
/// Unload a chunk. Returns false if not loaded.
pub fn unload_chunk(&mut self, coord: &ChunkCoord) -> bool {
self.chunks.remove(coord).is_some()
}
/// Number of loaded chunks.
pub fn chunk_count(&self) -> usize {
self.chunks.len()
}
/// Returns the coordinates of all loaded chunks (#578).
/// Used by the chunk streaming system to determine which chunks to unload.
pub fn loaded_chunk_coords(&self) -> Vec<ChunkCoord> {
self.chunks.keys().copied().collect()
}
}
/// Component representing an intent to move to a target tile.
#[derive(Component, Debug, Clone)]
pub struct MoveIntent {
pub target: TilePosition,
}
/// System to validate and execute movement intents.
/// Checks walkability map AND layer-based entity collision before allowing moves.
///
/// Same-tile occupancy (D-054, #420): multiple entities can share a tile if they
/// occupy different posture layers (TilePresence). Two entities in the same layer
/// on the same tile is a collision. Entities without TilePresence default to Standing.
///
/// Processes all intents in a single pass: first collect occupied layer slots from
/// entities without intents, then resolve movers in order — first valid claim
/// to a layer slot wins.
/// Always removes MoveIntent component after processing.
#[tracing::instrument(level = "debug", skip_all)]
pub fn validate_movement(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
mut movers: Query<(
Entity,
&MoveIntent,
&mut TilePosition,
Option<&TilePresence>,
Option<&Stance>,
)>,
stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without<MoveIntent>>,
) {
let Some(map) = walkability else {
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
for (entity, _, _, _, _) in movers.iter() {
commands.entity(entity).remove::<MoveIntent>();
}
return;
};
// Collect layer slots occupied by stationary entities (no MoveIntent).
// Key: (position, layer) — two entities can share a tile if different layers.
let mut occupied: BTreeMap<(TilePosition, TilePresence), Entity> = BTreeMap::new();
for (entity, pos, presence) in stationary.iter() {
let layer = presence.copied().unwrap_or_default();
occupied.insert((*pos, layer), entity);
}
// Sort movers by Entity::to_bits() for deterministic collision resolution (#458)
let mut mover_entities: Vec<Entity> = movers.iter().map(|(e, _, _, _, _)| e).collect();
mover_entities.sort_by_key(|e| e.to_bits());
for entity in mover_entities {
let Ok((_, intent, mut position, presence, stance_opt)) = movers.get_mut(entity) else {
continue;
};
let target = intent.target;
let layer = presence.copied().unwrap_or_default();
let slot = (target, layer);
if !map.can_move_to(&target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(&slot) {
tracing::trace!(
"Entity {:?} blocked by entity at {:?} (layer {:?})",
entity,
target,
layer
);
} else {
tracing::trace!(
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
entity,
*position,
target,
layer
);
// Free old layer slot, claim new one
occupied.remove(&(*position, layer));
*position = target;
occupied.insert(slot, entity);
// Emit Footstep sound event (#124, D-018)
let intensity = match stance_opt.map(|s| s.0) {
Some(MovementStance::Sprint) => 0.8,
Some(MovementStance::Walk) | None => 0.5,
Some(MovementStance::Careful) => 0.3,
Some(MovementStance::Crouch) => 0.15,
};
commands
.entity(entity)
.insert(SoundEventEmitter::new(SoundEvent::at(
&target,
SoundEventKind::Footstep,
intensity,
SoundRange::Close,
None,
)));
}
commands.entity(entity).remove::<MoveIntent>();
}
}
#[cfg(test)]
mod tests {
use super::*;
// -----------------------------------------------------------------------
// TileKind layer tests (#576)
// -----------------------------------------------------------------------
#[test]
fn tile_kind_default_is_floor_for_walkable_chunk() {
let map = WalkabilityMap::new(10, 10, 1);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor);
}
#[test]
fn tile_kind_unloaded_chunk_returns_void() {
let map = WalkabilityMap::new(10, 10, 1);
// Negative coords → unloaded chunk → Void
assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void);
assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void);
// z-level not loaded → Void
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void);
}
#[test]
fn tile_kind_round_trip() {
let mut map = WalkabilityMap::new(10, 10, 1);
let pos = TilePosition::new(5, 5, 0);
map.set_tile_kind(&pos, TileKind::Wall);
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
map.set_tile_kind(&pos, TileKind::Restricted);
assert_eq!(map.tile_kind(&pos), TileKind::Restricted);
map.set_tile_kind(&pos, TileKind::Void);
assert_eq!(map.tile_kind(&pos), TileKind::Void);
map.set_tile_kind(&pos, TileKind::Floor);
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
}
#[test]
fn tile_kind_independent_of_walkability() {
let mut map = WalkabilityMap::new(10, 10, 1);
let pos = TilePosition::new(3, 3, 0);
// Start: Floor + walkable
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
assert!(map.can_move_to(&pos));
// Set walkable = false; kind should remain Floor
map.set_walkable(&pos, false);
assert!(!map.can_move_to(&pos));
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
// Set kind = Wall; walkability should remain false
map.set_tile_kind(&pos, TileKind::Wall);
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
assert!(!map.can_move_to(&pos));
// Restore walkable = true; kind should stay Wall
map.set_walkable(&pos, true);
assert!(map.can_move_to(&pos));
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
}
#[test]
fn tile_kind_set_creates_chunk_on_demand() {
let mut map = WalkabilityMap::new(1, 1, 1);
let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk
assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
map.set_tile_kind(&new_chunk_pos, TileKind::Restricted);
assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted);
}
#[test]
fn blocked_chunk_default_kind_is_wall() {
let map = WalkabilityMap::new_blocked(32, 32, 1);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall);
assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall);
}
#[test]
fn tile_position_equality() {
let pos1 = TilePosition::new(5, 10, 0);
let pos2 = TilePosition::new(5, 10, 0);
let pos3 = TilePosition::new(5, 11, 0);
assert_eq!(pos1, pos2);
assert_ne!(pos1, pos3);
}
#[test]
fn manhattan_distance_same_level() {
let pos1 = TilePosition::new(0, 0, 0);
let pos2 = TilePosition::new(3, 4, 0);
assert_eq!(pos1.manhattan_distance(&pos2), Some(7));
assert_eq!(pos2.manhattan_distance(&pos1), Some(7));
}
#[test]
fn manhattan_distance_different_level_returns_none() {
let pos1 = TilePosition::new(0, 0, 0);
let pos2 = TilePosition::new(0, 0, 1);
assert_eq!(pos1.manhattan_distance(&pos2), None);
}
#[test]
fn cardinal_neighbors_correct() {
let pos = TilePosition::new(5, 5, 2);
let neighbors = pos.cardinal_neighbors();
assert_eq!(neighbors[0], TilePosition::new(5, 4, 2)); // North (y-1)
assert_eq!(neighbors[1], TilePosition::new(5, 6, 2)); // South (y+1)
assert_eq!(neighbors[2], TilePosition::new(6, 5, 2)); // East (x+1)
assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1)
}
#[test]
fn render_coord_conversion_roundtrip() {
let pos = TilePosition::new(5, 10, 0);
let (rx, ry, rz) = pos.to_render_coords();
assert_eq!(rx, 5.5);
assert_eq!(ry, 10.5);
assert_eq!(rz, 0);
let back = TilePosition::from_render_coords(rx, ry, rz);
assert_eq!(back, pos);
}
#[test]
fn chunk_coord_calculation() {
// Tile (0,0) → chunk (0,0)
assert_eq!(
TilePosition::new(0, 0, 0).chunk_coord(),
ChunkCoord { cx: 0, cy: 0, z: 0 }
);
// Tile (31,31) → chunk (0,0)
assert_eq!(
TilePosition::new(31, 31, 0).chunk_coord(),
ChunkCoord { cx: 0, cy: 0, z: 0 }
);
// Tile (32,0) → chunk (1,0)
assert_eq!(
TilePosition::new(32, 0, 0).chunk_coord(),
ChunkCoord { cx: 1, cy: 0, z: 0 }
);
// Negative tile (-1,0) → chunk (-1,0)
assert_eq!(
TilePosition::new(-1, 0, 0).chunk_coord(),
ChunkCoord {
cx: -1,
cy: 0,
z: 0
}
);
}
#[test]
fn walkability_map_default_all_walkable() {
let map = WalkabilityMap::new(10, 10, 1);
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
assert!(map.can_move_to(&TilePosition::new(9, 9, 0)));
}
#[test]
fn walkability_map_unloaded_chunk_not_walkable() {
let map = WalkabilityMap::new(10, 10, 1);
// Negative coords → unloaded chunk → not walkable
assert!(!map.can_move_to(&TilePosition::new(-1, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(0, -1, 0)));
// z=1 not loaded
assert!(!map.can_move_to(&TilePosition::new(0, 0, 1)));
}
#[test]
fn walkability_map_set_blocked() {
let mut map = WalkabilityMap::new(10, 10, 1);
let blocked_pos = TilePosition::new(5, 5, 0);
map.set_walkable(&blocked_pos, false);
assert!(!map.can_move_to(&blocked_pos));
assert!(map.can_move_to(&TilePosition::new(5, 6, 0))); // Adjacent still walkable
}
#[test]
fn walkability_map_multi_z_level() {
let mut map = WalkabilityMap::new(10, 10, 3);
let blocked_pos = TilePosition::new(5, 5, 1);
map.set_walkable(&blocked_pos, false);
assert!(!map.can_move_to(&blocked_pos));
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
assert!(map.can_move_to(&TilePosition::new(5, 5, 2)));
}
#[test]
fn chunk_load_unload() {
let mut map = WalkabilityMap::new(10, 10, 1);
let coord = ChunkCoord { cx: 0, cy: 0, z: 0 };
assert!(map.has_chunk(&coord));
map.unload_chunk(&coord);
assert!(!map.has_chunk(&coord));
assert!(!map.can_move_to(&TilePosition::new(0, 0, 0)));
map.load_chunk(coord);
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
}
#[test]
fn validate_movement_allows_walkable() {
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),
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);
assert_eq!(
*world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 4, 0)
);
assert!(world.get::<MoveIntent>(entity).is_none());
}
#[test]
fn validate_movement_blocks_unwalkable() {
let mut world = bevy_ecs::world::World::new();
let mut map = WalkabilityMap::new(10, 10, 1);
map.set_walkable(&TilePosition::new(5, 4, 0), false);
world.insert_resource(map);
let entity = 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);
assert_eq!(
*world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 5, 0)
);
assert!(world.get::<MoveIntent>(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::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0)
);
assert!(world.get::<MoveIntent>(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::<TilePosition>(entity_a).unwrap();
let pos_b = *world.get::<TilePosition>(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();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let entity = world
.spawn((
TilePosition::new(0, 0, 0),
MoveIntent {
target: TilePosition::new(-1, 0, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(0, 0, 0)
);
assert!(world.get::<MoveIntent>(entity).is_none());
}
// -----------------------------------------------------------------------
// TilePresence / same-tile occupancy tests (D-054, #420)
// -----------------------------------------------------------------------
#[test]
fn tile_presence_default_is_standing() {
assert_eq!(TilePresence::default(), TilePresence::Standing);
}
#[test]
fn same_layer_same_tile_blocks_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity at target, Standing layer
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover also Standing — should be blocked
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"same-layer collision should block movement"
);
}
#[test]
fn different_layer_same_tile_allows_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture at target tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Standing mover — different layer, should pass
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"different layers should share a tile"
);
}
#[test]
fn seated_and_fixture_share_tile() {
// Common case: NPC seated at a terminal (Fixture)
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Terminal fixture at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated NPC moves to same tile
let npc = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Seated,
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);
assert_eq!(
*world.get::<TilePosition>(npc).unwrap(),
TilePosition::new(5, 4, 0),
"Seated NPC should share tile with Fixture"
);
}
#[test]
fn prone_and_standing_share_tile() {
// Eavesdrop scenario: prone entity next to standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Standing NPC at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Prone entity moves in — different layer
let prone = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Prone,
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);
assert_eq!(
*world.get::<TilePosition>(prone).unwrap(),
TilePosition::new(5, 4, 0),
"Prone should share tile with Standing"
);
}
#[test]
fn entity_without_tile_presence_defaults_to_standing() {
// Backwards compat: entities spawned without TilePresence should
// still collide with Standing entities (default layer).
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity WITHOUT TilePresence component
world.spawn(TilePosition::new(5, 4, 0));
// Mover also WITHOUT TilePresence — both default to Standing
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"entities without TilePresence should default to Standing and collide"
);
}
#[test]
fn entity_without_presence_blocked_by_standing() {
// Entity without TilePresence blocked by explicit Standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary with explicit Standing
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover without TilePresence (defaults to Standing)
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"no-presence entity should collide with Standing"
);
}
#[test]
fn three_layers_on_same_tile() {
// Maximum plausible scenario: Standing + Seated + Fixture on one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Seated));
// Standing mover enters — third layer
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"three different layers should coexist on one tile"
);
}
#[test]
fn two_fixtures_same_tile_blocked() {
// Edge case: two fixtures can't stack on the same tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Fixture,
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);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"two Fixtures on same tile should collide"
);
}
// -----------------------------------------------------------------------
// Determinism regression test (#458 — Fix D)
// -----------------------------------------------------------------------
#[test]
fn same_tile_movers_resolve_by_entity_bits() {
// Fix D (#458): movers sorted by Entity::to_bits() before collision
// resolution. The entity with the lower bits value processes first
// and wins the tile. This prevents non-deterministic outcomes from
// ECS iteration order.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 5, 0);
let origin_a = TilePosition::new(5, 4, 0);
let origin_b = TilePosition::new(5, 6, 0);
let entity_a = world
.spawn((TilePosition::new(5, 4, 0), MoveIntent { target }))
.id();
let entity_b = world
.spawn((TilePosition::new(5, 6, 0), MoveIntent { target }))
.id();
// Determine which entity has lower bits (not guaranteed by spawn order)
let (lower, higher, _lower_origin, higher_origin) =
if entity_a.to_bits() < entity_b.to_bits() {
(entity_a, entity_b, origin_a, origin_b)
} else {
(entity_b, entity_a, origin_b, origin_a)
};
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let pos_lower = *world.get::<TilePosition>(lower).unwrap();
let pos_higher = *world.get::<TilePosition>(higher).unwrap();
// Entity with lower bits processes first and claims the target
assert_eq!(
pos_lower,
target,
"entity with lower Entity::to_bits() ({}) should win the tile",
lower.to_bits()
);
assert_eq!(
pos_higher,
higher_origin,
"entity with higher Entity::to_bits() ({}) should stay at origin",
higher.to_bits()
);
}
#[test]
fn all_four_layers_coexist_on_same_tile() {
// D-054: Standing + Prone + Seated + Fixture all share one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
// Fixture and Prone already at tile
world.spawn((target, TilePresence::Fixture));
world.spawn((target, TilePresence::Prone));
// Standing mover enters
let standing = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent { target },
))
.id();
// Seated mover enters from elsewhere
let seated = world
.spawn((
TilePosition::new(5, 3, 0),
TilePresence::Seated,
MoveIntent { target },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(standing).unwrap(),
target,
"Standing should share tile with Fixture + Prone"
);
assert_eq!(
*world.get::<TilePosition>(seated).unwrap(),
target,
"Seated should share tile with Fixture + Prone + Standing"
);
}
// --- Footstep sound emission tests (#124, D-018) ---
#[test]
fn successful_move_emits_footstep_sound() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
let entity = world
.spawn((TilePosition::new(5, 5, 0), MoveIntent { target }))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let emitter = world
.get::<SoundEventEmitter>(entity)
.expect("successful move should insert SoundEventEmitter");
assert_eq!(emitter.pending.len(), 1);
assert_eq!(emitter.pending[0].kind, SoundEventKind::Footstep);
assert_eq!(emitter.pending[0].range, SoundRange::Close);
// Default stance (None) → Walk intensity 0.5
assert!((emitter.pending[0].intensity - 0.5).abs() < f32::EPSILON);
}
#[test]
fn blocked_move_does_not_emit_footstep() {
let mut world = bevy_ecs::world::World::new();
let mut map = WalkabilityMap::new(10, 10, 1);
map.set_walkable(&TilePosition::new(5, 4, 0), false);
world.insert_resource(map);
let entity = 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);
assert!(
world.get::<SoundEventEmitter>(entity).is_none(),
"blocked move should not emit sound"
);
}
#[test]
fn sprint_stance_produces_louder_footstep() {
use crate::simulation::stance::Stance;
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent { target },
Stance(MovementStance::Sprint),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let emitter = world.get::<SoundEventEmitter>(entity).unwrap();
assert!((emitter.pending[0].intensity - 0.8).abs() < f32::EPSILON);
}
#[test]
fn crouch_stance_produces_quieter_footstep() {
use crate::simulation::stance::Stance;
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent { target },
Stance(MovementStance::Crouch),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let emitter = world.get::<SoundEventEmitter>(entity).unwrap();
assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON);
}
}