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:
2026-02-14 15:38:59 +01:00
co-authored by Claude Opus 4.6
parent 651b1d34a6
commit 98f4cedc03
18 changed files with 1825 additions and 95 deletions
+547 -39
View File
@@ -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()
);
}
}
}