refactor(server): NearbyInteractionBuffer from Resource to Component
Per-entity Component attached to PlayerCharacter instead of global Resource. Makes the interaction buffer multiplayer-ready (D-009) — each observer gets their own buffer without pipeline refactoring. Updated all 8 files touching the buffer: system signatures, player spawn bundles, and ~30 test spawn sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,19 +28,20 @@ pub struct Interactable;
|
||||
/// is deferred until the interaction system can read the observer's visible set.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn compute_nearby_interactions(
|
||||
player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
mut player_query: Query<
|
||||
(&TilePosition, &KnowledgeGraph, &mut NearbyInteractionBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interactables: Query<
|
||||
(Entity, &TilePosition, Option<&Npc>),
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut buffer: ResMut<NearbyInteractionBuffer>,
|
||||
) {
|
||||
buffer.interactions.clear();
|
||||
|
||||
let Ok((player_pos, knowledge)) = player_query.single() else {
|
||||
let Ok((player_pos, knowledge, mut buffer)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
buffer.interactions.clear();
|
||||
|
||||
for (entity, pos, is_npc) in interactables.iter() {
|
||||
let Some(distance) = player_pos.manhattan_distance(pos) else {
|
||||
@@ -131,6 +132,9 @@ pub fn compute_nearby_interactions(
|
||||
// Sort by priority (lower = higher), then by kind discriminant for stability
|
||||
verbs.sort_by_key(|v| (v.priority, v.kind as u8));
|
||||
|
||||
// Fallback to Entity::to_bits() is intentional for per-frame systems:
|
||||
// panicking would crash the server every tick. The error log makes this
|
||||
// loud enough to catch in testing while keeping the server alive.
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
@@ -156,9 +160,9 @@ pub fn compute_nearby_interactions(
|
||||
/// Buffer for nearby interaction results, consumed by snapshot generation.
|
||||
/// Field is private — use `take()` to drain results into the snapshot.
|
||||
///
|
||||
/// Global Resource — single-observer assumption (v0.1). D-009 multiplayer
|
||||
/// will refactor the entire observer + interaction pipeline to per-entity.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
/// Per-entity Component attached to the PlayerCharacter. Each observer gets
|
||||
/// their own interaction buffer, so D-009 multiplayer works without refactoring.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct NearbyInteractionBuffer {
|
||||
interactions: Vec<NearbyInteraction>,
|
||||
}
|
||||
@@ -180,28 +184,40 @@ mod tests {
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
/// Spawn player with standard components + NearbyInteractionBuffer
|
||||
fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity {
|
||||
world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(x, y, 0),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// Read the player's NearbyInteractionBuffer component
|
||||
fn read_buffer(world: &mut World) -> &NearbyInteractionBuffer {
|
||||
let mut query = world.query_filtered::<&NearbyInteractionBuffer, With<PlayerCharacter>>();
|
||||
query.single(world).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_close_range_gets_talk_and_observe() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
// Talk should be priority 1 (default, not POI)
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
}
|
||||
@@ -209,19 +225,14 @@ mod tests {
|
||||
#[test]
|
||||
fn npc_in_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 4 (mid range, beyond close)
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, 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 = world.resource::<NearbyInteractionBuffer>();
|
||||
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);
|
||||
@@ -230,19 +241,14 @@ mod tests {
|
||||
#[test]
|
||||
fn npc_out_of_range_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 8 (beyond mid range)
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
@@ -260,16 +266,20 @@ mod tests {
|
||||
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));
|
||||
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 = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
// Observe should be priority 1 for POI NPC
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk);
|
||||
}
|
||||
@@ -277,19 +287,14 @@ mod tests {
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Object (no Npc component) at close range
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
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::ExamineObject);
|
||||
@@ -298,39 +303,29 @@ mod tests {
|
||||
#[test]
|
||||
fn different_z_level_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_entities_sorted_by_distance() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Farther NPC
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
|
||||
// Closer NPC
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
assert!(buffer.interactions[0].distance < buffer.interactions[1].distance);
|
||||
}
|
||||
@@ -338,25 +333,19 @@ mod tests {
|
||||
#[test]
|
||||
fn non_interactable_entity_ignored() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC without Interactable component
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_npc_at_mid_range_gets_observe_only() {
|
||||
// POI priority flip only applies at close range — mid range always Observe-only
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
@@ -369,28 +358,29 @@ mod tests {
|
||||
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));
|
||||
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 = world.resource::<NearbyInteractionBuffer>();
|
||||
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();
|
||||
// Two NPCs at equal distance (1 tile each)
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable));
|
||||
world.spawn((Npc, TilePosition::new(4, 5, 0), Interactable));
|
||||
|
||||
@@ -398,9 +388,8 @@ mod tests {
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
// Both at distance 1 — order should be stable across runs
|
||||
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ impl Plugin for SimulationPlugin {
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<interaction::NearbyInteractionBuffer>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.add_systems(
|
||||
Update,
|
||||
|
||||
Reference in New Issue
Block a user