//! POI discovery system (#149). //! //! Detects when the player observer discovers a Point of Interest and //! grants the corresponding `FactId("poi.*")` to their knowledge graph. //! //! Discovery methods handled here: //! - Physical discovery (LOS, proximity) — checked each tick //! //! Discovery methods handled elsewhere: //! - Character background — inserted at spawn time by content system //! - NPC tips / research — via `KnowledgeGranted` event (#546) use bevy_ecs::prelude::*; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::types::{ FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, }; use crate::perception::query::VisibilityGeometry; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::poi::{PoiVisibility, PointOfInterest}; use crate::simulation::time::SimulationTime; /// Event emitted when the player discovers a POI. /// /// Other systems (monologue, minimap update, storyteller) can react to /// this event. Consumed and cleared each tick. #[derive(Debug, Clone)] pub struct PoiDiscoveredEvent { /// The `poi_id` string of the discovered POI. pub poi_id: String, /// Display name for monologue/UI use. pub name: String, /// Tick when discovered. pub tick: u64, } /// Resource: queue of POI discovery events from the current tick. #[derive(Resource, Default)] pub struct PoiDiscoveryEventQueue { events: Vec, } impl PoiDiscoveryEventQueue { pub fn push(&mut self, event: PoiDiscoveredEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } pub fn is_empty(&self) -> bool { self.events.is_empty() } } /// System: check for POI physical discovery by the player observer. /// /// Runs after visibility geometry is computed. For each undiscovered POI, /// checks visibility rules against the observer's position and known facts. /// Discovered POIs are added as `FactId("poi.*")` facts to the observer's /// KnowledgeGraph with `DirectObservation` source. pub fn discover_pois( time: Res, geometry: Res, mut discovery_queue: ResMut, poi_query: Query<&PointOfInterest>, mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With>, ) { let Ok((observer_pos, mut kg)) = observer_query.single_mut() else { return; }; for poi in poi_query.iter() { let fact_id = poi.fact_id(); // Skip already-known POIs if kg.knows_fact(&fact_id) { continue; } if can_discover(observer_pos, &geometry, &kg, poi) { // Direct KG write — bypasses the KnowledgeGranted event queue. // Justified for LOS-based physical discovery: the observer sees // the POI directly, no intermediary grant source. This is a D-079 // carve-out; NPC tips and research-based POI discovery (Sprint 18) // will use the event queue path. kg.facts.insert( fact_id, FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::DirectObservation { tick: time.tick }, state: KnowledgeState::Active, acquired_tick: time.tick, disclosure_blocked: false, }, ); discovery_queue.push(PoiDiscoveredEvent { poi_id: poi.poi_id.clone(), name: poi.name.clone(), tick: time.tick, }); tracing::info!( poi_id = %poi.poi_id, name = %poi.name, tick = time.tick, "Player discovered POI" ); } } } /// Evaluate whether an observer can discover a POI based on its visibility rules. fn can_discover( observer_pos: &TilePosition, geometry: &VisibilityGeometry, kg: &KnowledgeGraph, poi: &PointOfInterest, ) -> bool { match &poi.visibility { PoiVisibility::LineOfSight => { poi.position.z == geometry.observer_z && geometry .visible_positions .contains(&(poi.position.x, poi.position.y)) } PoiVisibility::Proximity { range } => observer_pos .manhattan_distance(&poi.position) .is_some_and(|d| d <= *range), PoiVisibility::KnowledgeOnly => { // Not discoverable by physical observation. // Requires KnowledgeGranted event from dialogue/evidence. false } PoiVisibility::RequiresFact { fact_id } => { // Must know the prerequisite fact AND see the POI in LOS. kg.knows_fact(&FactId(fact_id.clone())) && poi.position.z == geometry.observer_z && geometry .visible_positions .contains(&(poi.position.x, poi.position.y)) } } } #[cfg(test)] mod tests { use super::*; use crate::simulation::poi::{PoiCategory, PoiDiscoverySource}; use std::collections::BTreeSet; fn make_poi( id: &str, position: TilePosition, visibility: PoiVisibility, ) -> PointOfInterest { PointOfInterest { poi_id: id.to_string(), name: format!("Test {}", id), position, category: PoiCategory::Location, discovery_source: PoiDiscoverySource::MapTemplate, visibility, } } fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry { VisibilityGeometry { visible_tiles: vec![], visible_positions: visible.iter().copied().collect::>(), sector_lookup: Default::default(), observer_z: z, } } // --- can_discover tests --- #[test] fn los_poi_discovered_when_in_visible_positions() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); let geometry = make_geometry(&[(10, 5)], 0); let kg = KnowledgeGraph::new(); assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn los_poi_not_discovered_when_not_visible() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set let kg = KnowledgeGraph::new(); assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn los_poi_not_discovered_on_different_z() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight); let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1 let kg = KnowledgeGraph::new(); assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn proximity_poi_discovered_within_range() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "cache", TilePosition::new(7, 5, 0), PoiVisibility::Proximity { range: 3 }, ); let geometry = make_geometry(&[], 0); let kg = KnowledgeGraph::new(); // Manhattan distance = 2, range = 3 → discovered assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn proximity_poi_not_discovered_outside_range() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "cache", TilePosition::new(10, 5, 0), PoiVisibility::Proximity { range: 3 }, ); let geometry = make_geometry(&[], 0); let kg = KnowledgeGraph::new(); // Manhattan distance = 5, range = 3 → not discovered assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn proximity_poi_not_discovered_different_z() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "cache", TilePosition::new(5, 6, 1), // different z PoiVisibility::Proximity { range: 3 }, ); let geometry = make_geometry(&[], 0); let kg = KnowledgeGraph::new(); // manhattan_distance returns None for different z assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn knowledge_only_never_discovered_physically() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "secret", TilePosition::new(5, 5, 0), // same tile PoiVisibility::KnowledgeOnly, ); let geometry = make_geometry(&[(5, 5)], 0); let kg = KnowledgeGraph::new(); assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn requires_fact_discovered_when_fact_known_and_visible() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "hidden_door", TilePosition::new(8, 5, 0), PoiVisibility::RequiresFact { fact_id: "quest.secret_hint".to_string(), }, ); let geometry = make_geometry(&[(8, 5)], 0); let kg = KnowledgeGraph::with_background(vec![( FactId("quest.secret_hint".to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, )]); assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn requires_fact_not_discovered_without_fact() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "hidden_door", TilePosition::new(8, 5, 0), PoiVisibility::RequiresFact { fact_id: "quest.secret_hint".to_string(), }, ); let geometry = make_geometry(&[(8, 5)], 0); let kg = KnowledgeGraph::new(); // no facts assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } #[test] fn requires_fact_not_discovered_when_not_visible() { let observer_pos = TilePosition::new(5, 5, 0); let poi = make_poi( "hidden_door", TilePosition::new(8, 5, 0), PoiVisibility::RequiresFact { fact_id: "quest.secret_hint".to_string(), }, ); let geometry = make_geometry(&[], 0); // not visible let kg = KnowledgeGraph::with_background(vec![( FactId("quest.secret_hint".to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, )]); assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); } // --- System integration test --- #[test] fn discover_pois_system_grants_fact() { use bevy_ecs::world::World; let mut world = World::new(); // Resources let mut time = SimulationTime::default(); time.tick = 50; world.insert_resource(time); world.insert_resource(make_geometry(&[(10, 5)], 0)); world.insert_resource(PoiDiscoveryEventQueue::default()); // Player observer world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), KnowledgeGraph::new(), )); // POI entity world.spawn(make_poi( "docking_bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight, )); // Run system let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(discover_pois); schedule.run(&mut world); // Verify: player now knows the POI fact let mut query = world.query_filtered::<&KnowledgeGraph, With>(); let kg = query.single(&world).expect("player should exist"); let fact_id = FactId("poi.docking_bay".to_string()); assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay"); assert_eq!( kg.facts.get(&fact_id).unwrap().confidence, KnowledgeConfidence::KnowsOf ); // Verify: discovery event was emitted let queue = world.resource::(); assert_eq!(queue.events.len(), 1); assert_eq!(queue.events[0].poi_id, "docking_bay"); assert_eq!(queue.events[0].tick, 50); } #[test] fn discover_pois_system_skips_already_known() { use bevy_ecs::world::World; let mut world = World::new(); let mut time = SimulationTime::default(); time.tick = 100; world.insert_resource(time); world.insert_resource(make_geometry(&[(10, 5)], 0)); world.insert_resource(PoiDiscoveryEventQueue::default()); // Player already knows this POI let kg = KnowledgeGraph::with_background(vec![( FactId("poi.docking_bay".to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, )]); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); world.spawn(make_poi( "docking_bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(discover_pois); schedule.run(&mut world); // No new events — already known let queue = world.resource::(); assert!(queue.is_empty(), "No discovery event for already-known POI"); } }