Files
settled-reach/server/src/simulation/interaction.rs
T
jpmschweitzerandClaude Opus 4.6 1d76100f5d 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>
2026-02-14 16:00:09 +01:00

947 lines
35 KiB
Rust

// Interaction system — proximity detection + multi-verb InteractionOptions
// Implements #404: server-side verb computation for context-sensitive [E] key
// Extended by #421: ObjectType component + verb sets per type (D-057)
// Spec: docs/design/interaction-verbs-v0.1.md
// D-060: actions[] renamed to verbs[] across all surfaces
//
// Phase boundary: this system determines verb AVAILABILITY based on proximity
// and entity type only. Verb PRIORITY adjustment (e.g. POI flipping Observe
// above Talk) is a perception concern handled by the observer system.
// Phase 2 filtering (KG-gated verbs) handled by #422.
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, 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;
pub(crate) const MID_RANGE: u32 = 5;
/// Component marking an entity as having available interactions.
/// Attached to NPCs and examinable objects by the world setup or content loader.
#[derive(Component, Debug, Clone)]
pub struct Interactable;
// ObjectType enum is defined in bridge::types (wire type for NearbyInteraction).
// VerbDef and verb_set() impl remain here in the simulation layer.
/// A verb definition in an ObjectType's Phase 1 verb set.
#[derive(Debug, Clone, Copy)]
pub struct VerbDef {
pub kind: VerbKind,
pub label: &'static str,
pub priority: u8,
/// Whether this verb requires close range (true) or works at mid range (false)
pub close_only: bool,
}
impl ObjectType {
/// Phase 1 verb set: maximum possible verbs for this object type (D-057).
/// No KG dependency — this is simulation-level verb computation.
/// Phase 2 (#422) will filter these by the observer's knowledge.
///
/// Each verb has a default priority and range requirement:
/// - Primary verbs (priority 1-2): the main actions for this type
/// - Observe (priority 3): always available, works at mid range
pub fn verb_set(&self) -> &'static [VerbDef] {
match self {
Self::Readable => &[
VerbDef { kind: VerbKind::Read, label: "Read", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Container => &[
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Search, label: "Search", priority: 2, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
],
Self::Terminal => &[
VerbDef { kind: VerbKind::Use, label: "Use", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Door => &[
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Close, label: "Close", priority: 2, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
],
Self::Pickup => &[
VerbDef { kind: VerbKind::Take, label: "Take", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Furniture => &[
VerbDef { kind: VerbKind::Sit, label: "Sit", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
}
}
}
/// Compute nearby interactions for the player character.
/// For each entity in range, determines available verbs sorted by priority.
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
///
/// Verb source priority:
/// 1. NPCs: Talk + ExamineNpc (hardcoded, NPC-specific)
/// 2. ObjectType: type-specific verb set from verb_set() (D-057 Phase 1)
/// 3. Untyped objects: ExamineObject fallback (legacy/simple objects)
///
/// NOTE: Determines verb availability and default priority only. Relationship-based
/// priority adjustment (e.g. POI -> Observe first) is applied by the observer
/// system after taking the buffer. This keeps the simulation phase free of
/// knowledge graph dependencies (D-010 phase boundary).
#[allow(clippy::type_complexity)]
pub fn compute_nearby_interactions(
mut player_query: Query<
(&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>),
With<PlayerCharacter>,
>,
registry: Res<EntityRegistry>,
interactables: Query<
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
(With<Interactable>, Without<PlayerCharacter>),
>,
) {
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
};
if distance > MID_RANGE {
continue; // Out of interaction range
}
let entity_type = if is_npc.is_some() {
EntityKind::Npc
} else {
EntityKind::Object
};
let is_close = distance <= CLOSE_RANGE;
let mut verbs = Vec::new();
if is_npc.is_some() {
// NPC verb logic — unchanged from #404
if is_close {
// Default priority: Talk first, Observe second.
// Observer adjusts priority for POI entities.
verbs.push(VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
});
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 2,
available: true,
});
} else {
// Mid range: only Examine NPC (Talk requires close range)
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 1,
available: true,
});
}
} else if let Some(obj_type) = object_type {
// D-057 Phase 1: ObjectType-driven verb set (#421)
for def in obj_type.verb_set() {
if def.close_only && !is_close {
continue; // Skip close-only verbs when at mid range
}
verbs.push(VerbOption {
kind: def.kind,
label: def.label.into(),
priority: def.priority,
available: true,
});
}
} else {
// Legacy fallback: untyped object (no ObjectType component)
if is_close {
verbs.push(VerbOption {
kind: VerbKind::ExamineObject,
label: "Examine".into(),
priority: 1,
available: true,
});
}
}
if verbs.is_empty() {
continue;
}
// Sort by priority (lower = higher), then by kind discriminant for stability
verbs.sort_by_key(|v| (v.priority, v.kind as u8));
// Fallback to Entity::to_bits() is intentional for per-frame systems:
// panicking would crash the server every tick. The error log makes this
// loud enough to catch in testing while keeping the server alive.
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity in interaction range but not in EntityRegistry");
entity.to_bits()
});
buffer.interactions.push(NearbyInteraction {
entity_id: wire_id,
entity_type,
distance,
verbs,
object_type: object_type.copied(),
contradicted: false, // Phase 2 sets this from KG
});
}
// Sort interactions by distance (nearest first)
buffer
.interactions
.sort_by_key(|a| a.distance);
}
/// Buffer for nearby interaction results, consumed by snapshot generation.
/// Field is private — use `take()` to drain results into the snapshot.
///
/// Per-entity Component attached to the PlayerCharacter. Each observer gets
/// their own interaction buffer, so D-009 multiplayer works without refactoring.
#[derive(Component, Debug, Default)]
pub struct NearbyInteractionBuffer {
interactions: Vec<NearbyInteraction>,
}
impl NearbyInteractionBuffer {
/// Drain and return interactions, leaving the buffer empty.
/// Avoids cloning per-frame; snapshot owns the Vec after take.
pub fn take(&mut self) -> Vec<NearbyInteraction> {
std::mem::take(&mut self.interactions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::EntityRegistry;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
/// Spawn player with standard components (no KnowledgeGraph — interaction
/// system doesn't access it; POI priority is handled by observer).
fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity {
world
.spawn((
PlayerCharacter,
TilePosition::new(x, y, 0),
NearbyInteractionBuffer::default(),
))
.id()
}
/// Read the player's NearbyInteractionBuffer component
fn read_buffer(world: &mut World) -> &NearbyInteractionBuffer {
let mut query = world.query_filtered::<&NearbyInteractionBuffer, With<PlayerCharacter>>();
query.single(world).unwrap()
}
// -----------------------------------------------------------------------
// NPC verb tests (unchanged from #404)
// -----------------------------------------------------------------------
#[test]
fn npc_in_close_range_gets_talk_and_observe() {
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);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[0].priority, 1);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
assert_eq!(buffer.interactions[0].verbs[1].priority, 2);
}
#[test]
fn npc_in_mid_range_gets_observe_only() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 9, 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);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(buffer.interactions[0].verbs[0].priority, 1);
}
#[test]
fn npc_out_of_range_no_interactions() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 13, 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());
}
// -----------------------------------------------------------------------
// ObjectType verb tests (#421)
// -----------------------------------------------------------------------
#[test]
fn readable_close_range_gets_read_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Readable,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Read);
assert_eq!(buffer.interactions[0].verbs[0].label, "Read");
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
assert_eq!(buffer.interactions[0].verbs[1].label, "Observe");
}
#[test]
fn readable_mid_range_gets_observe_only() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
ObjectType::Readable,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
}
#[test]
fn container_close_range_gets_open_search_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Container,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Search);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
}
#[test]
fn terminal_close_range_gets_use_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
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_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Use);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn door_close_range_gets_open_close_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Door,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Close);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
}
#[test]
fn pickup_close_range_gets_take_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Pickup,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Take);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn furniture_close_range_gets_sit_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Furniture,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Sit);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn container_mid_range_gets_observe_only() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
ObjectType::Container,
));
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);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
}
// -----------------------------------------------------------------------
// Legacy untyped object fallback
// -----------------------------------------------------------------------
#[test]
fn untyped_object_in_close_range_gets_examine() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((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);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject);
}
#[test]
fn untyped_object_mid_range_no_verbs() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((TilePosition::new(5, 9, 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);
// Untyped objects have no mid-range verbs
assert!(buffer.interactions.is_empty());
}
// -----------------------------------------------------------------------
// General interaction tests
// -----------------------------------------------------------------------
#[test]
fn different_z_level_no_interactions() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 1), 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());
}
#[test]
fn multiple_entities_sorted_by_distance() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
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(), 2);
assert!(buffer.interactions[0].distance < buffer.interactions[1].distance);
}
#[test]
fn non_interactable_entity_ignored() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 0)));
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());
}
#[test]
fn equidistant_npcs_sorted_deterministically() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable));
world.spawn((Npc, 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_eq!(buffer.interactions.len(), 2);
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
}
#[test]
fn mixed_npcs_and_objects_all_detected() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// NPC nearby
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
// Typed object nearby
world.spawn((
TilePosition::new(6, 5, 0),
Interactable,
ObjectType::Terminal,
));
// Untyped object nearby
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_eq!(buffer.interactions.len(), 3);
// All should be at distance 1
for interaction in &buffer.interactions {
assert_eq!(interaction.distance, 1);
}
}
// -----------------------------------------------------------------------
// ObjectType::verb_set() unit tests
// -----------------------------------------------------------------------
#[test]
fn verb_set_readable_has_read_and_observe() {
let verbs = ObjectType::Readable.verb_set();
assert_eq!(verbs.len(), 2);
assert_eq!(verbs[0].kind, VerbKind::Read);
assert!(verbs[0].close_only);
assert_eq!(verbs[1].kind, VerbKind::Observe);
assert!(!verbs[1].close_only);
}
#[test]
fn verb_set_container_has_three_verbs() {
let verbs = ObjectType::Container.verb_set();
assert_eq!(verbs.len(), 3);
assert_eq!(verbs[0].kind, VerbKind::Open);
assert_eq!(verbs[1].kind, VerbKind::Search);
assert_eq!(verbs[2].kind, VerbKind::Observe);
}
#[test]
fn all_object_types_have_observe() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
let has_observe = verbs.iter().any(|v| v.kind == VerbKind::Observe);
assert!(has_observe, "{:?} should have Observe verb", obj_type);
}
}
#[test]
fn all_object_types_observe_is_mid_range() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
let observe = verbs.iter().find(|v| v.kind == VerbKind::Observe).unwrap();
assert!(
!observe.close_only,
"{:?}'s Observe should be available at mid range",
obj_type
);
}
}
// -----------------------------------------------------------------------
// Additional QA coverage (Hoshe, Sprint 6)
// -----------------------------------------------------------------------
/// All ObjectType variants at mid range should produce Observe only.
/// Covers gap: only Readable + Container had explicit mid-range tests.
#[test]
fn all_object_types_mid_range_observe_only() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// Distance 4 = mid range (> CLOSE_RANGE=2, <= MID_RANGE=5)
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
obj_type,
));
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,
"{:?} at mid range should produce 1 interaction", obj_type
);
assert_eq!(
buffer.interactions[0].verbs.len(), 1,
"{:?} at mid range should have exactly 1 verb (Observe)", obj_type
);
assert_eq!(
buffer.interactions[0].verbs[0].kind, VerbKind::Observe,
"{:?} at mid range verb should be Observe", obj_type
);
}
}
/// ObjectType entity beyond MID_RANGE produces no interactions.
#[test]
fn object_type_out_of_range_no_interactions() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// Distance 6 = beyond MID_RANGE=5
world.spawn((
TilePosition::new(5, 11, 0),
Interactable,
ObjectType::Container,
));
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());
}
/// Entity with both Npc and ObjectType: NPC verbs take priority.
/// ObjectType verbs should NOT appear — NPCs have their own verb logic.
#[test]
fn npc_with_object_type_uses_npc_verbs() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
Npc,
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Terminal, // Should be ignored
));
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);
// Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe)
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
}
/// All ObjectType primary verbs are close_only (except Observe).
/// Ensures no accidental mid-range primary actions.
#[test]
fn all_primary_verbs_are_close_only() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
for def in obj_type.verb_set() {
if def.kind == VerbKind::Observe {
assert!(!def.close_only, "{:?} Observe should be mid-range", obj_type);
} else {
assert!(
def.close_only,
"{:?} {:?} should be close-only", obj_type, def.kind
);
}
}
}
}
/// D-057: max 4 verbs per entity. Verify no ObjectType exceeds this.
#[test]
fn verb_set_max_four_verbs() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
assert!(
verbs.len() <= 4,
"{:?} has {} verbs, D-057 max is 4", obj_type, verbs.len()
);
}
}
// -----------------------------------------------------------------------
// 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");
}
}