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:
@@ -0,0 +1,244 @@
|
||||
//! Observer visibility query system (#112)
|
||||
//!
|
||||
//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version.
|
||||
//! Combines shadowcasting + vision cone to determine what the observer can see,
|
||||
//! then populates ObserverSnapshot v2 with only visible entities and tiles.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Compute observer snapshot with LOS filtering and vision cone.
|
||||
///
|
||||
/// System ordering: after validate_movement, before advance_tick.
|
||||
/// Replaces bridge::generate_snapshot.
|
||||
pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
walkability: Res<WalkabilityMap>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut observer_query: Query<
|
||||
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((observer_pos, facing_opt, observer_kg, mut interaction_buffer)) =
|
||||
observer_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let facing = facing_opt
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let config = VisionConeConfig::default();
|
||||
let z = observer_pos.z;
|
||||
|
||||
// Step 1: Compute raw FOV using symmetric shadowcasting
|
||||
let fov = compute_fov(
|
||||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||
observer_pos.x,
|
||||
observer_pos.y,
|
||||
config.forward_range,
|
||||
z,
|
||||
);
|
||||
|
||||
// Step 2: Apply vision cone to get sector-tagged tiles
|
||||
let cone_tiles =
|
||||
apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config);
|
||||
|
||||
// Step 3: Build visible_tiles for the snapshot
|
||||
let visible_tiles: Vec<VisibleTile> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| VisibleTile {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
visibility: sector,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Step 4: Build lookup set for fast entity visibility check
|
||||
let visible_positions: HashSet<(i32, i32)> =
|
||||
cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
|
||||
|
||||
// Build sector lookup (position -> sector)
|
||||
let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| ((x, y), sector))
|
||||
.collect();
|
||||
|
||||
// Step 5: Filter entities by visibility, overlay knowledge
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_entity_bits: HashSet<u64> = HashSet::new();
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
// Different z-level: not visible
|
||||
if pos.z != z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not in visible tile set: not visible
|
||||
if !visible_positions.contains(&(pos.x, pos.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = pos.to_render_coords();
|
||||
let kind = if is_player.is_some() {
|
||||
EntityKind::Player
|
||||
} else if is_npc.is_some() {
|
||||
EntityKind::Npc
|
||||
} else {
|
||||
EntityKind::Object
|
||||
};
|
||||
|
||||
let sector = sector_lookup
|
||||
.get(&(pos.x, pos.y))
|
||||
.copied()
|
||||
.unwrap_or(VisibilitySector::Peripheral);
|
||||
|
||||
// Look up relationship from knowledge graph (D-033 entity color)
|
||||
let relationship = if is_player.is_some() {
|
||||
RelationshipState::Known // Self
|
||||
} else if let Some(stable_id) = registry.to_stable(entity) {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
// 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)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity visible but not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
visible_entity_bits.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: wire_id,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind,
|
||||
visibility: sector,
|
||||
relationship,
|
||||
observation: EntityVisibility::Visible,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Add remembered entities from knowledge graph (#366)
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
&visible_entity_bits,
|
||||
&visible_positions,
|
||||
z,
|
||||
time.tick,
|
||||
&mut entities,
|
||||
);
|
||||
|
||||
// Step 7: Build GameTime from SimulationTime
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
time.tick,
|
||||
visible_entity_bits.len(),
|
||||
entities.len() - visible_entity_bits.len(),
|
||||
visible_tiles.len(),
|
||||
);
|
||||
|
||||
// Step 8: Assemble snapshot (v4: added nearby_interactions)
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
nearby_interactions: interaction_buffer.take(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
/// knows about but can't currently see. Filters out: already-visible entities,
|
||||
/// entities without known positions, wrong z-level, visible-tile ghosts, and
|
||||
/// transient Direct-confidence inconsistencies.
|
||||
fn collect_remembered_entities(
|
||||
observer_kg: &KnowledgeGraph,
|
||||
visible_ids: &HashSet<u64>,
|
||||
visible_positions: &HashSet<(i32, i32)>,
|
||||
observer_z: i32,
|
||||
current_tick: u64,
|
||||
entities: &mut Vec<VisibleEntity>,
|
||||
) {
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
if visible_ids.contains(&stable_id.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(position) = knowledge.last_known_position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if position.z != observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tile is visible but entity isn't there — player knows it moved
|
||||
if visible_positions.contains(&(position.x, position.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct confidence = should be in LOS; skip transient inconsistency
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = position.to_render_coords();
|
||||
debug_assert!(
|
||||
knowledge.last_observed_tick <= current_tick,
|
||||
"last_observed_tick {} > current tick {}",
|
||||
knowledge.last_observed_tick,
|
||||
current_tick,
|
||||
);
|
||||
let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: stable_id.0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: knowledge.relationship,
|
||||
observation: EntityVisibility::Remembered {
|
||||
confidence: knowledge.confidence,
|
||||
age_ticks,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user