Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -7,6 +7,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- `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
|
- 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
|
- 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)
|
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
|
||||||
|
|
||||||
### Fixed
|
### 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)
|
- 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
|
- 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)
|
- Deterministic test snapshots (replaced Time.get_ticks_msec() with incrementing counter)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// MessagePack framing protocol
|
||||||
|
// 4-byte big-endian length prefix + payload
|
||||||
|
// Implements D-020 IPC transport layer
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
|
||||||
|
/// Maximum message size: 16 MB.
|
||||||
|
/// Sized for ObserverSnapshot with ~1000 entities (each ~40 bytes serialized),
|
||||||
|
/// plus generous headroom for future field additions. A full-map dump of 10k
|
||||||
|
/// entities would be ~400 KB, well within this limit.
|
||||||
|
const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Write a length-prefixed message to a writer.
|
||||||
|
/// Format: [4-byte BE length][payload]
|
||||||
|
pub fn write_framed(writer: &mut impl Write, payload: &[u8]) -> io::Result<()> {
|
||||||
|
let len = payload.len() as u32;
|
||||||
|
if len > MAX_MESSAGE_SIZE {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"message too large: {} bytes (max {})",
|
||||||
|
len, MAX_MESSAGE_SIZE
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_all(&len.to_be_bytes())?;
|
||||||
|
writer.write_all(payload)?;
|
||||||
|
writer.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a length-prefixed message from a reader.
|
||||||
|
/// Returns Ok(None) on clean EOF (connection closed).
|
||||||
|
/// Returns error on incomplete/corrupted reads.
|
||||||
|
pub fn read_framed(reader: &mut impl Read) -> io::Result<Option<Vec<u8>>> {
|
||||||
|
// Read 4-byte length prefix
|
||||||
|
let mut len_bytes = [0u8; 4];
|
||||||
|
match reader.read_exact(&mut len_bytes) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = u32::from_be_bytes(len_bytes);
|
||||||
|
|
||||||
|
if len > MAX_MESSAGE_SIZE {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"message too large: {} bytes (max {})",
|
||||||
|
len, MAX_MESSAGE_SIZE
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read payload
|
||||||
|
let mut payload = vec![0u8; len as usize];
|
||||||
|
reader.read_exact(&mut payload)?;
|
||||||
|
Ok(Some(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_roundtrip() {
|
||||||
|
let payload = b"hello, world!";
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
write_framed(&mut buffer, payload).expect("write failed");
|
||||||
|
|
||||||
|
let mut cursor = Cursor::new(buffer);
|
||||||
|
let result = read_framed(&mut cursor).expect("read failed");
|
||||||
|
|
||||||
|
assert_eq!(result.unwrap(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_payload_roundtrip() {
|
||||||
|
let payload = b"";
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
write_framed(&mut buffer, payload).expect("write failed");
|
||||||
|
|
||||||
|
let mut cursor = Cursor::new(buffer);
|
||||||
|
let result = read_framed(&mut cursor).expect("read failed");
|
||||||
|
|
||||||
|
assert_eq!(result.unwrap(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_message() {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let oversized_payload = vec![0u8; (MAX_MESSAGE_SIZE + 1) as usize];
|
||||||
|
|
||||||
|
let result = write_framed(&mut buffer, &oversized_payload);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("too large"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eof_returns_none() {
|
||||||
|
let buffer = Vec::new();
|
||||||
|
let mut cursor = Cursor::new(buffer);
|
||||||
|
|
||||||
|
let result = read_framed(&mut cursor).expect("read failed");
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// LocalBridge - Unix socket IPC implementation
|
||||||
|
// Implements D-020 subprocess/IPC architecture
|
||||||
|
// Deterministic client-server communication via Unix domain sockets
|
||||||
|
|
||||||
|
use super::{BridgeError, ObserverSnapshot, PlayerInput, SimBridge};
|
||||||
|
use crate::bridge::framing::{read_framed, write_framed};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// LocalBridge: Unix socket transport for client-server IPC
|
||||||
|
pub struct LocalBridge {
|
||||||
|
reader: Mutex<BufReader<UnixStream>>,
|
||||||
|
writer: Mutex<BufWriter<UnixStream>>,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalBridge {
|
||||||
|
/// Server-side: create a Unix socket listener and accept one connection.
|
||||||
|
/// Removes any stale socket file before binding.
|
||||||
|
pub fn accept(path: &Path) -> Result<Self, BridgeError> {
|
||||||
|
// Remove stale socket if it exists
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(path).map_err(|e| {
|
||||||
|
BridgeError::Transport(format!("failed to remove stale socket: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("LocalBridge listening on {:?}", path);
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(path)
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to bind Unix socket: {}", e)))?;
|
||||||
|
|
||||||
|
// Accept one connection
|
||||||
|
let (stream, _addr) = listener
|
||||||
|
.accept()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("LocalBridge accepted connection on {:?}", path);
|
||||||
|
|
||||||
|
// Clone stream for reader and writer
|
||||||
|
let reader_stream = stream.try_clone().map_err(|e| {
|
||||||
|
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
reader: Mutex::new(BufReader::new(reader_stream)),
|
||||||
|
writer: Mutex::new(BufWriter::new(stream)),
|
||||||
|
socket_path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side: connect to an existing Unix socket.
|
||||||
|
pub fn connect(path: &Path) -> Result<Self, BridgeError> {
|
||||||
|
tracing::info!("LocalBridge connecting to {:?}", path);
|
||||||
|
|
||||||
|
let stream = UnixStream::connect(path).map_err(|e| {
|
||||||
|
BridgeError::Transport(format!("failed to connect to Unix socket: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::trace!("LocalBridge connected to {:?}", path);
|
||||||
|
|
||||||
|
// Clone stream for reader and writer
|
||||||
|
let reader_stream = stream.try_clone().map_err(|e| {
|
||||||
|
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
reader: Mutex::new(BufReader::new(reader_stream)),
|
||||||
|
writer: Mutex::new(BufWriter::new(stream)),
|
||||||
|
socket_path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_path(&self) -> &Path {
|
||||||
|
&self.socket_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimBridge for LocalBridge {
|
||||||
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
|
let payload = rmp_serde::to_vec(snapshot)?;
|
||||||
|
|
||||||
|
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
||||||
|
write_framed(writer.get_mut(), &payload)?;
|
||||||
|
|
||||||
|
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
|
||||||
|
let mut reader = self.reader.lock().expect("reader mutex poisoned");
|
||||||
|
|
||||||
|
match read_framed(reader.get_mut())? {
|
||||||
|
Some(payload) => {
|
||||||
|
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
|
||||||
|
tracing::trace!("received {} inputs", inputs.len());
|
||||||
|
Ok(inputs)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::trace!("received EOF, returning empty input vec");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LocalBridge {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Best-effort socket cleanup
|
||||||
|
if self.socket_path.exists() {
|
||||||
|
let _ = fs::remove_file(&self.socket_path);
|
||||||
|
tracing::trace!("removed socket file {:?}", self.socket_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
// MessagePack serialization for Rust<->Godot communication
|
// MessagePack serialization for Rust<->Godot communication
|
||||||
|
|
||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
pub mod framing;
|
||||||
|
pub mod local;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
||||||
@@ -14,6 +17,8 @@ pub enum BridgeError {
|
|||||||
Serialization(#[from] rmp_serde::encode::Error),
|
Serialization(#[from] rmp_serde::encode::Error),
|
||||||
#[error("deserialization error: {0}")]
|
#[error("deserialization error: {0}")]
|
||||||
Deserialization(#[from] rmp_serde::decode::Error),
|
Deserialization(#[from] rmp_serde::decode::Error),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
#[error("transport error: {0}")]
|
#[error("transport error: {0}")]
|
||||||
Transport(String),
|
Transport(String),
|
||||||
}
|
}
|
||||||
@@ -28,6 +33,28 @@ pub trait SimBridge: Send + Sync {
|
|||||||
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError>;
|
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
|
/// Bridge plugin for client-server communication
|
||||||
/// Abstracts transport layer (LocalBridge/NetworkBridge)
|
/// Abstracts transport layer (LocalBridge/NetworkBridge)
|
||||||
pub struct BridgePlugin;
|
pub struct BridgePlugin;
|
||||||
|
|||||||
@@ -47,13 +47,17 @@ pub struct PlayerInput {
|
|||||||
pub action: PlayerAction,
|
pub action: PlayerAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Player action variants
|
/// Player action variants — 8-directional movement for immersive sim genre expectations
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum PlayerAction {
|
pub enum PlayerAction {
|
||||||
MoveNorth,
|
MoveNorth,
|
||||||
MoveSouth,
|
MoveSouth,
|
||||||
MoveEast,
|
MoveEast,
|
||||||
MoveWest,
|
MoveWest,
|
||||||
|
MoveNortheast,
|
||||||
|
MoveNorthwest,
|
||||||
|
MoveSoutheast,
|
||||||
|
MoveSouthwest,
|
||||||
Interact,
|
Interact,
|
||||||
UsePerceptionMode(String),
|
UsePerceptionMode(String),
|
||||||
Pause,
|
Pause,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
// Implements deterministic tick-based simulation (D-010 principle 4)
|
// Implements deterministic tick-based simulation (D-010 principle 4)
|
||||||
|
|
||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
|
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||||
|
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod movement;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
pub mod tier;
|
pub mod tier;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
@@ -18,7 +20,11 @@ impl Plugin for SimulationPlugin {
|
|||||||
app.init_resource::<time::SimulationTime>()
|
app.init_resource::<time::SimulationTime>()
|
||||||
.insert_resource(rng::SimRng::new(0))
|
.insert_resource(rng::SimRng::new(0))
|
||||||
.init_resource::<input::InputQueue>()
|
.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");
|
tracing::debug!("SimulationPlugin initialized");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
// Tile-based movement and collision system
|
||||||
|
// Implements Sprint 1 ticket #236: walkability map and movement validation
|
||||||
|
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
|
||||||
|
// Y-down convention: North = y-1, South = y+1
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Chunk size in tiles (32x32 per chunk)
|
||||||
|
pub const CHUNK_SIZE: i32 = 32;
|
||||||
|
|
||||||
|
/// Tile position component for grid-based movement.
|
||||||
|
/// Discrete integer coordinates used in simulation; converted to f32
|
||||||
|
/// at the bridge boundary for VisibleEntity wire format.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct TilePosition {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub z: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TilePosition {
|
||||||
|
pub fn new(x: i32, y: i32, z: i32) -> Self {
|
||||||
|
Self { x, y, z }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Manhattan distance to another position.
|
||||||
|
/// Returns None if positions are on different z-levels.
|
||||||
|
pub fn manhattan_distance(&self, other: &TilePosition) -> Option<u32> {
|
||||||
|
if self.z != other.z {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.x.abs_diff(other.x) + self.y.abs_diff(other.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the four cardinal neighbors (N/S/E/W) on the same z-level.
|
||||||
|
/// Y-down convention: North = y-1, South = y+1, East = x+1, West = x-1.
|
||||||
|
pub fn cardinal_neighbors(&self) -> [TilePosition; 4] {
|
||||||
|
[
|
||||||
|
TilePosition::new(self.x, self.y - 1, self.z), // North
|
||||||
|
TilePosition::new(self.x, self.y + 1, self.z), // South
|
||||||
|
TilePosition::new(self.x + 1, self.y, self.z), // East
|
||||||
|
TilePosition::new(self.x - 1, self.y, self.z), // West
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all 8 neighbors (cardinal + diagonal) on the same z-level.
|
||||||
|
pub fn all_neighbors(&self) -> [TilePosition; 8] {
|
||||||
|
[
|
||||||
|
TilePosition::new(self.x, self.y - 1, self.z), // North
|
||||||
|
TilePosition::new(self.x, self.y + 1, self.z), // South
|
||||||
|
TilePosition::new(self.x + 1, self.y, self.z), // East
|
||||||
|
TilePosition::new(self.x - 1, self.y, self.z), // West
|
||||||
|
TilePosition::new(self.x + 1, self.y - 1, self.z), // Northeast
|
||||||
|
TilePosition::new(self.x - 1, self.y - 1, self.z), // Northwest
|
||||||
|
TilePosition::new(self.x + 1, self.y + 1, self.z), // Southeast
|
||||||
|
TilePosition::new(self.x - 1, self.y + 1, self.z), // Southwest
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to f32 coordinates for VisibleEntity wire format (D-020).
|
||||||
|
/// Maps tile center to float position (tile 0 → 0.5, tile 1 → 1.5, etc.)
|
||||||
|
pub fn to_render_coords(&self) -> (f32, f32, i32) {
|
||||||
|
(self.x as f32 + 0.5, self.y as f32 + 0.5, self.z)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert from f32 render coordinates back to tile position (floor).
|
||||||
|
pub fn from_render_coords(x: f32, y: f32, z: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
x: x.floor() as i32,
|
||||||
|
y: y.floor() as i32,
|
||||||
|
z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the chunk coordinate this tile belongs to.
|
||||||
|
fn chunk_coord(&self) -> ChunkCoord {
|
||||||
|
ChunkCoord {
|
||||||
|
cx: self.x.div_euclid(CHUNK_SIZE),
|
||||||
|
cy: self.y.div_euclid(CHUNK_SIZE),
|
||||||
|
z: self.z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the local offset within its chunk.
|
||||||
|
fn local_offset(&self) -> (i32, i32) {
|
||||||
|
(self.x.rem_euclid(CHUNK_SIZE), self.y.rem_euclid(CHUNK_SIZE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chunk coordinate for chunk-based map storage (D-012).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ChunkCoord {
|
||||||
|
pub cx: i32,
|
||||||
|
pub cy: i32,
|
||||||
|
pub z: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ChunkData {
|
||||||
|
tiles: Vec<bool>, // CHUNK_SIZE * CHUNK_SIZE, true = walkable
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChunkData {
|
||||||
|
fn new_walkable() -> Self {
|
||||||
|
Self {
|
||||||
|
tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_blocked() -> Self {
|
||||||
|
Self {
|
||||||
|
tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(lx: i32, ly: i32) -> usize {
|
||||||
|
(ly * CHUNK_SIZE + lx) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, lx: i32, ly: i32) -> bool {
|
||||||
|
self.tiles[Self::index(lx, ly)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
|
||||||
|
self.tiles[Self::index(lx, ly)] = walkable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chunk-based walkability map resource (D-012, D-014).
|
||||||
|
/// Stores walkability per tile in CHUNK_SIZE x CHUNK_SIZE chunks.
|
||||||
|
/// Supports chunk load/unload for future borderless generation.
|
||||||
|
/// Unloaded chunks are treated as unwalkable.
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct WalkabilityMap {
|
||||||
|
chunks: HashMap<ChunkCoord, ChunkData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WalkabilityMap {
|
||||||
|
/// Create a walkability map covering a rectangular area with all tiles walkable.
|
||||||
|
/// Generates chunks to cover the specified dimensions on z-level 0..z_levels.
|
||||||
|
pub fn new(width: i32, height: i32, z_levels: i32) -> Self {
|
||||||
|
let mut chunks = HashMap::new();
|
||||||
|
let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
||||||
|
let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
||||||
|
for z in 0..z_levels {
|
||||||
|
for cy in 0..cy_max {
|
||||||
|
for cx in 0..cx_max {
|
||||||
|
chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_walkable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { chunks }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a walkability map covering a rectangular area with all tiles blocked.
|
||||||
|
pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self {
|
||||||
|
let mut chunks = HashMap::new();
|
||||||
|
let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
||||||
|
let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
||||||
|
for z in 0..z_levels {
|
||||||
|
for cy in 0..cy_max {
|
||||||
|
for cx in 0..cx_max {
|
||||||
|
chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_blocked());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { chunks }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is walkable. Unloaded chunks are treated as unwalkable.
|
||||||
|
pub fn can_move_to(&self, pos: &TilePosition) -> bool {
|
||||||
|
let coord = pos.chunk_coord();
|
||||||
|
let (lx, ly) = pos.local_offset();
|
||||||
|
self.chunks
|
||||||
|
.get(&coord)
|
||||||
|
.is_some_and(|chunk| chunk.get(lx, ly))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set walkability of a tile. Creates the chunk if it doesn't exist.
|
||||||
|
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
|
||||||
|
let coord = pos.chunk_coord();
|
||||||
|
let (lx, ly) = pos.local_offset();
|
||||||
|
let chunk = self
|
||||||
|
.chunks
|
||||||
|
.entry(coord)
|
||||||
|
.or_insert_with(ChunkData::new_blocked);
|
||||||
|
chunk.set(lx, ly, walkable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a chunk is loaded.
|
||||||
|
pub fn has_chunk(&self, coord: &ChunkCoord) -> bool {
|
||||||
|
self.chunks.contains_key(coord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a chunk (all walkable). Returns false if already loaded.
|
||||||
|
pub fn load_chunk(&mut self, coord: ChunkCoord) -> bool {
|
||||||
|
if self.chunks.contains_key(&coord) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.chunks.insert(coord, ChunkData::new_walkable());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unload a chunk. Returns false if not loaded.
|
||||||
|
pub fn unload_chunk(&mut self, coord: &ChunkCoord) -> bool {
|
||||||
|
self.chunks.remove(coord).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of loaded chunks.
|
||||||
|
pub fn chunk_count(&self) -> usize {
|
||||||
|
self.chunks.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component representing an intent to move to a target tile.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct MoveIntent {
|
||||||
|
pub target: TilePosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to validate and execute movement intents.
|
||||||
|
/// Checks walkability map AND entity-entity collision before allowing moves.
|
||||||
|
/// Processes all intents in a single pass: first collect occupied tiles from
|
||||||
|
/// entities without intents, then resolve movers in order — first valid claim
|
||||||
|
/// to a tile wins.
|
||||||
|
/// Always removes MoveIntent component after processing.
|
||||||
|
pub fn validate_movement(
|
||||||
|
mut commands: Commands,
|
||||||
|
walkability: Option<Res<WalkabilityMap>>,
|
||||||
|
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
|
||||||
|
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
|
||||||
|
) {
|
||||||
|
let Some(map) = walkability else {
|
||||||
|
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
|
||||||
|
for (entity, _, _) in movers.iter() {
|
||||||
|
commands.entity(entity).remove::<MoveIntent>();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect tiles occupied by stationary entities (no MoveIntent)
|
||||||
|
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
|
||||||
|
for (entity, pos) in stationary.iter() {
|
||||||
|
occupied.insert(*pos, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, intent, mut position) in movers.iter_mut() {
|
||||||
|
let target = &intent.target;
|
||||||
|
if !map.can_move_to(target) {
|
||||||
|
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
|
||||||
|
} else if occupied.contains_key(target) {
|
||||||
|
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
|
||||||
|
} else {
|
||||||
|
tracing::trace!(
|
||||||
|
"Entity {:?} moving from {:?} to {:?}",
|
||||||
|
entity,
|
||||||
|
*position,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
// Free old tile, claim new tile
|
||||||
|
occupied.remove(&*position);
|
||||||
|
*position = *target;
|
||||||
|
occupied.insert(*target, entity);
|
||||||
|
}
|
||||||
|
commands.entity(entity).remove::<MoveIntent>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tile_position_equality() {
|
||||||
|
let pos1 = TilePosition::new(5, 10, 0);
|
||||||
|
let pos2 = TilePosition::new(5, 10, 0);
|
||||||
|
let pos3 = TilePosition::new(5, 11, 0);
|
||||||
|
|
||||||
|
assert_eq!(pos1, pos2);
|
||||||
|
assert_ne!(pos1, pos3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manhattan_distance_same_level() {
|
||||||
|
let pos1 = TilePosition::new(0, 0, 0);
|
||||||
|
let pos2 = TilePosition::new(3, 4, 0);
|
||||||
|
|
||||||
|
assert_eq!(pos1.manhattan_distance(&pos2), Some(7));
|
||||||
|
assert_eq!(pos2.manhattan_distance(&pos1), Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manhattan_distance_different_level_returns_none() {
|
||||||
|
let pos1 = TilePosition::new(0, 0, 0);
|
||||||
|
let pos2 = TilePosition::new(0, 0, 1);
|
||||||
|
|
||||||
|
assert_eq!(pos1.manhattan_distance(&pos2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardinal_neighbors_correct() {
|
||||||
|
let pos = TilePosition::new(5, 5, 2);
|
||||||
|
let neighbors = pos.cardinal_neighbors();
|
||||||
|
|
||||||
|
assert_eq!(neighbors[0], TilePosition::new(5, 4, 2)); // North (y-1)
|
||||||
|
assert_eq!(neighbors[1], TilePosition::new(5, 6, 2)); // South (y+1)
|
||||||
|
assert_eq!(neighbors[2], TilePosition::new(6, 5, 2)); // East (x+1)
|
||||||
|
assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_coord_conversion_roundtrip() {
|
||||||
|
let pos = TilePosition::new(5, 10, 0);
|
||||||
|
let (rx, ry, rz) = pos.to_render_coords();
|
||||||
|
assert_eq!(rx, 5.5);
|
||||||
|
assert_eq!(ry, 10.5);
|
||||||
|
assert_eq!(rz, 0);
|
||||||
|
let back = TilePosition::from_render_coords(rx, ry, rz);
|
||||||
|
assert_eq!(back, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_coord_calculation() {
|
||||||
|
// Tile (0,0) → chunk (0,0)
|
||||||
|
assert_eq!(
|
||||||
|
TilePosition::new(0, 0, 0).chunk_coord(),
|
||||||
|
ChunkCoord { cx: 0, cy: 0, z: 0 }
|
||||||
|
);
|
||||||
|
// Tile (31,31) → chunk (0,0)
|
||||||
|
assert_eq!(
|
||||||
|
TilePosition::new(31, 31, 0).chunk_coord(),
|
||||||
|
ChunkCoord { cx: 0, cy: 0, z: 0 }
|
||||||
|
);
|
||||||
|
// Tile (32,0) → chunk (1,0)
|
||||||
|
assert_eq!(
|
||||||
|
TilePosition::new(32, 0, 0).chunk_coord(),
|
||||||
|
ChunkCoord { cx: 1, cy: 0, z: 0 }
|
||||||
|
);
|
||||||
|
// Negative tile (-1,0) → chunk (-1,0)
|
||||||
|
assert_eq!(
|
||||||
|
TilePosition::new(-1, 0, 0).chunk_coord(),
|
||||||
|
ChunkCoord {
|
||||||
|
cx: -1,
|
||||||
|
cy: 0,
|
||||||
|
z: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walkability_map_default_all_walkable() {
|
||||||
|
let map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(9, 9, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walkability_map_unloaded_chunk_not_walkable() {
|
||||||
|
let map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
|
||||||
|
// Negative coords → unloaded chunk → not walkable
|
||||||
|
assert!(!map.can_move_to(&TilePosition::new(-1, 0, 0)));
|
||||||
|
assert!(!map.can_move_to(&TilePosition::new(0, -1, 0)));
|
||||||
|
|
||||||
|
// z=1 not loaded
|
||||||
|
assert!(!map.can_move_to(&TilePosition::new(0, 0, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walkability_map_set_blocked() {
|
||||||
|
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
let blocked_pos = TilePosition::new(5, 5, 0);
|
||||||
|
|
||||||
|
map.set_walkable(&blocked_pos, false);
|
||||||
|
|
||||||
|
assert!(!map.can_move_to(&blocked_pos));
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(5, 6, 0))); // Adjacent still walkable
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walkability_map_multi_z_level() {
|
||||||
|
let mut map = WalkabilityMap::new(10, 10, 3);
|
||||||
|
let blocked_pos = TilePosition::new(5, 5, 1);
|
||||||
|
|
||||||
|
map.set_walkable(&blocked_pos, false);
|
||||||
|
|
||||||
|
assert!(!map.can_move_to(&blocked_pos));
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(5, 5, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_load_unload() {
|
||||||
|
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
let coord = ChunkCoord { cx: 0, cy: 0, z: 0 };
|
||||||
|
assert!(map.has_chunk(&coord));
|
||||||
|
|
||||||
|
map.unload_chunk(&coord);
|
||||||
|
assert!(!map.has_chunk(&coord));
|
||||||
|
assert!(!map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||||
|
|
||||||
|
map.load_chunk(coord);
|
||||||
|
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_movement_allows_walkable() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||||
|
|
||||||
|
let entity = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 5, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 4, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(validate_movement);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*world.get::<TilePosition>(entity).unwrap(),
|
||||||
|
TilePosition::new(5, 4, 0)
|
||||||
|
);
|
||||||
|
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_movement_blocks_unwalkable() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
map.set_walkable(&TilePosition::new(5, 4, 0), false);
|
||||||
|
world.insert_resource(map);
|
||||||
|
|
||||||
|
let entity = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 5, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 4, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(validate_movement);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*world.get::<TilePosition>(entity).unwrap(),
|
||||||
|
TilePosition::new(5, 5, 0)
|
||||||
|
);
|
||||||
|
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_neighbors_correct() {
|
||||||
|
let pos = TilePosition::new(5, 5, 0);
|
||||||
|
let neighbors = pos.all_neighbors();
|
||||||
|
|
||||||
|
assert_eq!(neighbors[0], TilePosition::new(5, 4, 0)); // North
|
||||||
|
assert_eq!(neighbors[1], TilePosition::new(5, 6, 0)); // South
|
||||||
|
assert_eq!(neighbors[2], TilePosition::new(6, 5, 0)); // East
|
||||||
|
assert_eq!(neighbors[3], TilePosition::new(4, 5, 0)); // West
|
||||||
|
assert_eq!(neighbors[4], TilePosition::new(6, 4, 0)); // Northeast
|
||||||
|
assert_eq!(neighbors[5], TilePosition::new(4, 4, 0)); // Northwest
|
||||||
|
assert_eq!(neighbors[6], TilePosition::new(6, 6, 0)); // Southeast
|
||||||
|
assert_eq!(neighbors[7], TilePosition::new(4, 6, 0)); // Southwest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_movement_blocks_occupied_tile() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||||
|
|
||||||
|
// Stationary entity at target tile
|
||||||
|
world.spawn(TilePosition::new(5, 4, 0));
|
||||||
|
|
||||||
|
// Mover tries to move into occupied tile
|
||||||
|
let mover = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 5, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 4, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(validate_movement);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// Mover stayed put
|
||||||
|
assert_eq!(
|
||||||
|
*world.get::<TilePosition>(mover).unwrap(),
|
||||||
|
TilePosition::new(5, 5, 0)
|
||||||
|
);
|
||||||
|
assert!(world.get::<MoveIntent>(mover).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_movement_two_movers_same_target_first_wins() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||||
|
|
||||||
|
let entity_a = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 4, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 5, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let entity_b = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 6, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 5, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(validate_movement);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let pos_a = *world.get::<TilePosition>(entity_a).unwrap();
|
||||||
|
let pos_b = *world.get::<TilePosition>(entity_b).unwrap();
|
||||||
|
|
||||||
|
// Exactly one should have moved to (5,5), the other stays
|
||||||
|
let one_moved =
|
||||||
|
(pos_a == TilePosition::new(5, 5, 0)) ^ (pos_b == TilePosition::new(5, 5, 0));
|
||||||
|
assert!(one_moved, "exactly one entity should occupy the target");
|
||||||
|
assert_ne!(pos_a, pos_b, "both entities must not share a tile");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_movement_blocks_unloaded_chunk() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||||
|
|
||||||
|
let entity = world
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(0, 0, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(-1, 0, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(validate_movement);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*world.get::<TilePosition>(entity).unwrap(),
|
||||||
|
TilePosition::new(0, 0, 0)
|
||||||
|
);
|
||||||
|
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Integration tests for LocalBridge over Unix sockets (D-030 Layer 2: IPC roundtrip).
|
||||||
|
|
||||||
|
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||||
|
use settled_reach_server::bridge::local::LocalBridge;
|
||||||
|
use settled_reach_server::bridge::types::*;
|
||||||
|
use settled_reach_server::bridge::SimBridge;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Generate unique socket path for test isolation
|
||||||
|
fn test_socket_path(test_name: &str) -> PathBuf {
|
||||||
|
let pid = std::process::id();
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
PathBuf::from(format!(
|
||||||
|
"/tmp/sr-test-{}-{}-{}.sock",
|
||||||
|
test_name, pid, timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_roundtrip_over_unix_socket() {
|
||||||
|
let socket_path = test_socket_path("snapshot");
|
||||||
|
|
||||||
|
// Server thread: accept connection and send snapshot
|
||||||
|
let server_path = socket_path.clone();
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||||
|
|
||||||
|
let snapshot = ObserverSnapshot {
|
||||||
|
tick: 42,
|
||||||
|
entities: vec![VisibleEntity {
|
||||||
|
entity_id: 100,
|
||||||
|
x: 10.5,
|
||||||
|
y: 20.3,
|
||||||
|
z: 0,
|
||||||
|
kind: EntityKind::Npc,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
bridge
|
||||||
|
.send_snapshot(&snapshot)
|
||||||
|
.expect("failed to send snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give server time to bind
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Client: connect and receive snapshot
|
||||||
|
let stream = UnixStream::connect(&socket_path).expect("failed to connect");
|
||||||
|
let mut reader = std::io::BufReader::new(stream);
|
||||||
|
|
||||||
|
let payload = read_framed(&mut reader)
|
||||||
|
.expect("failed to read frame")
|
||||||
|
.expect("unexpected EOF");
|
||||||
|
|
||||||
|
let snapshot: ObserverSnapshot =
|
||||||
|
rmp_serde::from_slice(&payload).expect("failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.tick, 42);
|
||||||
|
assert_eq!(snapshot.entities.len(), 1);
|
||||||
|
assert_eq!(snapshot.entities[0].entity_id, 100);
|
||||||
|
assert_eq!(snapshot.entities[0].x, 10.5);
|
||||||
|
assert_eq!(snapshot.entities[0].y, 20.3);
|
||||||
|
|
||||||
|
server_handle.join().expect("server thread panicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_roundtrip_over_unix_socket() {
|
||||||
|
let socket_path = test_socket_path("input");
|
||||||
|
|
||||||
|
// Server thread: accept connection and receive inputs
|
||||||
|
let server_path = socket_path.clone();
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||||
|
|
||||||
|
let inputs = bridge.receive_inputs().expect("failed to receive inputs");
|
||||||
|
|
||||||
|
assert_eq!(inputs.len(), 2);
|
||||||
|
assert_eq!(inputs[0].tick, 10);
|
||||||
|
assert_eq!(inputs[1].tick, 11);
|
||||||
|
|
||||||
|
inputs
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give server time to bind
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Client: connect and send inputs
|
||||||
|
let stream = UnixStream::connect(&socket_path).expect("failed to connect");
|
||||||
|
let mut writer = std::io::BufWriter::new(stream);
|
||||||
|
|
||||||
|
let inputs = vec![
|
||||||
|
PlayerInput {
|
||||||
|
tick: 10,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
},
|
||||||
|
PlayerInput {
|
||||||
|
tick: 11,
|
||||||
|
action: PlayerAction::Interact,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let payload = rmp_serde::to_vec(&inputs).expect("failed to serialize");
|
||||||
|
write_framed(&mut writer, &payload).expect("failed to write frame");
|
||||||
|
|
||||||
|
// Drop writer to close connection and signal EOF to server
|
||||||
|
drop(writer);
|
||||||
|
|
||||||
|
let received_inputs = server_handle.join().expect("server thread panicked");
|
||||||
|
|
||||||
|
// Verify actions survived the round-trip
|
||||||
|
match &received_inputs[0].action {
|
||||||
|
PlayerAction::MoveNorth => {}
|
||||||
|
_ => panic!("expected MoveNorth action"),
|
||||||
|
}
|
||||||
|
match &received_inputs[1].action {
|
||||||
|
PlayerAction::Interact => {}
|
||||||
|
_ => panic!("expected Interact action"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use bevy_app::prelude::*;
|
||||||
|
use settled_reach_server::simulation::movement::*;
|
||||||
|
use settled_reach_server::simulation::time::SimulationTime;
|
||||||
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn movement_validated_within_app() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(SimulationPlugin);
|
||||||
|
|
||||||
|
// Insert a walkability map with one blocked tile
|
||||||
|
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||||
|
map.set_walkable(&TilePosition::new(3, 3, 0), false);
|
||||||
|
app.insert_resource(map);
|
||||||
|
|
||||||
|
// Spawn mover (walkable target) and blocked entity
|
||||||
|
let mover = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 5, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 4, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let blocked = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(3, 4, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(3, 3, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Mover moved
|
||||||
|
assert_eq!(
|
||||||
|
*app.world().get::<TilePosition>(mover).unwrap(),
|
||||||
|
TilePosition::new(5, 4, 0)
|
||||||
|
);
|
||||||
|
// Blocked stayed
|
||||||
|
assert_eq!(
|
||||||
|
*app.world().get::<TilePosition>(blocked).unwrap(),
|
||||||
|
TilePosition::new(3, 4, 0)
|
||||||
|
);
|
||||||
|
// Tick advanced
|
||||||
|
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
|
||||||
|
// Both intents consumed
|
||||||
|
assert!(app.world().get::<MoveIntent>(mover).is_none());
|
||||||
|
assert!(app.world().get::<MoveIntent>(blocked).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entity_collision_blocks_movement() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(SimulationPlugin);
|
||||||
|
app.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||||
|
|
||||||
|
// Stationary entity at (5,4)
|
||||||
|
app.world_mut().spawn(TilePosition::new(5, 4, 0));
|
||||||
|
|
||||||
|
// Mover tries to move into occupied tile
|
||||||
|
let mover = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
TilePosition::new(5, 5, 0),
|
||||||
|
MoveIntent {
|
||||||
|
target: TilePosition::new(5, 4, 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*app.world().get::<TilePosition>(mover).unwrap(),
|
||||||
|
TilePosition::new(5, 5, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ fn all_player_action_variants_roundtrip() {
|
|||||||
PlayerAction::MoveSouth,
|
PlayerAction::MoveSouth,
|
||||||
PlayerAction::MoveEast,
|
PlayerAction::MoveEast,
|
||||||
PlayerAction::MoveWest,
|
PlayerAction::MoveWest,
|
||||||
|
PlayerAction::MoveNortheast,
|
||||||
|
PlayerAction::MoveNorthwest,
|
||||||
|
PlayerAction::MoveSoutheast,
|
||||||
|
PlayerAction::MoveSouthwest,
|
||||||
PlayerAction::Interact,
|
PlayerAction::Interact,
|
||||||
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
||||||
PlayerAction::Pause,
|
PlayerAction::Pause,
|
||||||
|
|||||||
Reference in New Issue
Block a user