refactor(server): decompose observer pipeline and fix interaction boundary
Extract visibility geometry into a separate system behind a PerceptionQuery trait, enabling D-017 perception mode swapping. Two-stage pipeline: compute_visibility_geometry writes to VisibilityGeometry resource, compute_observer_snapshot reads it. Remove KnowledgeGraph from compute_nearby_interactions (simulation phase boundary violation). Verb availability stays in simulation; POI-based priority adjustment moves to observer via apply_poi_verb_priority helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,14 @@
|
||||
// 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::types::RelationshipState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
|
||||
@@ -23,13 +26,14 @@ pub struct Interactable;
|
||||
/// For each entity in range, determines available verbs sorted by priority.
|
||||
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
|
||||
///
|
||||
/// NOTE: Checks proximity only, not line-of-sight. The client filters
|
||||
/// interaction prompts against visible entities. Server-side LOS filtering
|
||||
/// is deferred until the interaction system can read the observer's visible set.
|
||||
/// 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, &KnowledgeGraph, &mut NearbyInteractionBuffer),
|
||||
(&TilePosition, &mut NearbyInteractionBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
registry: Res<EntityRegistry>,
|
||||
@@ -38,7 +42,7 @@ pub fn compute_nearby_interactions(
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
) {
|
||||
let Ok((player_pos, knowledge, mut buffer)) = player_query.single_mut() else {
|
||||
let Ok((player_pos, mut buffer)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
buffer.interactions.clear();
|
||||
@@ -58,50 +62,26 @@ pub fn compute_nearby_interactions(
|
||||
EntityKind::Object
|
||||
};
|
||||
|
||||
// Look up relationship state from knowledge graph
|
||||
let relationship = if let Some(stable_id) = registry.to_stable(entity) {
|
||||
knowledge.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
let is_poi = relationship == RelationshipState::PersonOfInterest;
|
||||
let is_close = distance <= CLOSE_RANGE;
|
||||
|
||||
let mut verbs = Vec::new();
|
||||
|
||||
match entity_type {
|
||||
EntityKind::Npc => {
|
||||
if is_close {
|
||||
if is_poi {
|
||||
// Post-contradiction: Observe takes priority over Talk
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
} else {
|
||||
// Default: Talk takes priority
|
||||
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,
|
||||
});
|
||||
}
|
||||
// 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 {
|
||||
@@ -187,13 +167,13 @@ mod tests {
|
||||
world
|
||||
}
|
||||
|
||||
/// Spawn player with standard components + NearbyInteractionBuffer
|
||||
/// 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),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id()
|
||||
@@ -219,7 +199,9 @@ mod tests {
|
||||
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]
|
||||
@@ -236,6 +218,7 @@ mod tests {
|
||||
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]
|
||||
@@ -252,38 +235,6 @@ mod tests {
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_npc_observe_takes_priority() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(5, 6, 0), Interactable))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
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[0].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
let mut world = setup_world();
|
||||
@@ -344,39 +295,6 @@ mod tests {
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_npc_at_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(5, 9, 0), Interactable))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
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 equidistant_npcs_sorted_deterministically() {
|
||||
let mut world = setup_world();
|
||||
|
||||
@@ -31,12 +31,9 @@ impl Plugin for SimulationPlugin {
|
||||
pathfinding::compute_paths.after(input::process_player_input),
|
||||
path_follow::follow_paths.after(pathfinding::compute_paths),
|
||||
movement::validate_movement.after(path_follow::follow_paths),
|
||||
interaction::compute_nearby_interactions
|
||||
.after(movement::validate_movement),
|
||||
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
||||
time::advance_tick
|
||||
.after(path_follow::cleanup_path_blocked)
|
||||
.after(interaction::compute_nearby_interactions),
|
||||
.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user