Follow-up to b6a9b78: register TerminalInteractedQueue resource and
door/terminal interaction systems in SimulationPlugin; add door state
save/load in save_io (open doors round-trip through SaveStateV1);
make WalkabilityMap param optional in process_door_interaction so
plugin-only tests work without a loaded map; fix information_boundaries
test missing open_doors field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1230 lines
42 KiB
Rust
1230 lines
42 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)
|
|
// Extended by #246: door toggle, terminal event, examine-text (#246)
|
|
// 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::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// 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::knowledge::types::StableId;
|
|
use crate::npc::Npc;
|
|
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
|
use crate::simulation::stance::Stance;
|
|
use crate::simulation::time::SimulationTime;
|
|
|
|
/// 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).
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
#[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 — Talk + ExamineNpc (#404), Follow (#241)
|
|
if is_close {
|
|
// Default priority: Talk first, Observe second, Follow third.
|
|
// 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: "Examine NPC".into(),
|
|
priority: 2,
|
|
available: true,
|
|
});
|
|
verbs.push(VerbOption {
|
|
kind: VerbKind::Follow,
|
|
label: "Follow".into(),
|
|
priority: 3,
|
|
available: true,
|
|
});
|
|
} else {
|
|
// Mid range: only Examine NPC (Talk + Follow require close range)
|
|
verbs.push(VerbOption {
|
|
kind: VerbKind::ExamineNpc,
|
|
label: "Examine NPC".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)
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// #246 — Door behavior (toggle walkability)
|
|
// ===========================================================================
|
|
|
|
/// Component tracking the open/closed state of a door and its blocking tile.
|
|
///
|
|
/// Attach to any entity with `ObjectType::Door`. The `blocking_tile` is the
|
|
/// tile that becomes walkable when the door opens and impassable when it closes.
|
|
///
|
|
/// Door state is persisted in `SaveStateV1.open_doors` (D-010).
|
|
#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct DoorState {
|
|
/// Whether the door is currently open (walkable) or closed (blocking).
|
|
pub is_open: bool,
|
|
/// The tile whose walkability is toggled by this door.
|
|
pub blocking_tile: TilePosition,
|
|
}
|
|
|
|
impl DoorState {
|
|
pub fn new(blocking_tile: TilePosition) -> Self {
|
|
DoorState {
|
|
is_open: false,
|
|
blocking_tile,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Marker: player requested a door interaction (Open or Close) this tick.
|
|
///
|
|
/// Inserted by `process_player_input` when verb is "Open" or "Close" on a
|
|
/// Door entity. Consumed and removed by `process_door_interaction`.
|
|
#[derive(Component, Debug)]
|
|
pub struct DoorInteractRequest {
|
|
/// The ECS entity of the door to toggle.
|
|
pub door_entity: Entity,
|
|
}
|
|
|
|
/// Event emitted when a player uses a Terminal (#246).
|
|
///
|
|
/// Downstream systems (dialogue hook — future work) subscribe to this queue.
|
|
/// The queue is not automatically drained — consumers must call `drain()`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TerminalInteracted {
|
|
/// Stable ID of the terminal entity.
|
|
pub terminal_id: StableId,
|
|
/// Tick when the interaction occurred.
|
|
pub tick: u64,
|
|
}
|
|
|
|
/// Resource: queue of terminal interaction events (#246).
|
|
#[derive(Resource, Default)]
|
|
pub struct TerminalInteractedQueue {
|
|
pub events: Vec<TerminalInteracted>,
|
|
}
|
|
|
|
impl TerminalInteractedQueue {
|
|
pub fn push(&mut self, event: TerminalInteracted) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
pub fn drain(&mut self) -> Vec<TerminalInteracted> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.events.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Marker: player requested a Terminal interaction this tick.
|
|
///
|
|
/// Inserted by `process_player_input` when verb is "Use" on a Terminal entity.
|
|
/// Consumed and removed by `process_terminal_interaction`.
|
|
#[derive(Component, Debug)]
|
|
pub struct TerminalInteractRequest {
|
|
pub terminal_entity: Entity,
|
|
}
|
|
|
|
/// System: toggle door open/closed state and update walkability map (#246).
|
|
///
|
|
/// Reads `DoorInteractRequest` on the player entity. Toggles `DoorState.is_open`
|
|
/// and updates `WalkabilityMap` for the door's `blocking_tile`.
|
|
///
|
|
/// Ordering: after `process_player_input`, before movement validation.
|
|
pub fn process_door_interaction(
|
|
mut commands: Commands,
|
|
walkability: Option<ResMut<WalkabilityMap>>,
|
|
player_query: Query<(Entity, &DoorInteractRequest), With<PlayerCharacter>>,
|
|
mut door_query: Query<&mut DoorState>,
|
|
) {
|
|
let Ok((player_entity, req)) = player_query.single() else {
|
|
return;
|
|
};
|
|
|
|
let door_entity = req.door_entity;
|
|
commands.entity(player_entity).remove::<DoorInteractRequest>();
|
|
|
|
let Ok(mut door) = door_query.get_mut(door_entity) else {
|
|
tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target");
|
|
return;
|
|
};
|
|
|
|
// Toggle state
|
|
door.is_open = !door.is_open;
|
|
let walkable = door.is_open;
|
|
let tile = door.blocking_tile;
|
|
|
|
if let Some(mut walkability) = walkability {
|
|
walkability.set_walkable(&tile, walkable);
|
|
}
|
|
|
|
tracing::info!(
|
|
?tile,
|
|
is_open = door.is_open,
|
|
"Door toggled: tile walkability set to {walkable}"
|
|
);
|
|
}
|
|
|
|
/// System: emit TerminalInteracted event when player uses a Terminal (#246).
|
|
///
|
|
/// Reads `TerminalInteractRequest` on the player entity, emits to
|
|
/// `TerminalInteractedQueue`, and removes the request.
|
|
pub fn process_terminal_interaction(
|
|
mut commands: Commands,
|
|
time: Res<SimulationTime>,
|
|
registry: Res<EntityRegistry>,
|
|
player_query: Query<(Entity, &TerminalInteractRequest), With<PlayerCharacter>>,
|
|
mut queue: ResMut<TerminalInteractedQueue>,
|
|
) {
|
|
let Ok((player_entity, req)) = player_query.single() else {
|
|
return;
|
|
};
|
|
|
|
let terminal_entity = req.terminal_entity;
|
|
commands.entity(player_entity).remove::<TerminalInteractRequest>();
|
|
|
|
let terminal_id = registry
|
|
.to_stable(terminal_entity)
|
|
.unwrap_or(StableId(terminal_entity.to_bits()));
|
|
|
|
queue.push(TerminalInteracted {
|
|
terminal_id,
|
|
tick: time.tick,
|
|
});
|
|
|
|
tracing::info!(
|
|
?terminal_entity,
|
|
terminal_id = terminal_id.0,
|
|
tick = time.tick,
|
|
"Terminal used: TerminalInteracted event queued"
|
|
);
|
|
}
|
|
|
|
#[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_examine_follow() {
|
|
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(), 3);
|
|
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);
|
|
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
|
|
assert_eq!(buffer.interactions[0].verbs[2].priority, 3);
|
|
}
|
|
|
|
#[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 + Follow), NOT Terminal verbs (Use + Observe)
|
|
assert_eq!(buffer.interactions[0].verbs.len(), 3);
|
|
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
|
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
|
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|