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
+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.