diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 05a398abe..44e6a7fbd 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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, + entities: Query<( + Entity, + &crate::simulation::movement::TilePosition, + Option<&crate::simulation::movement::PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, + mut buffer: ResMut, +) { + 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>, + mut input_queue: ResMut, + mut running: ResMut, +) { + 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>, + mut buffer: ResMut, +) { + 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::() + .init_resource::() + .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"); } } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 685262263..1dd34690f 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, +} diff --git a/server/src/main.rs b/server/src/main.rs index 6ddee05cf..d1550f443 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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::().0 { + break; + } + } + + tracing::info!("Simulation server shutting down"); } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index ea94108ab..cc711459c 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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, + mut time: ResMut, + mut commands: Commands, + player_query: Query<(Entity, &TilePosition), With>, +) { + 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>, + 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::().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::(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::().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::().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::().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::().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::(player).is_none()); + } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 054a04e1d..dc101ad62 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -20,10 +20,13 @@ impl Plugin for SimulationPlugin { app.init_resource::() .insert_resource(rng::SimRng::new(0)) .init_resource::() - .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"); diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index afdbf0fd4..d8943774b 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -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. diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs new file mode 100644 index 000000000..f2a53e760 --- /dev/null +++ b/server/tests/game_loop.rs @@ -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"); +}