// 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) // 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::*; // 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::npc::Npc; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::stance::Stance; /// 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). #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( mut player_query: Query< (&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>), With, >, registry: Res, interactables: Query< (Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>), (With, Without), >, ) { 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 — unchanged from #404 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, }); } } 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, } 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() } // ----------------------------------------------------------------------- // NPC verb tests (unchanged from #404) // ----------------------------------------------------------------------- #[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()); } // ----------------------------------------------------------------------- // 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), NOT Terminal verbs (Use + Observe) assert_eq!(buffer.interactions[0].verbs.len(), 2); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); } /// 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"); } }