From 4ed15c1a38735e9443e5bbb96de727890756c748 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:07:47 +0100 Subject: [PATCH 1/9] feat(simulation): add LocalBridge IPC over Unix socket (#78) Length-prefixed MessagePack framing (4-byte BE length + payload), LocalBridge struct implementing SimBridge trait over Unix domain sockets, BridgeResource wrapper for ECS integration. Adds Io error variant to BridgeError. Two integration tests verify snapshot and input round-trips over real sockets. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/framing.rs | 109 ++++++++++++++++++++++++++++++ server/src/bridge/local.rs | 118 ++++++++++++++++++++++++++++++++ server/src/bridge/mod.rs | 27 ++++++++ server/tests/bridge_ipc.rs | 126 +++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 server/src/bridge/framing.rs create mode 100644 server/src/bridge/local.rs create mode 100644 server/tests/bridge_ipc.rs diff --git a/server/src/bridge/framing.rs b/server/src/bridge/framing.rs new file mode 100644 index 000000000..1ec3f928e --- /dev/null +++ b/server/src/bridge/framing.rs @@ -0,0 +1,109 @@ +// 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 +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..f558a135c --- /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().unwrap(); + 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().unwrap(); + + 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/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"), + } +} From 862ab9099ffcb1e06b4d5d40bb2987b6b21d1d04 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:07:53 +0100 Subject: [PATCH 2/9] feat(simulation): add tile collision system (#236) TilePosition component with discrete grid coordinates, flat-storage WalkabilityMap resource with O(1) can_move_to() lookup, MoveIntent component and validate_movement system. Movement validated against walkability map each tick, blocking all NPC and player movement through unwalkable tiles. 11 unit tests + 1 integration test. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/mod.rs | 8 +- server/src/simulation/movement.rs | 346 ++++++++++++++++++++++++++++++ server/tests/movement.rs | 54 +++++ 3 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 server/src/simulation/movement.rs create mode 100644 server/tests/movement.rs 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..2a7a78bd2 --- /dev/null +++ b/server/src/simulation/movement.rs @@ -0,0 +1,346 @@ +// Tile-based movement and collision system +// Implements Sprint 1 ticket #236: walkability map and movement validation +// Y-down convention: North = y-1, South = y+1 + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Tile position component for grid-based movement +#[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 + ] + } +} + +/// Walkability map resource for tile collision +/// Flat storage with index = z*w*h + y*w + x +#[derive(Resource, Debug, Clone)] +pub struct WalkabilityMap { + width: i32, + height: i32, + z_levels: i32, + tiles: Vec, // true = walkable, false = blocked +} + +impl WalkabilityMap { + /// Create a new walkability map with all tiles walkable + pub fn new(width: i32, height: i32, z_levels: i32) -> Self { + let size = (width * height * z_levels) as usize; + Self { + width, + height, + z_levels, + tiles: vec![true; size], + } + } + + /// Create a new walkability map with all tiles blocked + pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self { + let size = (width * height * z_levels) as usize; + Self { + width, + height, + z_levels, + tiles: vec![false; size], + } + } + + pub fn width(&self) -> i32 { + self.width + } + + pub fn height(&self) -> i32 { + self.height + } + + pub fn z_levels(&self) -> i32 { + self.z_levels + } + + /// Check if a position is within map bounds + pub fn in_bounds(&self, pos: &TilePosition) -> bool { + pos.x >= 0 + && pos.x < self.width + && pos.y >= 0 + && pos.y < self.height + && pos.z >= 0 + && pos.z < self.z_levels + } + + /// Check if movement to a position is valid (in bounds and walkable) + pub fn can_move_to(&self, pos: &TilePosition) -> bool { + if !self.in_bounds(pos) { + return false; + } + let idx = self.index(pos); + self.tiles[idx] + } + + /// Set walkability of a tile + /// Panics if position is out of bounds + pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { + assert!( + self.in_bounds(pos), + "Position {:?} out of bounds ({}x{}x{})", + pos, + self.width, + self.height, + self.z_levels + ); + let idx = self.index(pos); + self.tiles[idx] = walkable; + } + + /// Calculate flat storage index for a position + fn index(&self, pos: &TilePosition) -> usize { + (pos.z * self.width * self.height + pos.y * self.width + pos.x) as usize + } +} + +/// 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 updates positions for valid moves +/// Always removes MoveIntent component after processing +pub fn validate_movement( + mut commands: Commands, + walkability: Option>, + mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>, +) { + // If no walkability map exists, reject all move intents + let Some(map) = walkability else { + for (entity, _, _) in query.iter() { + commands.entity(entity).remove::(); + } + return; + }; + + for (entity, intent, mut position) in query.iter_mut() { + if map.can_move_to(&intent.target) { + tracing::trace!( + "Entity {:?} moving from {:?} to {:?}", + entity, + *position, + intent.target + ); + *position = intent.target; + } else { + tracing::trace!( + "Entity {:?} blocked at {:?}, cannot move to {:?}", + entity, + *position, + intent.target + ); + } + 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 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_out_of_bounds_not_walkable() { + let map = WalkabilityMap::new(10, 10, 1); + + // Negative coordinates + assert!(!map.can_move_to(&TilePosition::new(-1, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, -1, 0))); + + // Over bounds + assert!(!map.can_move_to(&TilePosition::new(10, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, 10, 0))); + 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); + + // z=1 is blocked + assert!(!map.can_move_to(&blocked_pos)); + + // z=0 and z=2 at same x,y are walkable + assert!(map.can_move_to(&TilePosition::new(5, 5, 0))); + assert!(map.can_move_to(&TilePosition::new(5, 5, 2))); + } + + #[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); + + // Position updated to target + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(5, 4, 0) + ); + + // Intent removed + 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); + + // Position unchanged + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(5, 5, 0) + ); + + // Intent removed + assert!(world.get::(entity).is_none()); + } + + #[test] + fn validate_movement_blocks_out_of_bounds() { + 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); + + // Position unchanged + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(0, 0, 0) + ); + + // Intent removed + assert!(world.get::(entity).is_none()); + } +} diff --git a/server/tests/movement.rs b/server/tests/movement.rs new file mode 100644 index 000000000..7b3600d4b --- /dev/null +++ b/server/tests/movement.rs @@ -0,0 +1,54 @@ +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()); +} From e44e90c2cb873c9a08d5dd3b6c421ca1f4ab2187 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:08:07 +0100 Subject: [PATCH 3/9] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede1618e1..00f5fcca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 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, WalkabilityMap resource with O(1) lookup, MoveIntent + validate_movement system +- 18 new tests (4 framing + 2 IPC integration + 11 movement unit + 1 movement integration), total 38 - `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 From 78e0c71c6da1c6574ac2ef9b4a3821dd4d51df05 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:32:29 +0100 Subject: [PATCH 4/9] refactor(simulation): chunk-based walkability map per D-012 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites WalkabilityMap from flat Vec to HashMap with 32x32 tile chunks. Supports chunk load/unload for future borderless generation. Unloaded chunks treated as unwalkable. Adds TilePosition ↔ f32 render coordinate conversion (to_render_coords, from_render_coords) bridging i32 simulation coords and f32 wire format. Addresses Tyre PR review: D-012 chunk architecture compatibility and VisibleEntity coordinate mismatch. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/movement.rs | 301 +++++++++++++++++++++--------- 1 file changed, 211 insertions(+), 90 deletions(-) diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 2a7a78bd2..f0f510abe 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -1,11 +1,18 @@ // 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; -/// Tile position component for grid-based movement +/// 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, @@ -18,8 +25,8 @@ impl TilePosition { Self { x, y, z } } - /// Calculate Manhattan distance to another position - /// Returns None if positions are on different z-levels + /// 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; @@ -27,8 +34,11 @@ impl TilePosition { 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 + /// 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. + /// + /// TODO: Diagonal movement (8-directional) for genre expectations. + /// TODO: Chunk boundary awareness for neighbors in different chunks. pub fn cardinal_neighbors(&self) -> [TilePosition; 4] { [ TilePosition::new(self.x, self.y - 1, self.z), // North @@ -37,109 +47,181 @@ impl TilePosition { TilePosition::new(self.x - 1, self.y, self.z), // West ] } + + /// 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)) + } } -/// Walkability map resource for tile collision -/// Flat storage with index = z*w*h + y*w + x +/// 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. +/// +/// TODO: Entity-entity collision (multiple entities on same tile). #[derive(Resource, Debug, Clone)] pub struct WalkabilityMap { - width: i32, - height: i32, - z_levels: i32, - tiles: Vec, // true = walkable, false = blocked + chunks: HashMap, } impl WalkabilityMap { - /// Create a new walkability map with all tiles walkable + /// 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 size = (width * height * z_levels) as usize; - Self { - width, - height, - z_levels, - tiles: vec![true; size], + 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 new walkability map with all tiles blocked + /// 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 size = (width * height * z_levels) as usize; - Self { - width, - height, - z_levels, - tiles: vec![false; size], + 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 } } - pub fn width(&self) -> i32 { - self.width - } - - pub fn height(&self) -> i32 { - self.height - } - - pub fn z_levels(&self) -> i32 { - self.z_levels - } - - /// Check if a position is within map bounds - pub fn in_bounds(&self, pos: &TilePosition) -> bool { - pos.x >= 0 - && pos.x < self.width - && pos.y >= 0 - && pos.y < self.height - && pos.z >= 0 - && pos.z < self.z_levels - } - - /// Check if movement to a position is valid (in bounds and walkable) + /// Check if a tile is walkable. Unloaded chunks are treated as unwalkable. pub fn can_move_to(&self, pos: &TilePosition) -> bool { - if !self.in_bounds(pos) { + 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; } - let idx = self.index(pos); - self.tiles[idx] + self.chunks.insert(coord, ChunkData::new_walkable()); + true } - /// Set walkability of a tile - /// Panics if position is out of bounds - pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { - assert!( - self.in_bounds(pos), - "Position {:?} out of bounds ({}x{}x{})", - pos, - self.width, - self.height, - self.z_levels - ); - let idx = self.index(pos); - self.tiles[idx] = walkable; + /// Unload a chunk. Returns false if not loaded. + pub fn unload_chunk(&mut self, coord: &ChunkCoord) -> bool { + self.chunks.remove(coord).is_some() } - /// Calculate flat storage index for a position - fn index(&self, pos: &TilePosition) -> usize { - (pos.z * self.width * self.height + pos.y * self.width + pos.x) as usize + /// Number of loaded chunks. + pub fn chunk_count(&self) -> usize { + self.chunks.len() } } -/// Component representing an intent to move to a target tile +/// 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 updates positions for valid moves -/// Always removes MoveIntent component after processing +/// System to validate and execute movement intents. +/// Checks walkability map and updates positions for valid moves. +/// Always removes MoveIntent component after processing. pub fn validate_movement( mut commands: Commands, walkability: Option>, mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>, ) { - // If no walkability map exists, reject all move intents let Some(map) = walkability else { + tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); for (entity, _, _) in query.iter() { commands.entity(entity).remove::(); } @@ -209,6 +291,45 @@ mod tests { 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); @@ -219,16 +340,14 @@ mod tests { } #[test] - fn walkability_map_out_of_bounds_not_walkable() { + fn walkability_map_unloaded_chunk_not_walkable() { let map = WalkabilityMap::new(10, 10, 1); - // Negative coordinates + // 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))); - // Over bounds - assert!(!map.can_move_to(&TilePosition::new(10, 0, 0))); - assert!(!map.can_move_to(&TilePosition::new(0, 10, 0))); + // z=1 not loaded assert!(!map.can_move_to(&TilePosition::new(0, 0, 1))); } @@ -250,14 +369,25 @@ mod tests { map.set_walkable(&blocked_pos, false); - // z=1 is blocked assert!(!map.can_move_to(&blocked_pos)); - - // z=0 and z=2 at same x,y are walkable 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(); @@ -276,13 +406,10 @@ mod tests { schedule.add_systems(validate_movement); schedule.run(&mut world); - // Position updated to target assert_eq!( *world.get::(entity).unwrap(), TilePosition::new(5, 4, 0) ); - - // Intent removed assert!(world.get::(entity).is_none()); } @@ -306,18 +433,15 @@ mod tests { schedule.add_systems(validate_movement); schedule.run(&mut world); - // Position unchanged assert_eq!( *world.get::(entity).unwrap(), TilePosition::new(5, 5, 0) ); - - // Intent removed assert!(world.get::(entity).is_none()); } #[test] - fn validate_movement_blocks_out_of_bounds() { + fn validate_movement_blocks_unloaded_chunk() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(WalkabilityMap::new(10, 10, 1)); @@ -334,13 +458,10 @@ mod tests { schedule.add_systems(validate_movement); schedule.run(&mut world); - // Position unchanged assert_eq!( *world.get::(entity).unwrap(), TilePosition::new(0, 0, 0) ); - - // Intent removed assert!(world.get::(entity).is_none()); } } From 9860fc0857c2004d0fefa85c10f7ad9e72b7a016 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:32:35 +0100 Subject: [PATCH 5/9] fix(simulation): address bridge review feedback Replace .lock().unwrap() with .expect("mutex poisoned") in LocalBridge for clearer panic messages. Document 16MB MAX_MESSAGE_SIZE rationale in framing.rs with entity count sizing analysis. Addresses Hoshe PR review suggestions. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/framing.rs | 5 ++++- server/src/bridge/local.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/bridge/framing.rs b/server/src/bridge/framing.rs index 1ec3f928e..e9bbf51b7 100644 --- a/server/src/bridge/framing.rs +++ b/server/src/bridge/framing.rs @@ -4,7 +4,10 @@ use std::io::{self, Read, Write}; -/// Maximum message size: 16 MB +/// 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. diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index f558a135c..6669ea773 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -83,7 +83,7 @@ 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().unwrap(); + let mut writer = self.writer.lock().expect("writer mutex poisoned"); write_framed(writer.get_mut(), &payload)?; tracing::trace!("sent snapshot: tick={}", snapshot.tick); @@ -91,7 +91,7 @@ impl SimBridge for LocalBridge { } fn receive_inputs(&self) -> Result, BridgeError> { - let mut reader = self.reader.lock().unwrap(); + let mut reader = self.reader.lock().expect("reader mutex poisoned"); match read_framed(reader.get_mut())? { Some(payload) => { From 0f5f73a927a34c73f563a80f50b7449a1ca84b33 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:32:56 +0100 Subject: [PATCH 6/9] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f5fcca7..9bbd157ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - LocalBridge IPC over Unix domain sockets (#78) — length-prefixed MessagePack framing, SimBridge implementation, BridgeResource ECS wrapper -- Tile collision system (#236) — TilePosition component, WalkabilityMap resource with O(1) lookup, MoveIntent + validate_movement system -- 18 new tests (4 framing + 2 IPC integration + 11 movement unit + 1 movement integration), total 38 +- 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 +- 21 new tests (4 framing + 2 IPC integration + 14 movement unit + 1 movement integration), total 41 - `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 @@ -78,6 +80,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) From bef4383196004fe51f20e7e8fc323ac9509de682 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 20:15:18 +0100 Subject: [PATCH 7/9] feat(simulation): add 8-directional movement Add diagonal PlayerAction variants (MoveNortheast, MoveNorthwest, MoveSoutheast, MoveSouthwest) and TilePosition::all_neighbors() returning all 8 surrounding tiles. Genre-expected for immersive sim. Establishes the movement pattern before pathfinding is built on top. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/types.rs | 6 +- server/src/simulation/movement.rs | 140 ++++++++++++++++++++++++++---- server/tests/serialization.rs | 4 + 3 files changed, 130 insertions(+), 20 deletions(-) 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/movement.rs b/server/src/simulation/movement.rs index f0f510abe..afdbf0fd4 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -36,9 +36,6 @@ impl TilePosition { /// 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. - /// - /// TODO: Diagonal movement (8-directional) for genre expectations. - /// TODO: Chunk boundary awareness for neighbors in different chunks. pub fn cardinal_neighbors(&self) -> [TilePosition; 4] { [ TilePosition::new(self.x, self.y - 1, self.z), // North @@ -48,6 +45,20 @@ impl TilePosition { ] } + /// 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) { @@ -122,8 +133,6 @@ impl ChunkData { /// 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. -/// -/// TODO: Entity-entity collision (multiple entities on same tile). #[derive(Resource, Debug, Clone)] pub struct WalkabilityMap { chunks: HashMap, @@ -213,37 +222,48 @@ pub struct MoveIntent { } /// System to validate and execute movement intents. -/// Checks walkability map and updates positions for valid moves. +/// 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 query: Query<(Entity, &MoveIntent, &mut TilePosition)>, + 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 query.iter() { + for (entity, _, _) in movers.iter() { commands.entity(entity).remove::(); } return; }; - for (entity, intent, mut position) in query.iter_mut() { - if map.can_move_to(&intent.target) { + // 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, - intent.target - ); - *position = intent.target; - } else { - tracing::trace!( - "Entity {:?} blocked at {:?}, cannot move to {:?}", - entity, - *position, - intent.target + target ); + // Free old tile, claim new tile + occupied.remove(&*position); + *position = *target; + occupied.insert(*target, entity); } commands.entity(entity).remove::(); } @@ -440,6 +460,88 @@ mod tests { 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(); 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, From f87bd2b0e5c228736cfc4a6211df75a0dba13bea Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 20:15:24 +0100 Subject: [PATCH 8/9] feat(simulation): add entity-entity collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_movement now checks both terrain walkability AND tile occupancy. Builds a spatial index of occupied tiles from stationary entities, then resolves movers in order — first valid claim wins. Same spatial pattern needed for D-026 simulation tiers (30-80 active NPCs) and future pathfinding occupied-tile awareness. Co-Authored-By: Claude Opus 4.6 --- server/tests/movement.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/server/tests/movement.rs b/server/tests/movement.rs index 7b3600d4b..27c11fe57 100644 --- a/server/tests/movement.rs +++ b/server/tests/movement.rs @@ -52,3 +52,31 @@ fn movement_validated_within_app() { 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) + ); +} From 90edf3fabc72b0538bd40f5c5152021fe2af6582 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 20:15:48 +0100 Subject: [PATCH 9/9] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbd157ab..d31ea8f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - 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 -- 21 new tests (4 framing + 2 IPC integration + 14 movement unit + 1 movement integration), total 41 +- 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