Merge branch 'server' into main (PR #6)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- 12 LocalBridge tests (framing roundtrips, cross-layer Protocol+framing, diagonal wire mapping)
|
- 12 LocalBridge tests (framing roundtrips, cross-layer Protocol+framing, diagonal wire mapping)
|
||||||
- 4 diagonal movement cross-language fixtures (Rust → GDScript, D-030 Layer 1)
|
- 4 diagonal movement cross-language fixtures (Rust → GDScript, D-030 Layer 1)
|
||||||
- 33 total client tests passing (up from 20)
|
- 33 total client tests passing (up from 20)
|
||||||
|
- TcpBridge transport for Godot client connection — TCP localhost IPC alongside existing Unix socket LocalBridge
|
||||||
|
- Input processing system (process_player_input) — drains InputQueue, converts PlayerActions to MoveIntent components, handles pause/unpause
|
||||||
|
- Snapshot generation system (generate_snapshot) — builds ObserverSnapshot from ECS state with render coordinate conversion
|
||||||
|
- Bridge I/O systems (receive_bridge_inputs, send_bridge_snapshot) — wire bridge to ECS pipeline with graceful disconnect detection
|
||||||
|
- Full game loop in main.rs — TCP accept, tick loop with ServerRunning resource, CLI/env addr config
|
||||||
|
- PlayerCharacter marker component, Player EntityKind variant, SnapshotBuffer resource
|
||||||
|
- E2E game_loop integration test verifying player movement through full pipeline
|
||||||
|
- 8 new tests (3 TCP bridge + 4 input processing + 1 E2E game loop), total 53
|
||||||
- v0.1 content gap analysis workshop — 6 agents, 2 rounds, 9 content layers, 8 new decisions (D-032 through D-039)
|
- v0.1 content gap analysis workshop — 6 agents, 2 rounds, 9 content layers, 8 new decisions (D-032 through D-039)
|
||||||
- D-032: Separate monologue pools per playable character (hard partition, not filter)
|
- D-032: Separate monologue pools per playable character (hard partition, not filter)
|
||||||
- D-033: Entity color represents relationship to player character (asymmetric per character)
|
- D-033: Entity color represents relationship to player character (asymmetric per character)
|
||||||
@@ -103,6 +111,8 @@ 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
|
||||||
|
- Wire format mismatch: LocalBridge now uses rmp_serde::to_vec_named() (named maps) matching client Protocol.gd expectations
|
||||||
|
- EOF on bridge read now returns BridgeError::Transport for disconnect detection instead of empty Vec
|
||||||
- WalkabilityMap rewritten from flat Vec to chunk-based HashMap per D-012 architecture (Tyre review)
|
- 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)
|
- LocalBridge mutex .unwrap() → .expect() for clearer panic messages (Hoshe review)
|
||||||
- Documented 16MB MAX_MESSAGE_SIZE rationale in framing.rs (Hoshe review)
|
- Documented 16MB MAX_MESSAGE_SIZE rationale in framing.rs (Hoshe review)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ impl LocalBridge {
|
|||||||
|
|
||||||
impl SimBridge for LocalBridge {
|
impl SimBridge for LocalBridge {
|
||||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
let payload = rmp_serde::to_vec(snapshot)?;
|
let payload = rmp_serde::to_vec_named(snapshot)?;
|
||||||
|
|
||||||
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
||||||
write_framed(writer.get_mut(), &payload)?;
|
write_framed(writer.get_mut(), &payload)?;
|
||||||
@@ -99,10 +99,7 @@ impl SimBridge for LocalBridge {
|
|||||||
tracing::trace!("received {} inputs", inputs.len());
|
tracing::trace!("received {} inputs", inputs.len());
|
||||||
Ok(inputs)
|
Ok(inputs)
|
||||||
}
|
}
|
||||||
None => {
|
None => Err(BridgeError::Disconnected),
|
||||||
tracing::trace!("received EOF, returning empty input vec");
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-2
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
|
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||||
|
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod local;
|
pub mod local;
|
||||||
|
pub mod tcp;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ pub enum BridgeError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error("transport error: {0}")]
|
#[error("transport error: {0}")]
|
||||||
Transport(String),
|
Transport(String),
|
||||||
|
#[error("client disconnected")]
|
||||||
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstracts transport layer (D-020)
|
/// Abstracts transport layer (D-020)
|
||||||
@@ -55,13 +59,112 @@ impl BridgeResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate ObserverSnapshot from ECS state
|
||||||
|
pub fn generate_snapshot(
|
||||||
|
time: Res<crate::simulation::time::SimulationTime>,
|
||||||
|
entities: Query<(
|
||||||
|
Entity,
|
||||||
|
&crate::simulation::movement::TilePosition,
|
||||||
|
Option<&crate::simulation::movement::PlayerCharacter>,
|
||||||
|
Option<&crate::npc::Npc>,
|
||||||
|
)>,
|
||||||
|
mut buffer: ResMut<SnapshotBuffer>,
|
||||||
|
) {
|
||||||
|
let mut visible = Vec::new();
|
||||||
|
for (entity, pos, is_player, is_npc) in entities.iter() {
|
||||||
|
let (x, y, z) = pos.to_render_coords();
|
||||||
|
let kind = if is_player.is_some() {
|
||||||
|
EntityKind::Player
|
||||||
|
} else if is_npc.is_some() {
|
||||||
|
EntityKind::Npc
|
||||||
|
} else {
|
||||||
|
EntityKind::Object
|
||||||
|
};
|
||||||
|
visible.push(VisibleEntity {
|
||||||
|
entity_id: entity.to_bits(),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
"generate_snapshot: tick={}, entities={}",
|
||||||
|
time.tick,
|
||||||
|
visible.len()
|
||||||
|
);
|
||||||
|
buffer.snapshot = Some(ObserverSnapshot {
|
||||||
|
tick: time.tick,
|
||||||
|
entities: visible,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive inputs from bridge and push to InputQueue
|
||||||
|
pub fn receive_bridge_inputs(
|
||||||
|
bridge: Option<Res<BridgeResource>>,
|
||||||
|
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
||||||
|
mut running: ResMut<ServerRunning>,
|
||||||
|
) {
|
||||||
|
let Some(bridge) = bridge else { return };
|
||||||
|
match bridge.receive_inputs() {
|
||||||
|
Ok(inputs) => {
|
||||||
|
for input in inputs {
|
||||||
|
input_queue.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(BridgeError::Disconnected) => {
|
||||||
|
tracing::info!("Client disconnected, shutting down");
|
||||||
|
running.0 = false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Bridge receive error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send snapshot from buffer to bridge
|
||||||
|
pub fn send_bridge_snapshot(
|
||||||
|
bridge: Option<Res<BridgeResource>>,
|
||||||
|
mut buffer: ResMut<SnapshotBuffer>,
|
||||||
|
mut running: ResMut<ServerRunning>,
|
||||||
|
) {
|
||||||
|
let Some(bridge) = bridge else { return };
|
||||||
|
if let Some(snapshot) = buffer.snapshot.take() {
|
||||||
|
if let Err(e) = bridge.send_snapshot(&snapshot) {
|
||||||
|
tracing::error!("Bridge send error: {}", e);
|
||||||
|
running.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server running flag resource
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct ServerRunning(pub bool);
|
||||||
|
|
||||||
|
impl Default for ServerRunning {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
|
|
||||||
impl Plugin for BridgePlugin {
|
impl Plugin for BridgePlugin {
|
||||||
fn build(&self, _app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Stub implementation - will be populated in phase 2
|
app.init_resource::<SnapshotBuffer>()
|
||||||
|
.init_resource::<ServerRunning>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||||
|
generate_snapshot
|
||||||
|
.after(crate::simulation::movement::validate_movement)
|
||||||
|
.before(crate::simulation::time::advance_tick),
|
||||||
|
send_bridge_snapshot.after(generate_snapshot),
|
||||||
|
),
|
||||||
|
);
|
||||||
tracing::debug!("BridgePlugin initialized");
|
tracing::debug!("BridgePlugin initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// TcpBridge - TCP localhost IPC implementation
|
||||||
|
// Implements D-020 subprocess/IPC architecture
|
||||||
|
// Deterministic client-server communication via TCP sockets
|
||||||
|
// Used for Godot client which lacks Unix socket support
|
||||||
|
|
||||||
|
use super::{BridgeError, ObserverSnapshot, PlayerInput, SimBridge};
|
||||||
|
use crate::bridge::framing::{read_framed, write_framed};
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// TcpBridge: TCP transport for client-server IPC
|
||||||
|
/// Same semantics as LocalBridge but over TCP localhost
|
||||||
|
pub struct TcpBridge {
|
||||||
|
reader: Mutex<BufReader<TcpStream>>,
|
||||||
|
writer: Mutex<BufWriter<TcpStream>>,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TcpBridge {
|
||||||
|
/// Server-side: bind TCP listener and accept one connection.
|
||||||
|
/// Binds to the specified address (e.g., "127.0.0.1:0" for OS-assigned port).
|
||||||
|
/// Returns the bridge with the actual bound address available via local_addr().
|
||||||
|
pub fn accept(addr: &str) -> Result<Self, BridgeError> {
|
||||||
|
tracing::info!("TcpBridge binding to {}", addr);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to bind TCP socket: {}", e)))?;
|
||||||
|
|
||||||
|
let local_addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("TcpBridge listening on {}", local_addr);
|
||||||
|
|
||||||
|
// Accept one connection
|
||||||
|
let (stream, peer_addr) = listener
|
||||||
|
.accept()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"TcpBridge accepted connection from {} on {}",
|
||||||
|
peer_addr,
|
||||||
|
local_addr
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
local_addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side: accept one connection on an existing TcpListener.
|
||||||
|
/// Avoids race conditions in tests by separating bind from accept.
|
||||||
|
pub fn accept_on(listener: TcpListener) -> Result<Self, BridgeError> {
|
||||||
|
let local_addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("TcpBridge accepting on {}", local_addr);
|
||||||
|
|
||||||
|
let (stream, peer_addr) = listener
|
||||||
|
.accept()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to accept connection: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"TcpBridge accepted connection from {} on {}",
|
||||||
|
peer_addr,
|
||||||
|
local_addr
|
||||||
|
);
|
||||||
|
|
||||||
|
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)),
|
||||||
|
local_addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side: connect to TCP address (for tests).
|
||||||
|
pub fn connect(addr: &str) -> Result<Self, BridgeError> {
|
||||||
|
tracing::info!("TcpBridge connecting to {}", addr);
|
||||||
|
|
||||||
|
let stream = TcpStream::connect(addr).map_err(|e| {
|
||||||
|
BridgeError::Transport(format!("failed to connect to TCP socket: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let local_addr = stream
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::trace!("TcpBridge connected to {}", addr);
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
local_addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the local address (useful for OS-assigned port discovery in tests).
|
||||||
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
|
self.local_addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimBridge for TcpBridge {
|
||||||
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
|
let payload = rmp_serde::to_vec_named(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 => Err(BridgeError::Disconnected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// ObserverSnapshot: data crossing the client-server boundary
|
// ObserverSnapshot: data crossing the client-server boundary
|
||||||
// PlayerInput: semantic actions from client
|
// PlayerInput: semantic actions from client
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||||
@@ -32,6 +33,7 @@ pub struct VisibleEntity {
|
|||||||
/// Category of visible entity
|
/// Category of visible entity
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum EntityKind {
|
pub enum EntityKind {
|
||||||
|
Player,
|
||||||
Npc,
|
Npc,
|
||||||
Object,
|
Object,
|
||||||
Terrain,
|
Terrain,
|
||||||
@@ -63,3 +65,9 @@ pub enum PlayerAction {
|
|||||||
Pause,
|
Pause,
|
||||||
Unpause,
|
Unpause,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct SnapshotBuffer {
|
||||||
|
pub snapshot: Option<ObserverSnapshot>,
|
||||||
|
}
|
||||||
|
|||||||
+30
-4
@@ -4,7 +4,9 @@
|
|||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use settled_reach_server::bridge::BridgePlugin;
|
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||||
|
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
||||||
|
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||||
use settled_reach_server::simulation::SimulationPlugin;
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -17,15 +19,39 @@ fn main() {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
let addr = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.or_else(|| std::env::var("SR_ADDR").ok())
|
||||||
|
.unwrap_or_else(|| "127.0.0.1:9876".to_string());
|
||||||
|
|
||||||
tracing::info!("The Settled Reach - Simulation Server starting");
|
tracing::info!("The Settled Reach - Simulation Server starting");
|
||||||
|
tracing::info!("Waiting for client connection on {}", addr);
|
||||||
|
|
||||||
|
let bridge = TcpBridge::accept(&addr).unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to accept client connection on {}: {}", addr, e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
tracing::info!("Client connected, initializing simulation");
|
||||||
|
|
||||||
// Create the bevy App and add plugins
|
// Create the bevy App and add plugins
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(SimulationPlugin);
|
app.add_plugins(SimulationPlugin);
|
||||||
app.add_plugins(BridgePlugin);
|
app.add_plugins(BridgePlugin);
|
||||||
|
app.insert_resource(BridgeResource::new(bridge));
|
||||||
|
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||||
|
app.world_mut()
|
||||||
|
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||||
|
|
||||||
// Single tick for smoke verification; real game loop in phase 2
|
tracing::info!("Simulation initialized, entering game loop");
|
||||||
app.update();
|
|
||||||
|
|
||||||
tracing::info!("Simulation server update complete");
|
// Game loop: run until client disconnects
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
// Check ServerRunning resource
|
||||||
|
if !app.world().resource::<ServerRunning>().0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Simulation server shutting down");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
|
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
|
||||||
|
|
||||||
use crate::bridge::types::PlayerInput;
|
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||||
|
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
@@ -51,10 +53,62 @@ impl InputQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||||
|
pub fn process_player_input(
|
||||||
|
mut input_queue: ResMut<InputQueue>,
|
||||||
|
mut time: ResMut<SimulationTime>,
|
||||||
|
mut commands: Commands,
|
||||||
|
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||||
|
) {
|
||||||
|
let current_tick = time.tick;
|
||||||
|
let inputs = input_queue.drain_for_tick(current_tick);
|
||||||
|
|
||||||
|
for input in inputs {
|
||||||
|
match input.action {
|
||||||
|
PlayerAction::MoveNorth => apply_move(&player_query, &mut commands, 0, -1),
|
||||||
|
PlayerAction::MoveSouth => apply_move(&player_query, &mut commands, 0, 1),
|
||||||
|
PlayerAction::MoveEast => apply_move(&player_query, &mut commands, 1, 0),
|
||||||
|
PlayerAction::MoveWest => apply_move(&player_query, &mut commands, -1, 0),
|
||||||
|
PlayerAction::MoveNortheast => apply_move(&player_query, &mut commands, 1, -1),
|
||||||
|
PlayerAction::MoveNorthwest => apply_move(&player_query, &mut commands, -1, -1),
|
||||||
|
PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1),
|
||||||
|
PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1),
|
||||||
|
PlayerAction::Pause => {
|
||||||
|
time.paused = true;
|
||||||
|
tracing::debug!("Simulation paused by player input");
|
||||||
|
}
|
||||||
|
PlayerAction::Unpause => {
|
||||||
|
time.paused = false;
|
||||||
|
tracing::debug!("Simulation unpaused by player input");
|
||||||
|
}
|
||||||
|
PlayerAction::Interact => {
|
||||||
|
tracing::trace!("Interact action — no-op for Sprint 1");
|
||||||
|
}
|
||||||
|
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||||
|
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_move(
|
||||||
|
player_query: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||||
|
commands: &mut Commands,
|
||||||
|
dx: i32,
|
||||||
|
dy: i32,
|
||||||
|
) {
|
||||||
|
if let Ok((entity, pos)) = player_query.single() {
|
||||||
|
commands.entity(entity).insert(MoveIntent {
|
||||||
|
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No player entity found for movement input");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::bridge::types::PlayerAction;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drain_returns_inputs_up_to_tick() {
|
fn drain_returns_inputs_up_to_tick() {
|
||||||
@@ -96,4 +150,97 @@ mod tests {
|
|||||||
action: PlayerAction::MoveSouth,
|
action: PlayerAction::MoveSouth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_input_move_creates_intent() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(InputQueue::default());
|
||||||
|
world.insert_resource(SimulationTime {
|
||||||
|
tick: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let player = world
|
||||||
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||||
|
tick: 0,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(process_player_input);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let intent = world.get::<MoveIntent>(player).unwrap();
|
||||||
|
assert_eq!(intent.target, TilePosition::new(5, 4, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_input_pause_toggles() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(InputQueue::default());
|
||||||
|
world.insert_resource(SimulationTime {
|
||||||
|
tick: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||||
|
tick: 0,
|
||||||
|
action: PlayerAction::Pause,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(process_player_input);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
assert!(world.resource::<SimulationTime>().paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_input_no_player_no_panic() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(InputQueue::default());
|
||||||
|
world.insert_resource(SimulationTime {
|
||||||
|
tick: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||||
|
tick: 0,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(process_player_input);
|
||||||
|
// Should not panic
|
||||||
|
schedule.run(&mut world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_input_future_tick_ignored() {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.insert_resource(InputQueue::default());
|
||||||
|
world.insert_resource(SimulationTime {
|
||||||
|
tick: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let player = world
|
||||||
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||||
|
tick: 5,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(process_player_input);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// No MoveIntent should be created (input for future tick)
|
||||||
|
assert!(world.get::<MoveIntent>(player).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ 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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
movement::validate_movement.after(time::advance_tick),
|
(
|
||||||
|
input::process_player_input,
|
||||||
|
movement::validate_movement.after(input::process_player_input),
|
||||||
|
time::advance_tick.after(movement::validate_movement),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
tracing::debug!("SimulationPlugin initialized");
|
tracing::debug!("SimulationPlugin initialized");
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use std::collections::HashMap;
|
|||||||
/// Chunk size in tiles (32x32 per chunk)
|
/// Chunk size in tiles (32x32 per chunk)
|
||||||
pub const CHUNK_SIZE: i32 = 32;
|
pub const CHUNK_SIZE: i32 = 32;
|
||||||
|
|
||||||
|
/// Marker component identifying the player-controlled entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PlayerCharacter;
|
||||||
|
|
||||||
/// Tile position component for grid-based movement.
|
/// Tile position component for grid-based movement.
|
||||||
/// Discrete integer coordinates used in simulation; converted to f32
|
/// Discrete integer coordinates used in simulation; converted to f32
|
||||||
/// at the bridge boundary for VisibleEntity wire format.
|
/// at the bridge boundary for VisibleEntity wire format.
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ fn input_roundtrip_over_unix_socket() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let payload = rmp_serde::to_vec(&inputs).expect("failed to serialize");
|
let payload = rmp_serde::to_vec_named(&inputs).expect("failed to serialize");
|
||||||
write_framed(&mut writer, &payload).expect("failed to write frame");
|
write_framed(&mut writer, &payload).expect("failed to write frame");
|
||||||
|
|
||||||
// Drop writer to close connection and signal EOF to server
|
// Drop writer to close connection and signal EOF to server
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Integration tests for TcpBridge over TCP localhost (D-030 Layer 2: IPC roundtrip).
|
||||||
|
|
||||||
|
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||||
|
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||||
|
use settled_reach_server::bridge::types::*;
|
||||||
|
use settled_reach_server::bridge::SimBridge;
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_roundtrip_over_tcp() {
|
||||||
|
// Bind listener first — port is guaranteed ready before spawning threads
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||||
|
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||||
|
|
||||||
|
// Server thread: accept on pre-bound listener and send snapshot
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = TcpBridge::accept_on(listener).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");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect and receive snapshot (no sleep needed — listener already bound)
|
||||||
|
let stream = TcpStream::connect(server_addr).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_tcp() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||||
|
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||||
|
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = TcpBridge::accept_on(listener).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
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect and send inputs
|
||||||
|
let stream = TcpStream::connect(server_addr).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_named(&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");
|
||||||
|
|
||||||
|
match &received_inputs[0].action {
|
||||||
|
PlayerAction::MoveNorth => {}
|
||||||
|
_ => panic!("expected MoveNorth action"),
|
||||||
|
}
|
||||||
|
match &received_inputs[1].action {
|
||||||
|
PlayerAction::Interact => {}
|
||||||
|
_ => panic!("expected Interact action"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_bridge_eof_returns_error() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||||
|
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||||
|
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||||
|
|
||||||
|
let result = bridge.receive_inputs();
|
||||||
|
|
||||||
|
assert!(result.is_err(), "expected error on EOF");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result,
|
||||||
|
Err(settled_reach_server::bridge::BridgeError::Disconnected)
|
||||||
|
),
|
||||||
|
"expected Disconnected error"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect and immediately disconnect without sending data
|
||||||
|
let stream = TcpStream::connect(server_addr).expect("failed to connect");
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
server_handle.join().expect("server thread panicked");
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! E2E integration test: full game loop with input processing and snapshot generation
|
||||||
|
//! Tests the complete pipeline: client sends input -> server processes -> server sends snapshot
|
||||||
|
|
||||||
|
use bevy_app::prelude::*;
|
||||||
|
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||||
|
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||||
|
use settled_reach_server::bridge::types::*;
|
||||||
|
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||||
|
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||||
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_moves_north_through_full_pipeline() {
|
||||||
|
// Bind listener first — port guaranteed ready, no sleep needed
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||||
|
let server_addr = listener.local_addr().expect("get local addr");
|
||||||
|
|
||||||
|
// Spawn server thread
|
||||||
|
let server_handle = thread::spawn(move || {
|
||||||
|
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||||
|
|
||||||
|
// Build app
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(SimulationPlugin);
|
||||||
|
app.add_plugins(BridgePlugin);
|
||||||
|
app.insert_resource(BridgeResource::new(bridge));
|
||||||
|
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||||
|
app.world_mut()
|
||||||
|
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||||
|
|
||||||
|
// Run one tick: receive input, process, validate movement, generate snapshot, send
|
||||||
|
app.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client: connect and send input (no sleep — listener was pre-bound)
|
||||||
|
let stream = TcpStream::connect(server_addr).expect("client connect");
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
|
||||||
|
let mut writer = BufWriter::new(stream);
|
||||||
|
|
||||||
|
// Send PlayerInput: MoveNorth at tick 0
|
||||||
|
let inputs = vec![PlayerInput {
|
||||||
|
tick: 0,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
}];
|
||||||
|
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
|
||||||
|
write_framed(&mut writer, &payload).expect("send inputs");
|
||||||
|
|
||||||
|
// Receive ObserverSnapshot
|
||||||
|
let response = read_framed(&mut reader)
|
||||||
|
.expect("read snapshot")
|
||||||
|
.expect("not EOF");
|
||||||
|
let snapshot: ObserverSnapshot =
|
||||||
|
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||||
|
|
||||||
|
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
||||||
|
assert_eq!(snapshot.tick, 0);
|
||||||
|
assert_eq!(snapshot.entities.len(), 1);
|
||||||
|
|
||||||
|
let player_entity = &snapshot.entities[0];
|
||||||
|
// Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
|
||||||
|
// Render coords: (16.5, 15.5, 0)
|
||||||
|
assert_eq!(player_entity.x, 16.5);
|
||||||
|
assert_eq!(player_entity.y, 15.5);
|
||||||
|
assert_eq!(player_entity.z, 0);
|
||||||
|
assert!(matches!(player_entity.kind, EntityKind::Player));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
drop(reader);
|
||||||
|
drop(writer);
|
||||||
|
server_handle.join().expect("server thread panicked");
|
||||||
|
}
|
||||||
@@ -28,7 +28,10 @@ fn generate_msgpack_fixtures() {
|
|||||||
kind: EntityKind::Npc,
|
kind: EntityKind::Npc,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
write_fixture("snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap());
|
write_fixture(
|
||||||
|
"snapshot_one_npc",
|
||||||
|
&rmp_serde::to_vec_named(&snapshot).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
// Empty snapshot
|
// Empty snapshot
|
||||||
let empty = ObserverSnapshot {
|
let empty = ObserverSnapshot {
|
||||||
@@ -42,25 +45,52 @@ fn generate_msgpack_fixtures() {
|
|||||||
tick: 100,
|
tick: 100,
|
||||||
action: PlayerAction::MoveNorth,
|
action: PlayerAction::MoveNorth,
|
||||||
};
|
};
|
||||||
write_fixture("input_move_north", &rmp_serde::to_vec_named(&input_north).unwrap());
|
write_fixture(
|
||||||
|
"input_move_north",
|
||||||
|
&rmp_serde::to_vec_named(&input_north).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
// PlayerInput: UsePerceptionMode
|
// PlayerInput: UsePerceptionMode
|
||||||
let input_perception = PlayerInput {
|
let input_perception = PlayerInput {
|
||||||
tick: 200,
|
tick: 200,
|
||||||
action: PlayerAction::UsePerceptionMode("thermal".to_string()),
|
action: PlayerAction::UsePerceptionMode("thermal".to_string()),
|
||||||
};
|
};
|
||||||
write_fixture("input_perception_mode", &rmp_serde::to_vec_named(&input_perception).unwrap());
|
write_fixture(
|
||||||
|
"input_perception_mode",
|
||||||
|
&rmp_serde::to_vec_named(&input_perception).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
// Snapshot with multiple entities and all EntityKind variants
|
// Snapshot with multiple entities and all EntityKind variants
|
||||||
let snapshot_multi = ObserverSnapshot {
|
let snapshot_multi = ObserverSnapshot {
|
||||||
tick: 999,
|
tick: 999,
|
||||||
entities: vec![
|
entities: vec![
|
||||||
VisibleEntity { entity_id: 1, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc },
|
VisibleEntity {
|
||||||
VisibleEntity { entity_id: 2, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object },
|
entity_id: 1,
|
||||||
VisibleEntity { entity_id: 3, x: 0.0, y: 0.0, z: -1, kind: EntityKind::Terrain },
|
x: 5.0,
|
||||||
|
y: 10.0,
|
||||||
|
z: 0,
|
||||||
|
kind: EntityKind::Npc,
|
||||||
|
},
|
||||||
|
VisibleEntity {
|
||||||
|
entity_id: 2,
|
||||||
|
x: 15.5,
|
||||||
|
y: 3.0,
|
||||||
|
z: 1,
|
||||||
|
kind: EntityKind::Object,
|
||||||
|
},
|
||||||
|
VisibleEntity {
|
||||||
|
entity_id: 3,
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
z: -1,
|
||||||
|
kind: EntityKind::Terrain,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
write_fixture("snapshot_multi_entity", &rmp_serde::to_vec_named(&snapshot_multi).unwrap());
|
write_fixture(
|
||||||
|
"snapshot_multi_entity",
|
||||||
|
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
|
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
|
||||||
for (name, action) in [
|
for (name, action) in [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ fn observer_snapshot_roundtrip() {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
|
||||||
assert_eq!(decoded.tick, 42);
|
assert_eq!(decoded.tick, 42);
|
||||||
@@ -30,7 +30,7 @@ fn player_input_roundtrip() {
|
|||||||
action: PlayerAction::MoveNorth,
|
action: PlayerAction::MoveNorth,
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = rmp_serde::to_vec(&input).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
||||||
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
|
||||||
assert_eq!(decoded.tick, 100);
|
assert_eq!(decoded.tick, 100);
|
||||||
@@ -43,7 +43,7 @@ fn empty_snapshot_roundtrip() {
|
|||||||
entities: vec![],
|
entities: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
|
||||||
assert_eq!(decoded.tick, 0);
|
assert_eq!(decoded.tick, 0);
|
||||||
@@ -73,11 +73,11 @@ fn all_player_action_variants_roundtrip() {
|
|||||||
tick: 1,
|
tick: 1,
|
||||||
action: action.clone(),
|
action: action.clone(),
|
||||||
};
|
};
|
||||||
let bytes = rmp_serde::to_vec(&input).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
||||||
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
assert_eq!(decoded.tick, 1);
|
assert_eq!(decoded.tick, 1);
|
||||||
// Verify the variant survived by re-serializing and comparing bytes
|
// Verify the variant survived by re-serializing and comparing bytes
|
||||||
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
||||||
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
|
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,12 @@ fn all_player_action_variants_roundtrip() {
|
|||||||
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
||||||
#[test]
|
#[test]
|
||||||
fn all_entity_kind_variants_roundtrip() {
|
fn all_entity_kind_variants_roundtrip() {
|
||||||
let kinds = vec![EntityKind::Npc, EntityKind::Object, EntityKind::Terrain];
|
let kinds = vec![
|
||||||
|
EntityKind::Player,
|
||||||
|
EntityKind::Npc,
|
||||||
|
EntityKind::Object,
|
||||||
|
EntityKind::Terrain,
|
||||||
|
];
|
||||||
|
|
||||||
for (i, kind) in kinds.into_iter().enumerate() {
|
for (i, kind) in kinds.into_iter().enumerate() {
|
||||||
let entity = VisibleEntity {
|
let entity = VisibleEntity {
|
||||||
@@ -99,9 +104,9 @@ fn all_entity_kind_variants_roundtrip() {
|
|||||||
tick: 0,
|
tick: 0,
|
||||||
entities: vec![entity],
|
entities: vec![entity],
|
||||||
};
|
};
|
||||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bytes, re_bytes,
|
bytes, re_bytes,
|
||||||
"round-trip mismatch for EntityKind variant"
|
"round-trip mismatch for EntityKind variant"
|
||||||
|
|||||||
Reference in New Issue
Block a user