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
+89 -46
View File
@@ -1,18 +1,44 @@
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use bevy_ecs::world::World;
/// Helper: set up a test world with player, walkability map, and knowledge resources
/// Helper: set up a test world with resources for the two-stage observer pipeline.
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<EntityRegistry>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world
}
/// Run the two-stage observer pipeline: geometry + snapshot.
fn run_observer_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
compute_visibility_geometry,
compute_observer_snapshot.after(compute_visibility_geometry),
));
schedule.run(world);
}
/// Run the full pipeline including interaction system.
fn run_full_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
crate::simulation::interaction::compute_nearby_interactions,
compute_visibility_geometry,
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions),
));
schedule.run(world);
}
#[test]
fn player_always_visible_in_snapshot() {
let mut world = setup_world(32, 32);
@@ -24,9 +50,7 @@ fn player_always_visible_in_snapshot() {
NearbyInteractionBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
@@ -49,9 +73,7 @@ fn npc_in_los_visible() {
// NPC directly north of player (in forward cone)
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -81,9 +103,7 @@ fn npc_behind_wall_not_visible() {
// NPC behind the wall
world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -109,9 +129,7 @@ fn npc_behind_player_not_visible() {
// NPC far behind player (south, in blind spot)
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -136,9 +154,7 @@ fn different_z_level_not_visible() {
// NPC on different z-level
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -165,9 +181,7 @@ fn game_time_populated() {
NearbyInteractionBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -190,9 +204,7 @@ fn visible_tiles_populated() {
NearbyInteractionBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -233,9 +245,7 @@ fn visible_npc_has_relationship_from_knowledge() {
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -279,9 +289,7 @@ fn remembered_entity_appears_as_ghost() {
world.insert_resource(registry);
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -338,9 +346,7 @@ fn direct_confidence_not_shown_as_remembered() {
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -385,9 +391,7 @@ fn remembered_entity_on_visible_tile_not_shown() {
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -431,9 +435,7 @@ fn remembered_entity_different_z_not_shown() {
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -486,9 +488,7 @@ fn knowledge_without_position_not_shown() {
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -519,9 +519,7 @@ fn multiple_npcs_in_los_all_visible() {
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -558,9 +556,7 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
// NPC 3: also visible
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
@@ -572,3 +568,50 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
.collect();
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
}
#[test]
fn poi_interaction_gets_observe_first_priority() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC in close range, directly north of player and in LOS
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player knows NPC as PersonOfInterest
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
// Run full pipeline: interaction computes default priority,
// then observer applies POI adjustment
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// POI: Observe takes priority over Talk
assert_eq!(interaction.verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(interaction.verbs[0].priority, 1);
assert_eq!(interaction.verbs[1].kind, VerbKind::Talk);
assert_eq!(interaction.verbs[1].priority, 2);
}