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:
@@ -10,12 +10,14 @@ use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::monologue::MonologueBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Compute visibility geometry using the active perception mode.
|
||||
@@ -50,7 +52,7 @@ pub fn compute_observer_snapshot(
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut observer_query: Query<
|
||||
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer),
|
||||
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_entities: Query<(
|
||||
@@ -61,7 +63,7 @@ pub fn compute_observer_snapshot(
|
||||
)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer)) =
|
||||
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) =
|
||||
observer_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
@@ -71,6 +73,8 @@ pub fn compute_observer_snapshot(
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
let (mut entities, visible_ids) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
|
||||
@@ -90,9 +94,9 @@ pub fn compute_observer_snapshot(
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
// Take interactions and adjust POI verb priority (D-060)
|
||||
// Take interactions and apply Phase 2 verb filter (D-057, #422)
|
||||
let mut nearby_interactions = interaction_buffer.take();
|
||||
apply_poi_verb_priority(&mut nearby_interactions, observer_kg);
|
||||
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
@@ -109,6 +113,8 @@ pub fn compute_observer_snapshot(
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
|
||||
player_inventory: Vec::new(),
|
||||
entities,
|
||||
visible_tiles: geometry.visible_tiles.clone(),
|
||||
nearby_interactions,
|
||||
@@ -250,26 +256,110 @@ fn collect_remembered_entities(
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust verb priority for PersonOfInterest NPCs (D-060).
|
||||
/// Moves ExamineNpc to priority 1 and Talk to priority 2 when the observer
|
||||
/// knows the entity as POI. Called after interaction buffer is taken.
|
||||
fn apply_poi_verb_priority(
|
||||
/// Phase 2 verb filter: KG-gated observer-side verb processing (#422, D-057).
|
||||
///
|
||||
/// Runs after Phase 1 (simulation-level verb computation) and applies:
|
||||
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
|
||||
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
|
||||
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
|
||||
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
|
||||
///
|
||||
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
|
||||
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
|
||||
/// to filter, augment, and relabel. This separation keeps D-010 principle 1
|
||||
/// (info boundary) clean — simulation doesn't know what the observer knows.
|
||||
fn apply_phase2_verb_filter(
|
||||
interactions: &mut [NearbyInteraction],
|
||||
observer_kg: &KnowledgeGraph,
|
||||
archetype: CharacterArchetype,
|
||||
) {
|
||||
for interaction in interactions.iter_mut() {
|
||||
let stable_id = StableId(interaction.entity_id);
|
||||
let relationship = observer_kg.relationship_with(&stable_id);
|
||||
if relationship == RelationshipState::PersonOfInterest {
|
||||
for verb in &mut interaction.verbs {
|
||||
match verb.kind {
|
||||
VerbKind::ExamineNpc => verb.priority = 1,
|
||||
VerbKind::Talk => verb.priority = 2,
|
||||
_ => {}
|
||||
let knowledge = observer_kg.entity_knowledge(&stable_id);
|
||||
|
||||
// --- Contradiction marking ---
|
||||
// If observer's knowledge of this entity is Contradicted, mark the
|
||||
// interaction. Client renders a visual indicator (D-041).
|
||||
if let Some(k) = knowledge {
|
||||
if k.state == KnowledgeState::Contradicted {
|
||||
interaction.contradicted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- NPC-specific Phase 2 ---
|
||||
if interaction.entity_type == EntityKind::Npc {
|
||||
let relationship = observer_kg.relationship_with(&stable_id);
|
||||
|
||||
// POI priority flip (D-060): Observe first, Talk second
|
||||
if relationship == RelationshipState::PersonOfInterest {
|
||||
for verb in &mut interaction.verbs {
|
||||
match verb.kind {
|
||||
VerbKind::ExamineNpc => verb.priority = 1,
|
||||
VerbKind::Talk => verb.priority = 2,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confront injection: available when observer has KnowsDetails+
|
||||
// on this NPC and is at close range (distance ≤ 2).
|
||||
if interaction.distance <= 2 {
|
||||
let has_details = knowledge
|
||||
.map(|k| k.confidence >= KnowledgeConfidence::KnowsDetails)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_details {
|
||||
// Priority 3 = after Talk/ExamineNpc in normal case,
|
||||
// after ExamineNpc/Talk in POI case. Always the escalation option.
|
||||
interaction.verbs.push(VerbOption {
|
||||
kind: VerbKind::Confront,
|
||||
label: "Confront".into(),
|
||||
priority: 3,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8));
|
||||
}
|
||||
|
||||
// --- Archetype label relabeling ---
|
||||
// Phase 2 swaps verb labels based on character archetype.
|
||||
// The VerbKind stays the same (same handler), only the display label changes.
|
||||
// This implements D-057: "Character differentiation via Phase 2 observer
|
||||
// filter, not separate verb systems."
|
||||
for verb in &mut interaction.verbs {
|
||||
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) {
|
||||
verb.label = label.into();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort after priority changes and verb additions
|
||||
interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8));
|
||||
}
|
||||
}
|
||||
|
||||
/// Archetype-specific verb label overrides (#422, D-057).
|
||||
///
|
||||
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
|
||||
/// combination, or None to keep the Phase 1 default label.
|
||||
///
|
||||
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
|
||||
/// Add match arms here for future archetype-specific labels.
|
||||
fn archetype_verb_label(
|
||||
archetype: CharacterArchetype,
|
||||
object_type: Option<ObjectType>,
|
||||
kind: VerbKind,
|
||||
) -> Option<&'static str> {
|
||||
match (archetype, object_type, kind) {
|
||||
// Smuggler: Container verbs — physical manipulation vocabulary
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => Some("Stash"),
|
||||
|
||||
// Detective: Container verbs — investigation vocabulary
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => Some("Scan"),
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => Some("Flag"),
|
||||
|
||||
// All other combinations: keep Phase 1 default label
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
@@ -56,7 +57,7 @@ fn player_always_visible_in_snapshot() {
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, 5);
|
||||
assert_eq!(snapshot.version, 6);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
@@ -632,3 +633,54 @@ fn poi_interaction_gets_observe_first_priority() {
|
||||
assert_eq!(interaction.verbs[1].kind, VerbKind::Talk);
|
||||
assert_eq!(interaction.verbs[1].priority, 2);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// v6 field tests (Hoshe QA, Sprint 6 — #449)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn snapshot_v6_fields_default_through_pipeline() {
|
||||
// Until #417 (stance) and #424 (inventory) wire up the components,
|
||||
// the observer system should produce Walk stance and empty inventory.
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
assert_eq!(snapshot.version, 6, "should be protocol v6");
|
||||
assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk");
|
||||
assert!(snapshot.player_inventory.is_empty(), "default inventory is empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_v6_version_is_protocol_version() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.version,
|
||||
crate::bridge::types::PROTOCOL_VERSION,
|
||||
"snapshot version must match PROTOCOL_VERSION constant"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user