Extract visibility geometry into a separate system behind a PerceptionQuery trait, enabling D-017 perception mode swapping. Two-stage pipeline: compute_visibility_geometry writes to VisibilityGeometry resource, compute_observer_snapshot reads it. Remove KnowledgeGraph from compute_nearby_interactions (simulation phase boundary violation). Verb availability stays in simulation; POI-based priority adjustment moves to observer via apply_poi_verb_priority helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
// Interaction system — proximity detection + multi-verb InteractionOptions
|
|
// Implements #404: server-side verb computation for context-sensitive [E] key
|
|
// 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.
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
|
|
use crate::knowledge::EntityRegistry;
|
|
use crate::npc::Npc;
|
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
|
|
|
/// 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;
|
|
|
|
/// 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.
|
|
///
|
|
/// 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),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
registry: Res<EntityRegistry>,
|
|
interactables: Query<
|
|
(Entity, &TilePosition, Option<&Npc>),
|
|
(With<Interactable>, Without<PlayerCharacter>),
|
|
>,
|
|
) {
|
|
let Ok((player_pos, mut buffer)) = player_query.single_mut() else {
|
|
return;
|
|
};
|
|
buffer.interactions.clear();
|
|
|
|
for (entity, pos, is_npc) 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();
|
|
|
|
match entity_type {
|
|
EntityKind::Npc => {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
EntityKind::Object | EntityKind::Terrain => {
|
|
if is_close {
|
|
verbs.push(VerbOption {
|
|
kind: VerbKind::ExamineObject,
|
|
label: "Examine".into(),
|
|
priority: 1,
|
|
available: true,
|
|
});
|
|
}
|
|
}
|
|
EntityKind::Player => {} // No self-interaction
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[test]
|
|
fn 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 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);
|
|
}
|
|
}
|