// 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 { 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, } 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, } 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 { 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>, mut movers: Query<( Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>, Option<&Stance>, )>, stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { let Some(map) = walkability else { tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); for (entity, _, _, _, _) in movers.iter() { commands.entity(entity).remove::(); } 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 = 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::(); } } #[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::(entity).unwrap(), TilePosition::new(5, 4, 0) ); assert!(world.get::(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::(entity).unwrap(), TilePosition::new(5, 5, 0) ); 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(); 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::(entity).unwrap(), TilePosition::new(0, 0, 0) ); assert!(world.get::(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::(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::(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::(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::(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::(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::(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::(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::(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::(lower).unwrap(); let pos_higher = *world.get::(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::(standing).unwrap(), target, "Standing should share tile with Fixture + Prone" ); assert_eq!( *world.get::(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::(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::(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::(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::(entity).unwrap(); assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON); } }