Implements the core Sprint 6: Touch systems across 5 tickets: - #449 ObserverSnapshot v6: add player_stance (MovementStance) and player_inventory (Vec<InventoryItem>) wire fields with serde defaults for backward compatibility. Bump PROTOCOL_VERSION 5→6. - #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers, and PlayerMoveCooldown component. ToggleStanceUp/Down player actions. - #420 TilePresence: posture-layer collision system allowing same-tile occupancy for different layers (Standing/Prone/Seated/Fixture). Layer-based collision in validate_movement. - #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/ Furniture types with Phase 1 verb sets computed from type + proximity. - #422 Phase 2 verb filter: KG-gated observer-side verb processing — POI priority flips, Confront injection at KnowsDetails+, contradiction marking, archetype-specific label relabeling (Smuggler/Detective). 217 unit tests + 17 integration tests passing. All MessagePack fixtures regenerated for v6 wire format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
511 lines
18 KiB
Rust
511 lines
18 KiB
Rust
// Input processing system
|
|
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
|
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
|
|
|
use crate::bridge::types::{PlayerAction, PlayerInput};
|
|
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
|
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
|
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
|
use crate::simulation::time::{SimulationTime, TickRate};
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::VecDeque;
|
|
|
|
/// Queue of pending player inputs, ordered by tick
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct InputQueue {
|
|
queue: VecDeque<PlayerInput>,
|
|
}
|
|
|
|
impl InputQueue {
|
|
/// Add a new input to the queue.
|
|
/// Inputs must be pushed in tick order for deterministic processing.
|
|
/// Panics in debug builds if tick ordering is violated.
|
|
pub fn push(&mut self, input: PlayerInput) {
|
|
debug_assert!(
|
|
self.queue.back().is_none_or(|last| last.tick <= input.tick),
|
|
"InputQueue: tick ordering violated (last={}, new={})",
|
|
self.queue.back().map_or(0, |last| last.tick),
|
|
input.tick,
|
|
);
|
|
self.queue.push_back(input);
|
|
}
|
|
|
|
/// Drain all inputs for ticks <= the given tick
|
|
/// Returns inputs in FIFO order
|
|
pub fn drain_for_tick(&mut self, tick: u64) -> Vec<PlayerInput> {
|
|
let mut result = Vec::new();
|
|
while let Some(front) = self.queue.front() {
|
|
if front.tick <= tick {
|
|
result.push(self.queue.pop_front().unwrap());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Get the current queue length
|
|
pub fn len(&self) -> usize {
|
|
self.queue.len()
|
|
}
|
|
|
|
/// Check if the queue is empty
|
|
pub fn is_empty(&self) -> bool {
|
|
self.queue.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
|
/// Handles stance toggling (D-053) and movement cooldown based on current stance.
|
|
#[allow(clippy::type_complexity)]
|
|
pub fn process_player_input(
|
|
mut input_queue: ResMut<InputQueue>,
|
|
mut time: ResMut<SimulationTime>,
|
|
mut commands: Commands,
|
|
mut player_query: Query<
|
|
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
) {
|
|
let current_tick = time.tick;
|
|
let inputs = input_queue.drain_for_tick(current_tick);
|
|
|
|
// Track whether any movement was attempted this tick (for cooldown tick advance)
|
|
let mut move_attempted = false;
|
|
|
|
for input in inputs {
|
|
match input.action {
|
|
PlayerAction::MoveNorth => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, 0, -1);
|
|
}
|
|
PlayerAction::MoveSouth => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, 0, 1);
|
|
}
|
|
PlayerAction::MoveEast => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, 1, 0);
|
|
}
|
|
PlayerAction::MoveWest => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, -1, 0);
|
|
}
|
|
PlayerAction::MoveNortheast => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, 1, -1);
|
|
}
|
|
PlayerAction::MoveNorthwest => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, -1, -1);
|
|
}
|
|
PlayerAction::MoveSoutheast => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, 1, 1);
|
|
}
|
|
PlayerAction::MoveSouthwest => {
|
|
move_attempted = true;
|
|
apply_move(&mut player_query, &mut commands, -1, 1);
|
|
}
|
|
PlayerAction::ToggleStanceUp => {
|
|
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
|
|
let new_stance = stance.0.step_up();
|
|
if new_stance != stance.0 {
|
|
tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance);
|
|
stance.0 = new_stance;
|
|
}
|
|
}
|
|
}
|
|
PlayerAction::ToggleStanceDown => {
|
|
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
|
|
let new_stance = stance.0.step_down();
|
|
if new_stance != stance.0 {
|
|
tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance);
|
|
stance.0 = new_stance;
|
|
}
|
|
}
|
|
}
|
|
PlayerAction::Pause => {
|
|
time.tick_rate = TickRate::Paused;
|
|
tracing::debug!("Simulation paused by player input");
|
|
}
|
|
PlayerAction::Unpause => {
|
|
time.tick_rate = TickRate::Full;
|
|
tracing::debug!("Simulation unpaused by player input");
|
|
}
|
|
PlayerAction::SetTickRate(rate) => {
|
|
time.tick_rate = rate;
|
|
tracing::debug!("Tick rate set to {:?} by player input", rate);
|
|
}
|
|
PlayerAction::Interact { target_entity_id, verb } => {
|
|
tracing::info!(
|
|
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
|
target_entity_id,
|
|
verb,
|
|
);
|
|
}
|
|
PlayerAction::UsePerceptionMode(ref mode) => {
|
|
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no movement was attempted this tick, still advance cooldown counter
|
|
if !move_attempted {
|
|
if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() {
|
|
cooldown.tick();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply a movement action with stance-based cooldown enforcement.
|
|
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
|
|
/// according to the stance's ticks_per_move. Without these components,
|
|
/// movement is unrestricted (backward compatibility).
|
|
#[allow(clippy::type_complexity)]
|
|
fn apply_move(
|
|
player_query: &mut Query<
|
|
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
commands: &mut Commands,
|
|
dx: i32,
|
|
dy: i32,
|
|
) {
|
|
let (entity, pos, stance_opt, cooldown_opt) = player_query
|
|
.single_mut()
|
|
.expect("PlayerCharacter entity must exist when processing input");
|
|
|
|
let stance = stance_opt.map(|s| s.0).unwrap_or_default();
|
|
|
|
// Check cooldown if present
|
|
if let Some(mut cooldown) = cooldown_opt {
|
|
if !cooldown.try_move(stance) {
|
|
tracing::trace!("Movement throttled by stance {:?} cooldown", stance);
|
|
return;
|
|
}
|
|
}
|
|
|
|
commands.entity(entity).insert(MoveIntent {
|
|
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
|
});
|
|
// Update facing direction based on movement (D-015 vision cone)
|
|
commands
|
|
.entity(entity)
|
|
.insert(Facing(facing_from_delta(dx, dy)));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn drain_returns_inputs_up_to_tick() {
|
|
let mut queue = InputQueue::default();
|
|
queue.push(PlayerInput {
|
|
tick: 1,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 2,
|
|
action: PlayerAction::MoveSouth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::Interact { target_entity_id: None, verb: None },
|
|
});
|
|
let inputs = queue.drain_for_tick(3);
|
|
assert_eq!(inputs.len(), 2);
|
|
assert_eq!(queue.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn drain_empty_queue_returns_empty() {
|
|
let mut queue = InputQueue::default();
|
|
let inputs = queue.drain_for_tick(10);
|
|
assert!(inputs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "tick ordering violated")]
|
|
fn push_rejects_out_of_order_in_debug() {
|
|
let mut queue = InputQueue::default();
|
|
queue.push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 2,
|
|
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::default());
|
|
|
|
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_sets_paused() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
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_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Paused);
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_set_tick_rate() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::SetTickRate(TickRate::Half),
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Half);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "PlayerCharacter entity must exist")]
|
|
fn process_input_no_player_panics() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_future_tick_ignored() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
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());
|
|
}
|
|
|
|
use crate::bridge::types::MovementStance;
|
|
|
|
#[test]
|
|
fn toggle_stance_up_changes_stance() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(),
|
|
PlayerMoveCooldown::default(),
|
|
));
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::ToggleStanceUp,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
let mut query = world.query::<&Stance>();
|
|
let stance = query.single(&world).unwrap();
|
|
assert_eq!(stance.0, MovementStance::Sprint);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_stance_down_changes_stance() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(),
|
|
PlayerMoveCooldown::default(),
|
|
));
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::ToggleStanceDown,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
let mut query = world.query::<&Stance>();
|
|
let stance = query.single(&world).unwrap();
|
|
assert_eq!(stance.0, MovementStance::Careful);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_stance_throttles_movement_to_every_2_ticks() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(), // Walk
|
|
PlayerMoveCooldown::default(),
|
|
))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
|
|
// Tick 0: move north — should succeed (first move)
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some(), "first move should succeed");
|
|
|
|
// Remove MoveIntent (simulating validate_movement consuming it)
|
|
world.entity_mut(player).remove::<MoveIntent>();
|
|
|
|
// Tick 0 again: move north — should be throttled (cooldown)
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_none(), "second move should be throttled");
|
|
|
|
// Tick 0 again: move north — should succeed (cooldown elapsed)
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some(), "third move should succeed after cooldown");
|
|
}
|
|
|
|
#[test]
|
|
fn sprint_stance_allows_every_tick() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance(MovementStance::Sprint),
|
|
PlayerMoveCooldown::default(),
|
|
))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
|
|
// First move
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some());
|
|
world.entity_mut(player).remove::<MoveIntent>();
|
|
|
|
// Second move — sprint allows every tick
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some(), "sprint should allow every tick");
|
|
}
|
|
|
|
#[test]
|
|
fn no_stance_component_moves_unrestricted() {
|
|
// Backward compatibility: entities without Stance/Cooldown move freely
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some());
|
|
world.entity_mut(player).remove::<MoveIntent>();
|
|
|
|
// Second move immediately — no throttle without components
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(world.get::<MoveIntent>(player).is_some());
|
|
}
|
|
}
|