Files
settled-reach/server/src/simulation/stance.rs
T
jpmschweitzerandClaude Opus 4.6 d0663c4193 refactor(server): apply rustfmt and clippy suggestions
Formatting pass across simulation, perception, knowledge, NPC, and
test modules. Includes two clippy fixes in monologue.rs (.values()
instead of for (_, v) pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:40:43 +01:00

257 lines
8.9 KiB
Rust

// 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;
/// Per-archetype default movement configuration (D-053).
/// Stores the default stance so spawn code can initialize Stance from it.
///
/// v0.1: smuggler and detective both default to Walk.
/// Future archetypes may differ (e.g., maintenance worker → Careful).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MovementProfile {
pub default_stance: MovementStance,
}
impl Default for MovementProfile {
fn default() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
}
impl MovementProfile {
pub fn smuggler() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
pub fn detective() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
/// Create the initial Stance component from this profile's default.
pub fn initial_stance(&self) -> Stance {
Stance(self.default_stance)
}
}
/// 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
}
// -----------------------------------------------------------------------
// MovementProfile tests (#418, D-053)
// -----------------------------------------------------------------------
#[test]
fn movement_profile_default_is_walk() {
let profile = MovementProfile::default();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_smuggler_defaults_to_walk() {
let profile = MovementProfile::smuggler();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_detective_defaults_to_walk() {
let profile = MovementProfile::detective();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_initial_stance_matches_default() {
let profile = MovementProfile::smuggler();
let stance = profile.initial_stance();
assert_eq!(stance.0, profile.default_stance);
}
#[test]
fn movement_profile_custom_default_stance() {
let profile = MovementProfile {
default_stance: MovementStance::Careful,
};
assert_eq!(profile.default_stance, MovementStance::Careful);
assert_eq!(profile.initial_stance().0, MovementStance::Careful);
}
#[test]
fn movement_profile_as_ecs_component() {
let mut world = bevy_ecs::world::World::new();
let profile = MovementProfile::smuggler();
let entity = world
.spawn((
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
let stored = world.get::<MovementProfile>(entity).unwrap();
assert_eq!(stored.default_stance, MovementStance::Walk);
let stance = world.get::<Stance>(entity).unwrap();
assert_eq!(stance.0, MovementStance::Walk);
}
}