feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6

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>
This commit is contained in:
2026-02-14 15:38:59 +01:00
co-authored by Claude Opus 4.6
parent 651b1d34a6
commit 98f4cedc03
18 changed files with 1825 additions and 95 deletions
+259 -13
View File
@@ -1,10 +1,11 @@
// Input processing system
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
// 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;
@@ -55,25 +56,75 @@ impl InputQueue {
}
/// 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,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
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 => 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::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");
@@ -98,17 +149,43 @@ pub fn process_player_input(
}
}
}
// 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: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
player_query: &mut Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
commands: &mut Commands,
dx: i32,
dy: i32,
) {
let (entity, pos) = player_query
.single()
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),
});
@@ -261,4 +338,173 @@ mod tests {
// 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());
}
}