feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6
Implements the core Sprint 6: Touch systems across 5 tickets: - #449 ObserverSnapshot v6: add player_stance (MovementStance) and player_inventory (Vec<InventoryItem>) wire fields with serde defaults for backward compatibility. Bump PROTOCOL_VERSION 5→6. - #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers, and PlayerMoveCooldown component. ToggleStanceUp/Down player actions. - #420 TilePresence: posture-layer collision system allowing same-tile occupancy for different layers (Standing/Prone/Seated/Fixture). Layer-based collision in validate_movement. - #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/ Furniture types with Phase 1 verb sets computed from type + proximity. - #422 Phase 2 verb filter: KG-gated observer-side verb processing — POI priority flips, Confront injection at KnowsDetails+, contradiction marking, archetype-specific label relabeling (Smuggler/Detective). 217 unit tests + 17 integration tests passing. All MessagePack fixtures regenerated for v6 wire format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
// 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, NearbyInteraction, VerbKind, VerbOption};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
@@ -22,12 +27,70 @@ pub(crate) const MID_RANGE: u32 = 5;
|
||||
#[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
|
||||
/// 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)]
|
||||
@@ -38,7 +101,7 @@ pub fn compute_nearby_interactions(
|
||||
>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interactables: Query<
|
||||
(Entity, &TilePosition, Option<&Npc>),
|
||||
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
) {
|
||||
@@ -47,7 +110,7 @@ pub fn compute_nearby_interactions(
|
||||
};
|
||||
buffer.interactions.clear();
|
||||
|
||||
for (entity, pos, is_npc) in interactables.iter() {
|
||||
for (entity, pos, is_npc, object_type) in interactables.iter() {
|
||||
let Some(distance) = player_pos.manhattan_distance(pos) else {
|
||||
continue; // Different z-level
|
||||
};
|
||||
@@ -65,44 +128,55 @@ pub fn compute_nearby_interactions(
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
EntityKind::Object | EntityKind::Terrain => {
|
||||
if is_close {
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineObject,
|
||||
label: "Examine".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,
|
||||
});
|
||||
}
|
||||
EntityKind::Player => {} // No self-interaction
|
||||
}
|
||||
|
||||
if verbs.is_empty() {
|
||||
@@ -128,6 +202,8 @@ pub fn compute_nearby_interactions(
|
||||
entity_type,
|
||||
distance,
|
||||
verbs,
|
||||
object_type: object_type.copied(),
|
||||
contradicted: false, // Phase 2 sets this from KG
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +261,10 @@ mod tests {
|
||||
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();
|
||||
@@ -235,8 +315,186 @@ mod tests {
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ObjectType verb tests (#421)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
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));
|
||||
@@ -251,6 +509,25 @@ mod tests {
|
||||
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();
|
||||
@@ -310,4 +587,235 @@ mod tests {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user