refactor(simulation): split the input.rs and dialogue.rs dispatchers (T-1062)
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>
This commit is contained in:
@@ -11,6 +11,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::bridge::types::MovementStance;
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind};
|
||||
use crate::simulation::stance::Stance;
|
||||
|
||||
@@ -448,6 +449,39 @@ pub fn validate_movement(
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a movement action with stance-based cooldown enforcement.
|
||||
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
|
||||
/// according to the stance's ticks_per_move. Without these components,
|
||||
/// movement is unrestricted (backward compatibility).
|
||||
pub fn apply_move(
|
||||
player_query: &mut crate::simulation::input::PlayerInputQuery,
|
||||
commands: &mut Commands,
|
||||
dx: i32,
|
||||
dy: i32,
|
||||
) {
|
||||
let (entity, pos, stance_opt, cooldown_opt) = player_query
|
||||
.single_mut()
|
||||
.expect("PlayerCharacter entity must exist when processing input");
|
||||
|
||||
let stance = stance_opt.map(|s| s.0).unwrap_or_default();
|
||||
|
||||
// Check cooldown if present
|
||||
if let Some(mut cooldown) = cooldown_opt {
|
||||
if !cooldown.try_move(stance) {
|
||||
tracing::trace!("Movement throttled by stance {:?} cooldown", stance);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands.entity(entity).insert(MoveIntent {
|
||||
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||
});
|
||||
// Update facing direction based on movement (D-015 vision cone)
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Facing(facing_from_delta(dx, dy)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1283,4 +1317,141 @@ mod tests {
|
||||
let emitter = world.get::<SoundEventEmitter>(entity).unwrap();
|
||||
assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// apply_move stance-cooldown throttle tests (D-053; moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::stance::PlayerMoveCooldown;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
#[test]
|
||||
fn walk_stance_throttles_movement_to_every_2_ticks() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(), // Walk
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Tick 0: move north — should succeed (first move)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"first move should succeed"
|
||||
);
|
||||
|
||||
// Remove MoveIntent (simulating validate_movement consuming it)
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Tick 0 again: move north — should be throttled (cooldown)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"second move should be throttled"
|
||||
);
|
||||
|
||||
// Tick 0 again: move north — should succeed (cooldown elapsed)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"third move should succeed after cooldown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_stance_allows_every_tick() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance(MovementStance::Sprint),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First move
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move — sprint allows every tick
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"sprint should allow every tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stance_component_moves_unrestricted() {
|
||||
// Backward compatibility: entities without Stance/Cooldown move freely
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move immediately — no throttle without components
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user