diff --git a/CHANGELOG.md b/CHANGELOG.md index ede1618e1..d31ea8f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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) diff --git a/server/src/bridge/framing.rs b/server/src/bridge/framing.rs new file mode 100644 index 000000000..e9bbf51b7 --- /dev/null +++ b/server/src/bridge/framing.rs @@ -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>> { + // 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); + } +} diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs new file mode 100644 index 000000000..6669ea773 --- /dev/null +++ b/server/src/bridge/local.rs @@ -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>, + writer: Mutex>, + 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 { + // 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 { + 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, BridgeError> { + let mut reader = self.reader.lock().expect("reader mutex poisoned"); + + match read_framed(reader.get_mut())? { + Some(payload) => { + let inputs: Vec = 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); + } + } +} diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 50619a4b1..05a398abe 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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, BridgeError>; } +/// BridgeResource: Bevy Resource wrapper for SimBridge trait object +#[derive(Resource)] +pub struct BridgeResource { + inner: Box, +} + +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, BridgeError> { + self.inner.receive_inputs() + } +} + /// Bridge plugin for client-server communication /// Abstracts transport layer (LocalBridge/NetworkBridge) pub struct BridgePlugin; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index f7d3811c6..685262263 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -47,13 +47,17 @@ pub struct PlayerInput { pub action: PlayerAction, } -/// Player action variants +/// Player action variants — 8-directional movement for immersive sim genre expectations #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PlayerAction { MoveNorth, MoveSouth, MoveEast, MoveWest, + MoveNortheast, + MoveNorthwest, + MoveSoutheast, + MoveSouthwest, Interact, UsePerceptionMode(String), Pause, diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b0cd793f4..054a04e1d 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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::() .insert_resource(rng::SimRng::new(0)) .init_resource::() - .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"); } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs new file mode 100644 index 000000000..afdbf0fd4 --- /dev/null +++ b/server/src/simulation/movement.rs @@ -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 { + 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, // 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, +} + +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>, + mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>, + stationary: Query<(Entity, &TilePosition), Without>, +) { + let Some(map) = walkability else { + tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); + for (entity, _, _) in movers.iter() { + commands.entity(entity).remove::(); + } + return; + }; + + // Collect tiles occupied by stationary entities (no MoveIntent) + let mut occupied: HashMap = HashMap::new(); + for (entity, pos) in stationary.iter() { + occupied.insert(*pos, entity); + } + + for (entity, intent, mut position) in movers.iter_mut() { + let target = &intent.target; + if !map.can_move_to(target) { + tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target); + } else if occupied.contains_key(target) { + tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target); + } else { + tracing::trace!( + "Entity {:?} moving from {:?} to {:?}", + entity, + *position, + target + ); + // Free old tile, claim new tile + occupied.remove(&*position); + *position = *target; + occupied.insert(*target, entity); + } + commands.entity(entity).remove::(); + } +} + +#[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::(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()); + } +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs new file mode 100644 index 000000000..448144f57 --- /dev/null +++ b/server/tests/bridge_ipc.rs @@ -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"), + } +} diff --git a/server/tests/movement.rs b/server/tests/movement.rs new file mode 100644 index 000000000..27c11fe57 --- /dev/null +++ b/server/tests/movement.rs @@ -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::(mover).unwrap(), + TilePosition::new(5, 4, 0) + ); + // Blocked stayed + assert_eq!( + *app.world().get::(blocked).unwrap(), + TilePosition::new(3, 4, 0) + ); + // Tick advanced + assert_eq!(app.world().resource::().tick, 1); + // Both intents consumed + assert!(app.world().get::(mover).is_none()); + assert!(app.world().get::(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::(mover).unwrap(), + TilePosition::new(5, 5, 0) + ); +} diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 568aa6618..abe61f56a 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -58,6 +58,10 @@ fn all_player_action_variants_roundtrip() { PlayerAction::MoveSouth, PlayerAction::MoveEast, PlayerAction::MoveWest, + PlayerAction::MoveNortheast, + PlayerAction::MoveNorthwest, + PlayerAction::MoveSoutheast, + PlayerAction::MoveSouthwest, PlayerAction::Interact, PlayerAction::UsePerceptionMode("thermal".to_string()), PlayerAction::Pause,