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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user