diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 56e20a558..e47532916 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -145,18 +145,25 @@ impl Plugin for BridgePlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( - receive_bridge_inputs.before(crate::simulation::input::process_player_input), + receive_bridge_inputs + .before(crate::simulation::input::process_player_input), + crate::perception::observer::compute_visibility_geometry + .after(crate::simulation::movement::validate_movement), + crate::simulation::interaction::compute_nearby_interactions + .after(crate::simulation::movement::validate_movement), crate::perception::observer::compute_observer_snapshot - .after(crate::simulation::movement::validate_movement) + .after(crate::perception::observer::compute_visibility_geometry) + .after(crate::simulation::interaction::compute_nearby_interactions) .before(crate::simulation::time::advance_tick), crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), send_bridge_snapshot - .after(crate::perception::observer::compute_observer_snapshot) - .after(crate::simulation::interaction::compute_nearby_interactions), + .after(crate::perception::observer::compute_observer_snapshot), ), ); tracing::debug!("BridgePlugin initialized"); diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 1c78ae395..338db3c9b 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -186,7 +186,8 @@ mod tests { use super::*; use crate::knowledge::registry::EntityRegistry; use crate::npc::RoutineEntry; - use crate::perception::observer::compute_observer_snapshot; + use crate::perception::observer::{compute_observer_snapshot, compute_visibility_geometry}; + use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::movement::WalkabilityMap; use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE}; @@ -199,16 +200,18 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world } - /// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update. - /// Interpretation runs BEFORE knowledge updates so it can detect new entities - /// and compare against the PREVIOUS tick's knowledge state. + /// Run the observation pipeline: geometry -> snapshot -> emit -> interpret -> knowledge. fn run_pipeline(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( - compute_observer_snapshot, + compute_visibility_geometry, + compute_observer_snapshot + .after(compute_visibility_geometry), crate::perception::observation::emit_observation_events .after(compute_observer_snapshot), generate_observation_events diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 38ae3839d..472b86ae1 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -8,6 +8,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod interpretation; pub mod observation; pub mod observer; +pub mod query; pub mod shadowcast; pub mod vision_cone; @@ -18,6 +19,8 @@ pub struct PerceptionPlugin; impl Plugin for PerceptionPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, interpretation::generate_observation_events diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index ecfe9bd07..dff298646 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -90,7 +90,8 @@ mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::Npc; - use crate::perception::observer::compute_observer_snapshot; + use crate::perception::observer::{compute_observer_snapshot, compute_visibility_geometry}; + use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::movement::WalkabilityMap; @@ -101,6 +102,8 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world } @@ -129,7 +132,11 @@ mod tests { // First: compute snapshot so NPC is visible let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::(); @@ -171,7 +178,11 @@ mod tests { walkability.set_walkable(&TilePosition::new(16, 15, 0), false); let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::(); @@ -203,7 +214,11 @@ mod tests { world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::(); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 1d1796b39..ad5eb1716 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -1,27 +1,52 @@ //! 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. +//! Two-stage pipeline: +//! 1. compute_visibility_geometry — FOV + vision cone → VisibilityGeometry resource +//! 2. compute_observer_snapshot — entity filtering + knowledge overlay → ObserverSnapshot +//! +//! D-017 perception modes swap the geometry producer via PerceptionQuery trait. 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::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; +use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; +use crate::perception::vision_cone::Facing; 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. +/// Compute visibility geometry using the active perception mode. +/// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry. /// -/// System ordering: after validate_movement, before advance_tick. -/// Replaces bridge::generate_snapshot. +/// System ordering: after validate_movement, before compute_observer_snapshot. +pub fn compute_visibility_geometry( + walkability: Res, + mode: Res, + observer_query: Query<(&TilePosition, Option<&Facing>), With>, + mut geometry: ResMut, +) { + let Ok((observer_pos, facing_opt)) = observer_query.single() else { + return; + }; + + let facing = facing_opt + .map(|f| f.0) + .unwrap_or(FacingDirection::default()); + + *geometry = mode.0.compute_geometry(observer_pos, facing, &walkability); +} + +/// Assemble observer snapshot from precomputed geometry and entity state. +/// Stage 2 of the observer pipeline: entity filtering + knowledge overlay → snapshot. +/// +/// System ordering: after compute_visibility_geometry + compute_nearby_interactions, +/// before advance_tick. +#[allow(clippy::type_complexity)] pub fn compute_observer_snapshot( time: Res, - walkability: Res, + geometry: Res, registry: Res, mut observer_query: Query< (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer), @@ -35,7 +60,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut, ) { - let Ok((observer_pos, facing_opt, observer_kg, mut interaction_buffer)) = + let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer)) = observer_query.single_mut() else { return; @@ -45,54 +70,70 @@ pub fn compute_observer_snapshot( .map(|f| f.0) .unwrap_or(FacingDirection::default()); - let config = VisionConeConfig::default(); - let z = observer_pos.z; + let (mut entities, visible_ids) = + filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); - // 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, + collect_remembered_entities( + observer_kg, + &visible_ids, + &geometry.visible_positions, + geometry.observer_z, + time.tick, + &mut entities, ); - // 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); + 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, + }; - // 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(); + // Take interactions and adjust POI verb priority (D-060) + let mut nearby_interactions = interaction_buffer.take(); + apply_poi_verb_priority(&mut nearby_interactions, observer_kg); - // 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(); + tracing::trace!( + "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", + time.tick, + visible_ids.len(), + entities.len() - visible_ids.len(), + geometry.visible_tiles.len(), + ); - // 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(); + buffer.snapshot = Some(ObserverSnapshot { + version: crate::bridge::types::PROTOCOL_VERSION, + tick: time.tick, + game_time, + player_facing: facing, + entities, + visible_tiles: geometry.visible_tiles.clone(), + nearby_interactions, + }); +} - // Step 5: Filter entities by visibility, overlay knowledge +/// Filter entities by visibility using precomputed geometry. +/// Returns (visible entities, set of visible wire IDs). +#[allow(clippy::type_complexity)] +fn filter_visible_entities( + geometry: &VisibilityGeometry, + registry: &EntityRegistry, + observer_kg: &KnowledgeGraph, + all_entities: &Query<( + Entity, + &TilePosition, + Option<&PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, +) -> (Vec, HashSet) { let mut entities = Vec::new(); - let mut visible_entity_bits: HashSet = HashSet::new(); + let mut visible_ids: HashSet = HashSet::new(); + for (entity, pos, is_player, is_npc) in all_entities.iter() { - // Different z-level: not visible - if pos.z != z { + if pos.z != geometry.observer_z { continue; } - - // Not in visible tile set: not visible - if !visible_positions.contains(&(pos.x, pos.y)) { + if !geometry.visible_positions.contains(&(pos.x, pos.y)) { continue; } @@ -105,12 +146,12 @@ pub fn compute_observer_snapshot( EntityKind::Object }; - let sector = sector_lookup + let sector = geometry + .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) { @@ -129,7 +170,7 @@ pub fn compute_observer_snapshot( tracing::error!(?entity, "entity visible but not in EntityRegistry"); entity.to_bits() }); - visible_entity_bits.insert(wire_id); + visible_ids.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, x: rx, @@ -142,42 +183,7 @@ pub fn compute_observer_snapshot( }); } - // 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(), - }); + (entities, visible_ids) } /// Collect remembered entities from the knowledge graph — entities the observer @@ -240,5 +246,28 @@ fn collect_remembered_entities( } } +/// Adjust verb priority for PersonOfInterest NPCs (D-060). +/// Moves ExamineNpc to priority 1 and Talk to priority 2 when the observer +/// knows the entity as POI. Called after interaction buffer is taken. +fn apply_poi_verb_priority( + interactions: &mut [NearbyInteraction], + observer_kg: &KnowledgeGraph, +) { + for interaction in interactions.iter_mut() { + let stable_id = StableId(interaction.entity_id); + let relationship = observer_kg.relationship_with(&stable_id); + if relationship == RelationshipState::PersonOfInterest { + for verb in &mut interaction.verbs { + match verb.kind { + VerbKind::ExamineNpc => verb.priority = 1, + VerbKind::Talk => verb.priority = 2, + _ => {} + } + } + interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + } + } +} + #[cfg(test)] mod tests; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 96c90778f..78cb50bf0 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -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::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); 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::(); + 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); +} diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs new file mode 100644 index 000000000..a632191b2 --- /dev/null +++ b/server/src/perception/query.rs @@ -0,0 +1,103 @@ +//! Perception query trait (D-017). +//! +//! Abstraction for perception mode geometry computation. Each mode +//! (natural vision, thermal, EM, etc.) implements PerceptionQuery to +//! provide mode-specific FOV and visibility sector computation. +//! v0.1 implements only NaturalVision. + +use std::collections::{HashMap, HashSet}; + +use bevy_ecs::prelude::*; + +use crate::bridge::types::{FacingDirection, VisibilitySector, VisibleTile}; +use crate::perception::shadowcast::compute_fov; +use crate::perception::vision_cone::{apply_vision_cone, VisionConeConfig}; +use crate::simulation::movement::{TilePosition, WalkabilityMap}; + +/// Cached FOV geometry for the current frame. Produced by +/// compute_visibility_geometry, consumed by compute_observer_snapshot. +/// D-017 perception modes swap the geometry producer while the consumer +/// remains unchanged. +#[derive(Resource, Default)] +pub struct VisibilityGeometry { + pub visible_tiles: Vec, + pub visible_positions: HashSet<(i32, i32)>, + pub sector_lookup: HashMap<(i32, i32), VisibilitySector>, + pub observer_z: i32, +} + +/// Trait for perception mode geometry computation (D-017). +/// +/// Each perception mode implements this to produce a VisibilityGeometry +/// from the observer's position and facing. v0.1 only implements +/// NaturalVision; D-017 adds Thermal, EM, etc. +pub trait PerceptionQuery: Send + Sync { + fn compute_geometry( + &self, + observer_pos: &TilePosition, + facing: FacingDirection, + walkability: &WalkabilityMap, + ) -> VisibilityGeometry; +} + +/// Natural vision — default perception mode. +/// Uses symmetric shadowcasting (D-011) + directional vision cone (D-015). +pub struct NaturalVision; + +impl PerceptionQuery for NaturalVision { + fn compute_geometry( + &self, + observer_pos: &TilePosition, + facing: FacingDirection, + walkability: &WalkabilityMap, + ) -> VisibilityGeometry { + let config = VisionConeConfig::default(); + let z = observer_pos.z; + + 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, + ); + + let cone_tiles = + apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); + + let visible_tiles = cone_tiles + .iter() + .map(|&(x, y, sector)| VisibleTile { + x, + y, + z, + visibility: sector, + }) + .collect(); + + let visible_positions = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); + + let sector_lookup = cone_tiles + .iter() + .map(|&(x, y, sector)| ((x, y), sector)) + .collect(); + + VisibilityGeometry { + visible_tiles, + visible_positions, + sector_lookup, + observer_z: z, + } + } +} + +/// Resource wrapping the active perception mode (D-017). +/// Defaults to NaturalVision. Swap this resource to change perception modes. +#[derive(Resource)] +pub struct ActivePerceptionMode(pub Box); + +impl Default for ActivePerceptionMode { + fn default() -> Self { + Self(Box::new(NaturalVision)) + } +} diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index d931da5fe..cb5d2935c 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -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, >, registry: Res, @@ -38,7 +42,7 @@ pub fn compute_nearby_interactions( (With, Without), >, ) { - 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(); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 5805cd3ec..492d2400a 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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), ), );