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>
947 lines
35 KiB
Rust
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");
|
|
}
|
|
}
|