Files
settled-reach/server/src/simulation/poi_discovery.rs
T
jpmschweitzerandClaude Opus 4.6 132676c25a chore(simulation): address review suggestions — comments and minor fixes
- monologue.rs: tracing::warn on unrecognized trigger in fallback arm
- disclosure.rs: borrow-sequencing comment, first() simplification note
- trait_modifiers.rs: delegate parse_confidence to KnowledgeConfidence::try_from
- npc_knowledge_transfer.rs: fix misleading "draw randomly" comment,
  document one-directional transfer behavior
- graph.rs: document intentional no-retrigger after contradiction
- dialogue.rs: explain entity grant guardrail asymmetry (D-079)
- knowledge/mod.rs: document one-tick monologue lag from system ordering
- poi_discovery.rs: document D-079 carve-out for direct KG write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:45:47 +01:00

427 lines
14 KiB
Rust

//! 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<PoiDiscoveredEvent>,
}
impl PoiDiscoveryEventQueue {
pub fn push(&mut self, event: PoiDiscoveredEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<PoiDiscoveredEvent> {
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<SimulationTime>,
geometry: Res<VisibilityGeometry>,
mut discovery_queue: ResMut<PoiDiscoveryEventQueue>,
poi_query: Query<&PointOfInterest>,
mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With<PlayerCharacter>>,
) {
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::<BTreeSet<_>>(),
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<PlayerCharacter>>();
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::<PoiDiscoveryEventQueue>();
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::<PoiDiscoveryEventQueue>();
assert!(queue.is_empty(), "No discovery event for already-known POI");
}
}