// 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); } } /// Step the player's stance one level up or down the ladder (D-053). /// No-op when the player has no Stance component or is already at the end. pub fn handle_toggle_stance( player_query: &mut crate::simulation::input::PlayerInputQuery, up: bool, ) { if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { let new_stance = if up { stance.0.step_up() } else { stance.0.step_down() }; if new_stance != stance.0 { tracing::debug!( "Stance {}: {:?} -> {:?}", if up { "up" } else { "down" }, stance.0, new_stance ); stance.0 = new_stance; } } } #[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::(entity).unwrap(); assert_eq!(stored.default_stance, MovementStance::Walk); let stance = world.get::(entity).unwrap(); assert_eq!(stance.0, MovementStance::Walk); } // ----------------------------------------------------------------------- // handle_toggle_stance ladder tests (moved from input.rs, T-1062) // ----------------------------------------------------------------------- use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::simulation::input::{process_player_input, InputQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; #[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.init_resource::(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), PlayerMoveCooldown::default(), )); world.resource_mut::().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.init_resource::(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), PlayerMoveCooldown::default(), )); world.resource_mut::().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); } }