input.rs 2,739 → 858 lines: per-domain action handlers moved to their owning modules (inventory, movement, stance, examine, follow, interaction, save_io, settings, bridge::debug, economy, vision_cone, bookmark, test_world reset + new teleport.rs); input.rs keeps the queue, the thin dispatch table, and pause/cooldown glue. All 9 type_complexity allows dissolved via one PlayerInputQuery alias. dialogue.rs → dialogue/ directory module: selection (631), response (1,473), confrontation (714), mod.rs (226, shared session components + re-exports — public paths preserved). Documented seam deviation: process_walk_away lives with confrontation (D-064/D-063 share the same world-response shape). Mechanical, zero behavior change: determinism + golden_suite byte-identical (independently re-verified); 1,504 lib tests unchanged — 23 input tests moved with their subjects, 53 dialogue tests redistributed, zero deleted. System scheduling registrations untouched (input_plugin.rs 0-line diff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346 lines
12 KiB
Rust
346 lines
12 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);
|
|
}
|
|
}
|
|
|
|
/// 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::<MovementProfile>(entity).unwrap();
|
|
assert_eq!(stored.default_stance, MovementStance::Walk);
|
|
|
|
let stance = world.get::<Stance>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
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.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
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);
|
|
}
|
|
}
|