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
+162
View File
@@ -0,0 +1,162 @@
// Stance system — D-053 movement stances with tick-based speed
//
// MovementStance (Sprint/Walk/Careful/Crouch) affects:
// - Movement speed (ticks per step): Sprint=1, Walk=2, Careful=3, Crouch=4
// - Monologue rate: Sprint=40%, Walk=100%, Careful=150%, Crouch=100%
// - Interaction buffer: Sprint suppresses (D-055, wired in #419)
//
// The stance ladder is toggled via PlayerAction::ToggleStanceUp/Down.
// This module provides the ECS component and movement cooldown.
use bevy_ecs::prelude::*;
use crate::bridge::types::MovementStance;
/// ECS component tracking an entity's current movement stance.
/// Attached to PlayerCharacter (and potentially NPCs in future).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Stance(pub MovementStance);
impl Default for Stance {
fn default() -> Self {
Stance(MovementStance::Walk)
}
}
/// Tracks ticks since last movement step for stance-based speed enforcement.
/// The player's movement is throttled server-side based on their current stance.
#[derive(Component, Debug, Clone)]
pub struct PlayerMoveCooldown {
pub ticks_since_last_move: u32,
}
impl Default for PlayerMoveCooldown {
fn default() -> Self {
Self {
// Start at max so first move is immediate
ticks_since_last_move: u32::MAX,
}
}
}
impl PlayerMoveCooldown {
/// Check if the player can move this tick given their stance.
/// Returns true and resets the counter if movement is allowed.
pub fn try_move(&mut self, stance: MovementStance) -> bool {
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
if self.ticks_since_last_move >= stance.ticks_per_move() {
self.ticks_since_last_move = 0;
true
} else {
false
}
}
/// Advance the cooldown counter without attempting a move.
/// Call this each tick when no move input is present to keep the counter progressing.
pub fn tick(&mut self) {
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stance_default_is_walk() {
assert_eq!(Stance::default().0, MovementStance::Walk);
}
#[test]
fn stance_ladder_step_up() {
assert_eq!(MovementStance::Crouch.step_up(), MovementStance::Careful);
assert_eq!(MovementStance::Careful.step_up(), MovementStance::Walk);
assert_eq!(MovementStance::Walk.step_up(), MovementStance::Sprint);
assert_eq!(MovementStance::Sprint.step_up(), MovementStance::Sprint);
}
#[test]
fn stance_ladder_step_down() {
assert_eq!(MovementStance::Sprint.step_down(), MovementStance::Walk);
assert_eq!(MovementStance::Walk.step_down(), MovementStance::Careful);
assert_eq!(MovementStance::Careful.step_down(), MovementStance::Crouch);
assert_eq!(MovementStance::Crouch.step_down(), MovementStance::Crouch);
}
#[test]
fn ticks_per_move_values() {
assert_eq!(MovementStance::Sprint.ticks_per_move(), 1);
assert_eq!(MovementStance::Walk.ticks_per_move(), 2);
assert_eq!(MovementStance::Careful.ticks_per_move(), 3);
assert_eq!(MovementStance::Crouch.ticks_per_move(), 4);
}
#[test]
fn monologue_rate_values() {
assert_eq!(MovementStance::Sprint.monologue_rate_percent(), 40);
assert_eq!(MovementStance::Walk.monologue_rate_percent(), 100);
assert_eq!(MovementStance::Careful.monologue_rate_percent(), 150);
assert_eq!(MovementStance::Crouch.monologue_rate_percent(), 100);
}
#[test]
fn cooldown_first_move_immediate() {
let mut cd = PlayerMoveCooldown::default();
// First move should always succeed (counter starts at MAX)
assert!(cd.try_move(MovementStance::Walk));
}
#[test]
fn cooldown_sprint_every_tick() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Sprint)); // tick 1
assert!(cd.try_move(MovementStance::Sprint)); // tick 2
assert!(cd.try_move(MovementStance::Sprint)); // tick 3
}
#[test]
fn cooldown_walk_every_two_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Walk)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Walk)); // tick 2: cooldown
assert!(cd.try_move(MovementStance::Walk)); // tick 3: allowed
assert!(!cd.try_move(MovementStance::Walk)); // tick 4: cooldown
assert!(cd.try_move(MovementStance::Walk)); // tick 5: allowed
}
#[test]
fn cooldown_careful_every_three_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Careful)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Careful)); // tick 2: cd
assert!(!cd.try_move(MovementStance::Careful)); // tick 3: cd
assert!(cd.try_move(MovementStance::Careful)); // tick 4: allowed
}
#[test]
fn cooldown_crouch_every_four_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Crouch)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Crouch)); // tick 2: cd
assert!(!cd.try_move(MovementStance::Crouch)); // tick 3: cd
assert!(!cd.try_move(MovementStance::Crouch)); // tick 4: cd
assert!(cd.try_move(MovementStance::Crouch)); // tick 5: allowed
}
#[test]
fn cooldown_tick_advances_counter() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Walk)); // move
cd.tick(); // no move, but counter advances
assert!(cd.try_move(MovementStance::Walk)); // allowed after tick + try_move = 2
}
#[test]
fn cooldown_stance_switch_mid_cooldown() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed
// Switch to sprint mid-cooldown
assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick
}
}