// 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, >, registry: Res, interactables: Query< (Entity, &TilePosition, Option<&Npc>), (With, Without), >, ) { 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, } 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 { 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::(); 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>(); 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); } }