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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,43 @@ 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)]
|
||||
@@ -159,4 +196,55 @@ mod tests {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user