From 6f9537fff37d27256cfb372b920545d4b6f21cc5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 22:52:47 +0100 Subject: [PATCH] refactor(server): NearbyInteractionBuffer from Resource to Component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/main.rs | 2 + server/src/perception/interpretation.rs | 6 +- server/src/perception/observation.rs | 4 +- server/src/perception/observer.rs | 798 ------------------------ server/src/perception/observer/mod.rs | 244 ++++++++ server/src/simulation/interaction.rs | 133 ++-- server/src/simulation/mod.rs | 1 - server/tests/game_loop.rs | 2 + 8 files changed, 317 insertions(+), 873 deletions(-) delete mode 100644 server/src/perception/observer.rs create mode 100644 server/src/perception/observer/mod.rs diff --git a/server/src/main.rs b/server/src/main.rs index 4f4318359..96991b5e6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,7 @@ use settled_reach_server::npc::{ Want, WantKind, }; use settled_reach_server::perception::vision_cone::Facing; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::path_follow::MovementSpeed; use settled_reach_server::simulation::time::DayPhase; @@ -68,6 +69,7 @@ fn main() { TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 252d7a2ca..1c78ae395 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -199,7 +199,6 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); world } @@ -235,6 +234,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -285,6 +285,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -355,6 +356,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -387,6 +389,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), // Empty — never seen anyone + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -433,6 +436,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 05b63f255..ecfe9bd07 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -101,7 +101,6 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); world } @@ -116,6 +115,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -159,6 +159,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -195,6 +196,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs deleted file mode 100644 index 057475059..000000000 --- a/server/src/perception/observer.rs +++ /dev/null @@ -1,798 +0,0 @@ -//! 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, - walkability: Res, - registry: Res, - mut interaction_buffer: ResMut, - observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With>, - all_entities: Query<( - Entity, - &TilePosition, - Option<&PlayerCharacter>, - Option<&crate::npc::Npc>, - )>, - mut buffer: ResMut, -) { - let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() 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 = 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 = 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 - }; - - 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, - visible_positions: &HashSet<(i32, i32)>, - observer_z: i32, - current_tick: u64, - entities: &mut Vec, -) { - 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 { - use super::*; - use crate::knowledge::{EntityRegistry, KnowledgeGraph}; - use crate::perception::vision_cone::Facing; - use bevy_ecs::world::World; - - /// Helper: set up a test world with player, walkability map, and knowledge resources - 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::(); - world.init_resource::(); - world.init_resource::(); - world - } - - #[test] - fn player_always_visible_in_snapshot() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 4); - assert_eq!(snapshot.entities.len(), 1); - assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); - assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); - } - - #[test] - fn npc_in_los_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.entities.len(), 2); - let npc = snapshot - .entities - .iter() - .find(|e| matches!(e.kind, EntityKind::Npc)) - .expect("NPC should be visible"); - assert_eq!(npc.visibility, VisibilitySector::Forward); - assert_eq!(npc.observation, EntityVisibility::Visible); - } - - #[test] - fn npc_behind_wall_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // Wall between player and NPC - let mut walkability = world.resource_mut::(); - walkability.set_walkable(&TilePosition::new(16, 14, 0), false); - // 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Only player should be visible, not the NPC behind the wall - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC behind wall should not be visible"); - } - - #[test] - fn npc_behind_player_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC in blind spot should not be visible"); - } - - #[test] - fn different_z_level_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - // 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC on different z should not be visible"); - } - - #[test] - fn game_time_populated() { - let mut world = setup_world(32, 32); - let mut time = SimulationTime::default(); - time.tick = 7200; // 720 minutes = Evening - time.tick_rate = crate::simulation::time::TickRate::Paused; - world.insert_resource(time); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.game_time.time_of_day, 720); - assert_eq!( - snapshot.game_time.day_phase, - crate::simulation::time::DayPhase::Evening - ); - assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); - } - - #[test] - fn visible_tiles_populated() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert!( - !snapshot.visible_tiles.is_empty(), - "should have visible tiles" - ); - // Observer's tile should be in the list - let has_observer_tile = snapshot - .visible_tiles - .iter() - .any(|t| t.x == 16 && t.y == 16 && t.z == 0); - assert!(has_observer_tile, "observer tile should be visible"); - } - - #[test] - fn visible_npc_has_relationship_from_knowledge() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player knows NPC is hostile - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); - kg.set_relationship(&npc_sid, RelationshipState::Hostile); - - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )); - - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npc_entity = snapshot - .entities - .iter() - .find(|e| matches!(e.kind, EntityKind::Npc)) - .expect("NPC should be visible"); - assert_eq!(npc_entity.relationship, RelationshipState::Hostile); - assert_eq!(npc_entity.observation, EntityVisibility::Visible); - } - - #[test] - fn remembered_entity_appears_as_ghost() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - // NPC exists far behind the player (not visible) - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player previously saw NPC at (16, 28) — behind the player (south), - // well beyond peripheral range. The tile is NOT in the player's FOV. - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - // Should have player (visible) + NPC (remembered) - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert_eq!(remembered.len(), 1, "should have one remembered entity"); - assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); - - // Remembered entity at last_known_position (16, 28), not actual (16, 30) - assert_eq!(remembered[0].x, 16.5); - assert_eq!(remembered[0].y, 28.5); - - if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { - assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); - assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 - } - } - - #[test] - fn direct_confidence_not_shown_as_remembered() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - // NPC exists but not in LOS - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Knowledge still shows Direct (transient inconsistency) - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); - // Still Direct — don't show as ghost - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - - // Wall blocks actual NPC position - let mut walkability = world.resource_mut::(); - walkability.set_walkable(&TilePosition::new(16, 12, 0), false); - - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "Direct-confidence entities should not appear as remembered ghosts" - ); - } - - #[test] - fn remembered_entity_on_visible_tile_not_shown() { - // If the player can see a tile and the entity isn't there, - // don't show a ghost — the player knows it moved. - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player remembers NPC at (16, 15) — a tile the player can currently see - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "ghost should not appear on a tile the player can currently see" - ); - } - - #[test] - fn remembered_entity_different_z_not_shown() { - // Remembered entity on a different z-level should not appear - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) - .id(); - let npc_sid = registry.register(npc); - - // Player remembers NPC at z=1, but player is at z=0 - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "remembered entity on different z-level should not appear in snapshot" - ); - } - - #[test] - fn knowledge_without_position_not_shown() { - // Entity known via gossip (no last_known_position) should not appear - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player knows about NPC but has never seen it (no position) - let mut kg = KnowledgeGraph::new(); - // Insert knowledge manually without a position - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: None, - last_observed_tick: 0, - last_updated_tick: 50, - confidence: KnowledgeConfidence::KnowsOf, - source: crate::knowledge::KnowledgeSource::Background, - state: crate::knowledge::KnowledgeState::Active, - relationship: RelationshipState::PersonOfInterest, - known_attributes: std::collections::BTreeMap::new(), - }); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "entity without last_known_position should not appear as ghost" - ); - } - - #[test] - fn multiple_npcs_in_los_all_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // Three NPCs in front of player, no walls - world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); - 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Player + 3 NPCs = 4 entities - assert_eq!(snapshot.entities.len(), 4); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert_eq!(npcs.len(), 3); - assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); - } - - #[test] - fn npc_behind_wall_excluded_from_multi_entity_snapshot() { - let mut world = setup_world(32, 32); - // Wall at (16,14) - world - .resource_mut::() - .set_walkable(&TilePosition::new(16, 14, 0), false); - - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // NPC 1: behind wall (should be hidden) - world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0))); - // NPC 2: to the side, no wall (should be visible) - world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); - // 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); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Player + 2 visible NPCs = 3 (NPC behind wall excluded) - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); - } -} diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs new file mode 100644 index 000000000..1d1796b39 --- /dev/null +++ b/server/src/perception/observer/mod.rs @@ -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, + walkability: Res, + registry: Res, + mut observer_query: Query< + (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer), + With, + >, + all_entities: Query<( + Entity, + &TilePosition, + Option<&PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, + mut buffer: ResMut, +) { + 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 = 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 = 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, + visible_positions: &HashSet<(i32, i32)>, + observer_z: i32, + current_tick: u64, + entities: &mut Vec, +) { + 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; diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index c9b2c12ba..d931da5fe 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -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>, + mut player_query: Query< + (&TilePosition, &KnowledgeGraph, &mut NearbyInteractionBuffer), + With, + >, registry: Res, interactables: Query< (Entity, &TilePosition, Option<&Npc>), (With, Without), >, - mut buffer: ResMut, ) { - 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, } @@ -180,28 +184,40 @@ mod tests { fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); - world.init_resource::(); 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>(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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); } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 086365d82..5805cd3ec 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -23,7 +23,6 @@ impl Plugin for SimulationPlugin { app.init_resource::() .insert_resource(rng::SimRng::new(0)) .init_resource::() - .init_resource::() .init_resource::() .add_systems( Update, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index a8bf8b52f..76e634e36 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -7,6 +7,7 @@ use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::SimulationPlugin; use std::io::{BufReader, BufWriter}; @@ -34,6 +35,7 @@ fn player_moves_north_through_full_pipeline() { PlayerCharacter, TilePosition::new(16, 16, 0), KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), )); // Run one tick: receive input, process, validate movement, generate snapshot, send