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