feat(simulation): add proximity detection and interaction verbs

Implement compute_nearby_interactions system that detects entities
within close (≤2) and mid (≤5) Manhattan distance, computes
available verbs per D-060 spec. NPCs get Talk+Observe at close
range, Observe-only at mid range; PersonOfInterest flips priority.
Objects get Examine. Results populate nearby_interactions[] on
ObserverSnapshot v4. Bump protocol version 3→4. Implements #404.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 19:48:55 +01:00
co-authored by Claude Opus 4.6
parent 5afea99622
commit 2de413dd87
12 changed files with 438 additions and 29 deletions
+332
View File
@@ -0,0 +1,332 @@
// 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
use bevy_ecs::prelude::*;
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::types::RelationshipState;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
/// Interaction range thresholds (Manhattan distance, same z-level)
pub const CLOSE_RANGE: u32 = 2;
pub 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 visible entity in range, determines available verbs sorted by priority.
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
#[allow(clippy::type_complexity)]
pub fn compute_nearby_interactions(
player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
interactables: Query<
(Entity, &TilePosition, Option<&Npc>),
(With<Interactable>, Without<PlayerCharacter>),
>,
mut buffer: ResMut<NearbyInteractionBuffer>,
) {
buffer.interactions.clear();
let Ok((player_pos, knowledge)) = player_query.single() else {
return;
};
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
};
// Look up relationship state from knowledge graph
let relationship = if let Some(stable_id) = registry.to_stable(entity) {
knowledge.relationship_with(&stable_id)
} else {
RelationshipState::Unknown
};
let is_poi = relationship == RelationshipState::PersonOfInterest;
let is_close = distance <= CLOSE_RANGE;
let mut verbs = Vec::new();
match entity_type {
EntityKind::Npc => {
if is_close {
if is_poi {
// Post-contradiction: Observe takes priority over Talk
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 1,
available: true,
});
verbs.push(VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 2,
available: true,
});
} else {
// Default: Talk takes priority
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 number = higher priority)
verbs.sort_by_key(|v| v.priority);
buffer.interactions.push(NearbyInteraction {
entity_id: entity.to_bits(),
entity_type,
distance: distance as f32,
verbs,
});
}
// Sort interactions by distance (nearest first)
buffer
.interactions
.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
}
/// Buffer for nearby interaction results, consumed by snapshot generation
#[derive(Resource, Debug, Default)]
pub struct NearbyInteractionBuffer {
pub interactions: Vec<NearbyInteraction>,
}
#[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.init_resource::<NearbyInteractionBuffer>();
world
}
#[test]
fn npc_in_close_range_gets_talk_and_observe() {
let mut world = setup_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
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 = world.resource::<NearbyInteractionBuffer>();
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
// Talk should be priority 1 (default, not POI)
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
}
#[test]
fn npc_in_mid_range_gets_observe_only() {
let mut world = setup_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// Distance 4 (mid range, beyond close)
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 = world.resource::<NearbyInteractionBuffer>();
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
}
#[test]
fn npc_out_of_range_no_interactions() {
let mut world = setup_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// Distance 8 (beyond mid range)
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 = world.resource::<NearbyInteractionBuffer>();
assert!(buffer.interactions.is_empty());
}
#[test]
fn poi_npc_observe_takes_priority() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((Npc, TilePosition::new(5, 6, 0), Interactable))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = world.resource::<NearbyInteractionBuffer>();
assert_eq!(buffer.interactions.len(), 1);
// Observe should be priority 1 for POI NPC
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk);
}
#[test]
fn object_in_close_range_gets_examine() {
let mut world = setup_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// Object (no Npc component) at close range
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 = world.resource::<NearbyInteractionBuffer>();
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();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
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 = world.resource::<NearbyInteractionBuffer>();
assert!(buffer.interactions.is_empty());
}
#[test]
fn multiple_entities_sorted_by_distance() {
let mut world = setup_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// Farther NPC
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
// Closer NPC
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 = world.resource::<NearbyInteractionBuffer>();
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();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
// NPC without Interactable component
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 = world.resource::<NearbyInteractionBuffer>();
assert!(buffer.interactions.is_empty());
}
}
+8 -1
View File
@@ -5,6 +5,7 @@ use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod interaction;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
@@ -22,6 +23,8 @@ impl Plugin for SimulationPlugin {
app.init_resource::<time::SimulationTime>()
.insert_resource(rng::SimRng::new(0))
.init_resource::<input::InputQueue>()
.init_resource::<interaction::NearbyInteractionBuffer>()
.init_resource::<crate::knowledge::EntityRegistry>()
.add_systems(
Update,
(
@@ -29,8 +32,12 @@ impl Plugin for SimulationPlugin {
pathfinding::compute_paths.after(input::process_player_input),
path_follow::follow_paths.after(pathfinding::compute_paths),
movement::validate_movement.after(path_follow::follow_paths),
interaction::compute_nearby_interactions
.after(movement::validate_movement),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
time::advance_tick.after(path_follow::cleanup_path_blocked),
time::advance_tick
.after(path_follow::cleanup_path_blocked)
.after(interaction::compute_nearby_interactions),
),
);