Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -7,6 +7,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- LocalBridge IPC over Unix domain sockets (#78) — length-prefixed MessagePack framing, SimBridge implementation, BridgeResource ECS wrapper
|
||||
- Tile collision system (#236) — TilePosition component, chunk-based WalkabilityMap (D-012), MoveIntent + validate_movement system
|
||||
- TilePosition ↔ f32 render coordinate conversion (to_render_coords, from_render_coords) bridging simulation and wire format
|
||||
- Chunk load/unload support in WalkabilityMap — HashMap<ChunkCoord, ChunkData> with 32x32 tile chunks
|
||||
- 8-directional movement — diagonal PlayerAction variants (MoveNortheast/Northwest/Southeast/Southwest) + TilePosition::all_neighbors()
|
||||
- Entity-entity collision in validate_movement — spatial occupancy check prevents multiple entities on same tile
|
||||
- 25 new tests (4 framing + 2 IPC integration + 17 movement unit + 2 movement integration), total 45
|
||||
- `db/connectors/ticket` CLI — ergonomic ticket management (list, show, create, assign, sprint, deps, search, epics, children, count) with JSON output
|
||||
- Sprint 1 "Run" created with 8 stories targeting moving character on screen via IPC bridge
|
||||
- Ticket skill rewritten to use ticket CLI instead of raw SQL wrapper scripts
|
||||
@@ -75,6 +82,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
|
||||
|
||||
### Fixed
|
||||
- WalkabilityMap rewritten from flat Vec to chunk-based HashMap per D-012 architecture (Tyre review)
|
||||
- LocalBridge mutex .unwrap() → .expect() for clearer panic messages (Hoshe review)
|
||||
- Documented 16MB MAX_MESSAGE_SIZE rationale in framing.rs (Hoshe review)
|
||||
- Client input_mapper double-check bug (redundant event.pressed + is_action_pressed)
|
||||
- Bounds validation on snapshot position arrays in game_state.gd and entity_renderer.gd
|
||||
- Deterministic test snapshots (replaced Time.get_ticks_msec() with incrementing counter)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// MessagePack framing protocol
|
||||
// 4-byte big-endian length prefix + payload
|
||||
// Implements D-020 IPC transport layer
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Maximum message size: 16 MB.
|
||||
/// Sized for ObserverSnapshot with ~1000 entities (each ~40 bytes serialized),
|
||||
/// plus generous headroom for future field additions. A full-map dump of 10k
|
||||
/// entities would be ~400 KB, well within this limit.
|
||||
const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024;
|
||||
|
||||
/// Write a length-prefixed message to a writer.
|
||||
/// Format: [4-byte BE length][payload]
|
||||
pub fn write_framed(writer: &mut impl Write, payload: &[u8]) -> io::Result<()> {
|
||||
let len = payload.len() as u32;
|
||||
if len > MAX_MESSAGE_SIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"message too large: {} bytes (max {})",
|
||||
len, MAX_MESSAGE_SIZE
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
writer.write_all(&len.to_be_bytes())?;
|
||||
writer.write_all(payload)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a length-prefixed message from a reader.
|
||||
/// Returns Ok(None) on clean EOF (connection closed).
|
||||
/// Returns error on incomplete/corrupted reads.
|
||||
pub fn read_framed(reader: &mut impl Read) -> io::Result<Option<Vec<u8>>> {
|
||||
// Read 4-byte length prefix
|
||||
let mut len_bytes = [0u8; 4];
|
||||
match reader.read_exact(&mut len_bytes) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
let len = u32::from_be_bytes(len_bytes);
|
||||
|
||||
if len > MAX_MESSAGE_SIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"message too large: {} bytes (max {})",
|
||||
len, MAX_MESSAGE_SIZE
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Read payload
|
||||
let mut payload = vec![0u8; len as usize];
|
||||
reader.read_exact(&mut payload)?;
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn write_then_read_roundtrip() {
|
||||
let payload = b"hello, world!";
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
write_framed(&mut buffer, payload).expect("write failed");
|
||||
|
||||
let mut cursor = Cursor::new(buffer);
|
||||
let result = read_framed(&mut cursor).expect("read failed");
|
||||
|
||||
assert_eq!(result.unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_roundtrip() {
|
||||
let payload = b"";
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
write_framed(&mut buffer, payload).expect("write failed");
|
||||
|
||||
let mut cursor = Cursor::new(buffer);
|
||||
let result = read_framed(&mut cursor).expect("read failed");
|
||||
|
||||
assert_eq!(result.unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_message() {
|
||||
let mut buffer = Vec::new();
|
||||
let oversized_payload = vec![0u8; (MAX_MESSAGE_SIZE + 1) as usize];
|
||||
|
||||
let result = write_framed(&mut buffer, &oversized_payload);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_returns_none() {
|
||||
let buffer = Vec::new();
|
||||
let mut cursor = Cursor::new(buffer);
|
||||
|
||||
let result = read_framed(&mut cursor).expect("read failed");
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// LocalBridge - Unix socket IPC implementation
|
||||
// Implements D-020 subprocess/IPC architecture
|
||||
// Deterministic client-server communication via Unix domain sockets
|
||||
|
||||
use super::{BridgeError, ObserverSnapshot, PlayerInput, SimBridge};
|
||||
use crate::bridge::framing::{read_framed, write_framed};
|
||||
use std::fs;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// LocalBridge: Unix socket transport for client-server IPC
|
||||
pub struct LocalBridge {
|
||||
reader: Mutex<BufReader<UnixStream>>,
|
||||
writer: Mutex<BufWriter<UnixStream>>,
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalBridge {
|
||||
/// Server-side: create a Unix socket listener and accept one connection.
|
||||
/// Removes any stale socket file before binding.
|
||||
pub fn accept(path: &Path) -> Result<Self, BridgeError> {
|
||||
// Remove stale socket if it exists
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to remove stale socket: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
tracing::info!("LocalBridge listening on {:?}", path);
|
||||
|
||||
let listener = UnixListener::bind(path)
|
||||
.map_err(|e| BridgeError::Transport(format!("failed to bind Unix socket: {}", e)))?;
|
||||
|
||||
// Accept one connection
|
||||
let (stream, _addr) = listener
|
||||
.accept()
|
||||
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
||||
|
||||
tracing::info!("LocalBridge accepted connection on {:?}", path);
|
||||
|
||||
// Clone stream for reader and writer
|
||||
let reader_stream = stream.try_clone().map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
reader: Mutex::new(BufReader::new(reader_stream)),
|
||||
writer: Mutex::new(BufWriter::new(stream)),
|
||||
socket_path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Client-side: connect to an existing Unix socket.
|
||||
pub fn connect(path: &Path) -> Result<Self, BridgeError> {
|
||||
tracing::info!("LocalBridge connecting to {:?}", path);
|
||||
|
||||
let stream = UnixStream::connect(path).map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to connect to Unix socket: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::trace!("LocalBridge connected to {:?}", path);
|
||||
|
||||
// Clone stream for reader and writer
|
||||
let reader_stream = stream.try_clone().map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
reader: Mutex::new(BufReader::new(reader_stream)),
|
||||
writer: Mutex::new(BufWriter::new(stream)),
|
||||
socket_path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
}
|
||||
|
||||
impl SimBridge for LocalBridge {
|
||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||
let payload = rmp_serde::to_vec(snapshot)?;
|
||||
|
||||
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
||||
write_framed(writer.get_mut(), &payload)?;
|
||||
|
||||
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
|
||||
let mut reader = self.reader.lock().expect("reader mutex poisoned");
|
||||
|
||||
match read_framed(reader.get_mut())? {
|
||||
Some(payload) => {
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
|
||||
tracing::trace!("received {} inputs", inputs.len());
|
||||
Ok(inputs)
|
||||
}
|
||||
None => {
|
||||
tracing::trace!("received EOF, returning empty input vec");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalBridge {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort socket cleanup
|
||||
if self.socket_path.exists() {
|
||||
let _ = fs::remove_file(&self.socket_path);
|
||||
tracing::trace!("removed socket file {:?}", self.socket_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
// MessagePack serialization for Rust<->Godot communication
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
pub mod framing;
|
||||
pub mod local;
|
||||
pub mod types;
|
||||
pub use types::*;
|
||||
|
||||
@@ -14,6 +17,8 @@ pub enum BridgeError {
|
||||
Serialization(#[from] rmp_serde::encode::Error),
|
||||
#[error("deserialization error: {0}")]
|
||||
Deserialization(#[from] rmp_serde::decode::Error),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("transport error: {0}")]
|
||||
Transport(String),
|
||||
}
|
||||
@@ -28,6 +33,28 @@ pub trait SimBridge: Send + Sync {
|
||||
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError>;
|
||||
}
|
||||
|
||||
/// BridgeResource: Bevy Resource wrapper for SimBridge trait object
|
||||
#[derive(Resource)]
|
||||
pub struct BridgeResource {
|
||||
inner: Box<dyn SimBridge>,
|
||||
}
|
||||
|
||||
impl BridgeResource {
|
||||
pub fn new(bridge: impl SimBridge + 'static) -> Self {
|
||||
Self {
|
||||
inner: Box::new(bridge),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||
self.inner.send_snapshot(snapshot)
|
||||
}
|
||||
|
||||
pub fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
|
||||
self.inner.receive_inputs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge plugin for client-server communication
|
||||
/// Abstracts transport layer (LocalBridge/NetworkBridge)
|
||||
pub struct BridgePlugin;
|
||||
|
||||
@@ -47,13 +47,17 @@ pub struct PlayerInput {
|
||||
pub action: PlayerAction,
|
||||
}
|
||||
|
||||
/// Player action variants
|
||||
/// Player action variants — 8-directional movement for immersive sim genre expectations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PlayerAction {
|
||||
MoveNorth,
|
||||
MoveSouth,
|
||||
MoveEast,
|
||||
MoveWest,
|
||||
MoveNortheast,
|
||||
MoveNorthwest,
|
||||
MoveSoutheast,
|
||||
MoveSouthwest,
|
||||
Interact,
|
||||
UsePerceptionMode(String),
|
||||
Pause,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// Implements deterministic tick-based simulation (D-010 principle 4)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod movement;
|
||||
pub mod rng;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
@@ -18,7 +20,11 @@ impl Plugin for SimulationPlugin {
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.add_systems(Update, time::advance_tick);
|
||||
.add_systems(Update, time::advance_tick)
|
||||
.add_systems(
|
||||
Update,
|
||||
movement::validate_movement.after(time::advance_tick),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
// Tile-based movement and collision system
|
||||
// Implements Sprint 1 ticket #236: walkability map and movement validation
|
||||
// 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::HashMap;
|
||||
|
||||
/// Chunk size in tiles (32x32 per chunk)
|
||||
pub const CHUNK_SIZE: i32 = 32;
|
||||
|
||||
/// 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, 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.
|
||||
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)]
|
||||
pub struct ChunkCoord {
|
||||
pub cx: i32,
|
||||
pub cy: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChunkData {
|
||||
tiles: Vec<bool>, // CHUNK_SIZE * CHUNK_SIZE, true = walkable
|
||||
}
|
||||
|
||||
impl ChunkData {
|
||||
fn new_walkable() -> Self {
|
||||
Self {
|
||||
tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
fn new_blocked() -> Self {
|
||||
Self {
|
||||
tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
fn index(lx: i32, ly: i32) -> usize {
|
||||
(ly * CHUNK_SIZE + lx) as usize
|
||||
}
|
||||
|
||||
fn get(&self, lx: i32, ly: i32) -> bool {
|
||||
self.tiles[Self::index(lx, ly)]
|
||||
}
|
||||
|
||||
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
|
||||
self.tiles[Self::index(lx, ly)] = walkable;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: HashMap<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 = HashMap::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 = HashMap::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.
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 entity-entity collision before allowing moves.
|
||||
/// Processes all intents in a single pass: first collect occupied tiles from
|
||||
/// entities without intents, then resolve movers in order — first valid claim
|
||||
/// to a tile wins.
|
||||
/// Always removes MoveIntent component after processing.
|
||||
pub fn validate_movement(
|
||||
mut commands: Commands,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
|
||||
stationary: Query<(Entity, &TilePosition), 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 tiles occupied by stationary entities (no MoveIntent)
|
||||
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
|
||||
for (entity, pos) in stationary.iter() {
|
||||
occupied.insert(*pos, entity);
|
||||
}
|
||||
|
||||
for (entity, intent, mut position) in movers.iter_mut() {
|
||||
let target = &intent.target;
|
||||
if !map.can_move_to(target) {
|
||||
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
|
||||
} else if occupied.contains_key(target) {
|
||||
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
|
||||
} else {
|
||||
tracing::trace!(
|
||||
"Entity {:?} moving from {:?} to {:?}",
|
||||
entity,
|
||||
*position,
|
||||
target
|
||||
);
|
||||
// Free old tile, claim new tile
|
||||
occupied.remove(&*position);
|
||||
*position = *target;
|
||||
occupied.insert(*target, entity);
|
||||
}
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Integration tests for LocalBridge over Unix sockets (D-030 Layer 2: IPC roundtrip).
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::local::LocalBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::SimBridge;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Generate unique socket path for test isolation
|
||||
fn test_socket_path(test_name: &str) -> PathBuf {
|
||||
let pid = std::process::id();
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
PathBuf::from(format!(
|
||||
"/tmp/sr-test-{}-{}-{}.sock",
|
||||
test_name, pid, timestamp
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_roundtrip_over_unix_socket() {
|
||||
let socket_path = test_socket_path("snapshot");
|
||||
|
||||
// Server thread: accept connection and send snapshot
|
||||
let server_path = socket_path.clone();
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 42,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
y: 20.3,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
}],
|
||||
};
|
||||
|
||||
bridge
|
||||
.send_snapshot(&snapshot)
|
||||
.expect("failed to send snapshot");
|
||||
});
|
||||
|
||||
// Give server time to bind
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Client: connect and receive snapshot
|
||||
let stream = UnixStream::connect(&socket_path).expect("failed to connect");
|
||||
let mut reader = std::io::BufReader::new(stream);
|
||||
|
||||
let payload = read_framed(&mut reader)
|
||||
.expect("failed to read frame")
|
||||
.expect("unexpected EOF");
|
||||
|
||||
let snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&payload).expect("failed to deserialize");
|
||||
|
||||
assert_eq!(snapshot.tick, 42);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert_eq!(snapshot.entities[0].entity_id, 100);
|
||||
assert_eq!(snapshot.entities[0].x, 10.5);
|
||||
assert_eq!(snapshot.entities[0].y, 20.3);
|
||||
|
||||
server_handle.join().expect("server thread panicked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_roundtrip_over_unix_socket() {
|
||||
let socket_path = test_socket_path("input");
|
||||
|
||||
// Server thread: accept connection and receive inputs
|
||||
let server_path = socket_path.clone();
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||
|
||||
let inputs = bridge.receive_inputs().expect("failed to receive inputs");
|
||||
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert_eq!(inputs[0].tick, 10);
|
||||
assert_eq!(inputs[1].tick, 11);
|
||||
|
||||
inputs
|
||||
});
|
||||
|
||||
// Give server time to bind
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Client: connect and send inputs
|
||||
let stream = UnixStream::connect(&socket_path).expect("failed to connect");
|
||||
let mut writer = std::io::BufWriter::new(stream);
|
||||
|
||||
let inputs = vec![
|
||||
PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::MoveNorth,
|
||||
},
|
||||
PlayerInput {
|
||||
tick: 11,
|
||||
action: PlayerAction::Interact,
|
||||
},
|
||||
];
|
||||
|
||||
let payload = rmp_serde::to_vec(&inputs).expect("failed to serialize");
|
||||
write_framed(&mut writer, &payload).expect("failed to write frame");
|
||||
|
||||
// Drop writer to close connection and signal EOF to server
|
||||
drop(writer);
|
||||
|
||||
let received_inputs = server_handle.join().expect("server thread panicked");
|
||||
|
||||
// Verify actions survived the round-trip
|
||||
match &received_inputs[0].action {
|
||||
PlayerAction::MoveNorth => {}
|
||||
_ => panic!("expected MoveNorth action"),
|
||||
}
|
||||
match &received_inputs[1].action {
|
||||
PlayerAction::Interact => {}
|
||||
_ => panic!("expected Interact action"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use bevy_app::prelude::*;
|
||||
use settled_reach_server::simulation::movement::*;
|
||||
use settled_reach_server::simulation::time::SimulationTime;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
#[test]
|
||||
fn movement_validated_within_app() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
|
||||
// Insert a walkability map with one blocked tile
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(3, 3, 0), false);
|
||||
app.insert_resource(map);
|
||||
|
||||
// Spawn mover (walkable target) and blocked entity
|
||||
let mover = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let blocked = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
TilePosition::new(3, 4, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(3, 3, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
// Mover moved
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 4, 0)
|
||||
);
|
||||
// Blocked stayed
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(blocked).unwrap(),
|
||||
TilePosition::new(3, 4, 0)
|
||||
);
|
||||
// Tick advanced
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
|
||||
// Both intents consumed
|
||||
assert!(app.world().get::<MoveIntent>(mover).is_none());
|
||||
assert!(app.world().get::<MoveIntent>(blocked).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_collision_blocks_movement() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Stationary entity at (5,4)
|
||||
app.world_mut().spawn(TilePosition::new(5, 4, 0));
|
||||
|
||||
// Mover tries to move into occupied tile
|
||||
let mover = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 5, 0)
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,10 @@ fn all_player_action_variants_roundtrip() {
|
||||
PlayerAction::MoveSouth,
|
||||
PlayerAction::MoveEast,
|
||||
PlayerAction::MoveWest,
|
||||
PlayerAction::MoveNortheast,
|
||||
PlayerAction::MoveNorthwest,
|
||||
PlayerAction::MoveSoutheast,
|
||||
PlayerAction::MoveSouthwest,
|
||||
PlayerAction::Interact,
|
||||
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
||||
PlayerAction::Pause,
|
||||
|
||||
Reference in New Issue
Block a user