Files
settled-reach/server/src/simulation/interaction.rs
T
jpmschweitzerandClaude Opus 4.6 18d8253bfe refactor(server): decompose observer pipeline and fix interaction boundary
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>
2026-02-12 23:32:41 +01:00

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);
}
}