feat(simulation): add input processing, snapshot gen, and game loop

Implements the full server-side tick pipeline:
- process_player_input drains InputQueue, converts PlayerActions to
  MoveIntent components or pause/unpause toggles
- generate_snapshot builds ObserverSnapshot from ECS state with
  render coordinate conversion
- receive_bridge_inputs/send_bridge_snapshot handle bridge I/O with
  graceful disconnect detection via ServerRunning resource
- main.rs now accepts TCP connections and runs a proper game loop
- PlayerCharacter marker, Player EntityKind, SnapshotBuffer resource

Closes server side of #81, #82, #83.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:01:28 +01:00
co-authored by Claude Opus 4.6
parent 1b0e514560
commit 7864bfdf7d
7 changed files with 397 additions and 10 deletions
+94 -2
View File
@@ -4,9 +4,11 @@
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod framing;
pub mod local;
pub mod tcp;
pub mod types;
pub use types::*;
@@ -55,13 +57,103 @@ 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,
});
}
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::Transport(ref msg)) if msg.contains("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>,
) {
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);
}
}
}
/// 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
/// Abstracts transport layer (LocalBridge/NetworkBridge)
pub struct BridgePlugin;
impl Plugin for BridgePlugin {
fn build(&self, _app: &mut App) {
// Stub implementation - will be populated in phase 2
fn build(&self, app: &mut App) {
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),
send_bridge_snapshot.after(generate_snapshot),
),
);
tracing::debug!("BridgePlugin initialized");
}
}
+8
View File
@@ -2,6 +2,7 @@
// ObserverSnapshot: data crossing the client-server boundary
// PlayerInput: semantic actions from client
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
/// The ONLY data structure crossing the client-server boundary (D-020)
@@ -32,6 +33,7 @@ pub struct VisibleEntity {
/// Category of visible entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EntityKind {
Player,
Npc,
Object,
Terrain,
@@ -63,3 +65,9 @@ pub enum PlayerAction {
Pause,
Unpause,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
pub snapshot: Option<ObserverSnapshot>,
}
+27 -4
View File
@@ -4,7 +4,9 @@
use bevy_app::prelude::*;
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;
fn main() {
@@ -17,15 +19,36 @@ fn main() {
.with(tracing_subscriber::fmt::layer())
.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!("Waiting for client connection on {}", addr);
let bridge = TcpBridge::accept(&addr).expect("Failed to accept client connection");
tracing::info!("Client connected, initializing simulation");
// Create the bevy App and add plugins
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)));
// Single tick for smoke verification; real game loop in phase 2
app.update();
tracing::info!("Simulation initialized, entering game loop");
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");
}
+149 -2
View File
@@ -2,7 +2,9 @@
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// 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 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)]
mod tests {
use super::*;
use crate::bridge::types::PlayerAction;
#[test]
fn drain_returns_inputs_up_to_tick() {
@@ -96,4 +150,97 @@ mod tests {
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());
}
}
+5 -2
View File
@@ -20,10 +20,13 @@ 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,
movement::validate_movement.after(time::advance_tick),
(
input::process_player_input,
time::advance_tick.after(input::process_player_input),
movement::validate_movement.after(time::advance_tick),
),
);
tracing::debug!("SimulationPlugin initialized");
+4
View File
@@ -10,6 +10,10 @@ use std::collections::HashMap;
/// Chunk size in tiles (32x32 per chunk)
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.
/// Discrete integer coordinates used in simulation; converted to f32
/// at the bridge boundary for VisibleEntity wire format.
+110
View File
@@ -0,0 +1,110 @@
//! 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::sync::mpsc;
use std::thread;
use std::time::Duration;
#[test]
fn player_moves_north_through_full_pipeline() {
// Pre-bind listener to discover the port
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
let addr_str = server_addr.to_string();
// Channel to signal when server is ready to accept
let (ready_tx, ready_rx) = mpsc::channel();
// Spawn server thread
let server_handle = thread::spawn(move || {
// Drop the pre-bound listener since TcpBridge::accept will bind its own
drop(listener);
// Signal we're about to accept
ready_tx.send(()).expect("send ready signal");
// Accept connection
let bridge = TcpBridge::accept(&addr_str).expect("accept connection");
eprintln!("Test server accepted 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();
eprintln!("Server completed one update cycle");
});
// Wait for server thread to start
ready_rx
.recv_timeout(Duration::from_secs(2))
.expect("server did not become ready");
// Give server time to bind and call accept()
thread::sleep(Duration::from_millis(100));
// Client: connect and send input
{
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");
eprintln!("Client sent MoveNorth input");
// 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");
eprintln!(
"Client received snapshot: tick={}, entities={}",
snapshot.tick,
snapshot.entities.len()
);
// Verify snapshot
assert_eq!(snapshot.tick, 1); // After one tick
assert_eq!(snapshot.entities.len(), 1); // One entity (player)
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));
eprintln!(
"Client verified player moved to ({}, {}, {})",
player_entity.x, player_entity.y, player_entity.z
);
}
// Wait for server thread
server_handle.join().expect("server thread panicked");
}