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>
1287 lines
43 KiB
Rust
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);
|
|
}
|
|
}
|