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:
2026-02-12 23:32:41 +01:00
co-authored by Claude Opus 4.6
parent e6bd577a5a
commit 18d8253bfe
9 changed files with 380 additions and 262 deletions
+30 -112
View File
@@ -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();
+1 -4
View File
@@ -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),
),
);