diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index de26c42ad..e802a5303 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -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, >, registry: Res, @@ -105,11 +106,18 @@ pub fn compute_nearby_interactions( (With, Without), >, ) { - 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"); + } } diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs index 79994cec1..302318172 100644 --- a/server/src/simulation/stance.rs +++ b/server/src/simulation/stance.rs @@ -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::(entity).unwrap(); + assert_eq!(stored.default_stance, MovementStance::Walk); + + let stance = world.get::(entity).unwrap(); + assert_eq!(stance.0, MovementStance::Walk); + } }