Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-02-11 20:25:55 +01:00
10 changed files with 1060 additions and 2 deletions
+10
View File
@@ -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)
+112
View File
@@ -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);
}
}
+118
View File
@@ -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);
}
}
}
+27
View File
@@ -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;
+5 -1
View File
@@ -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,
+7 -1
View File
@@ -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");
}
+569
View File
@@ -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());
}
}
+126
View File
@@ -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"),
}
}
+82
View File
@@ -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)
);
}
+4
View File
@@ -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,