feat(simulation): movement profile per archetype and sprint interaction suppression (#418, #419)

Add MovementProfile component with per-archetype default stances (D-053).
Sprint stance now explicitly clears the interaction buffer — no verbs
computed or sent during sprint (D-055). Anomaly monologue pipeline is
unaffected (handled by separate system).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:00:09 +01:00
co-authored by Claude Opus 4.6
parent 2fc8bf9545
commit 1d76100f5d
2 changed files with 216 additions and 3 deletions
+128 -3
View File
@@ -13,10 +13,11 @@ use bevy_ecs::prelude::*;
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
pub use crate::bridge::types::ObjectType;
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::stance::Stance;
/// Interaction range thresholds (Manhattan distance, same z-level)
pub(crate) const CLOSE_RANGE: u32 = 2;
@@ -96,7 +97,7 @@ impl ObjectType {
#[allow(clippy::type_complexity)]
pub fn compute_nearby_interactions(
mut player_query: Query<
(&TilePosition, &mut NearbyInteractionBuffer),
(&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>),
With<PlayerCharacter>,
>,
registry: Res<EntityRegistry>,
@@ -105,11 +106,18 @@ pub fn compute_nearby_interactions(
(With<Interactable>, Without<PlayerCharacter>),
>,
) {
let Ok((player_pos, mut buffer)) = player_query.single_mut() else {
let Ok((player_pos, mut buffer, stance_opt)) = player_query.single_mut() else {
return;
};
buffer.interactions.clear();
// D-055: Sprint explicitly suppresses interaction buffer.
// No interaction verbs are computed or sent to the client during sprint.
// Anomaly monologue survives sprint (handled by separate monologue system).
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
return;
}
for (entity, pos, is_npc, object_type) in interactables.iter() {
let Some(distance) = player_pos.manhattan_distance(pos) else {
continue; // Different z-level
@@ -818,4 +826,121 @@ mod tests {
);
}
}
// -----------------------------------------------------------------------
// Sprint interaction buffer suppression (#419, D-055)
// -----------------------------------------------------------------------
/// Spawn player with Stance component for sprint suppression tests.
fn spawn_player_with_stance(world: &mut World, x: i32, y: i32, stance: MovementStance) -> Entity {
world
.spawn((
PlayerCharacter,
TilePosition::new(x, y, 0),
NearbyInteractionBuffer::default(),
Stance(stance),
))
.id()
}
#[test]
fn sprint_suppresses_npc_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all interactions");
}
#[test]
fn sprint_suppresses_object_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress object interactions");
}
#[test]
fn walk_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Walk should allow interactions");
}
#[test]
fn careful_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Careful should allow interactions");
}
#[test]
fn crouch_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Crouch should allow interactions");
}
#[test]
fn no_stance_component_allows_interactions() {
// Backward compatibility: players without Stance still get interactions
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "no Stance component should allow interactions");
}
#[test]
fn sprint_suppresses_multiple_nearby_entities() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
world.spawn((TilePosition::new(6, 5, 0), Interactable, ObjectType::Container));
world.spawn((TilePosition::new(4, 5, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all 3 nearby entities");
}
}