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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user