DisclosureCandidates component with 7 trigger gates, three-layer rate limiting, and two-stage trait filter (what + how). Cautious/Gossipy/Loyal/ Talkative predicates via TraitModifierConfig. POI component and discovery system. Implements D-081, D-082 step 2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
//! Point of Interest data model (#148).
|
||||
//!
|
||||
//! POIs are discoverable world locations: quest-relevant places, hidden
|
||||
//! areas, landmarks, vendors, etc. They integrate with the knowledge
|
||||
//! graph via `FactId("poi.*")` namespace per D-079.
|
||||
//!
|
||||
//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted`
|
||||
//! with `Fact` variant to grant POI facts to observers.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::FactId;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Category of point of interest. Determines client-side icon and color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiCategory {
|
||||
/// Named location (dock, bar, office, residential block).
|
||||
Location,
|
||||
/// Vendor or service provider (fixer, medic, data broker).
|
||||
Service,
|
||||
/// Quest-relevant target (drop point, meeting place, evidence site).
|
||||
QuestTarget,
|
||||
/// Hidden area (secret passage, concealed cache, restricted zone).
|
||||
Hidden,
|
||||
/// Navigation landmark visible from a distance.
|
||||
Landmark,
|
||||
}
|
||||
|
||||
/// How a POI was placed in the world (content provenance).
|
||||
///
|
||||
/// Distinct from visibility rules: discovery_source tracks *why* the POI
|
||||
/// exists; visibility tracks *how* it can be found.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiDiscoverySource {
|
||||
/// Part of the map template — always present on this map.
|
||||
MapTemplate,
|
||||
/// Procedurally generated at world creation.
|
||||
Procedural,
|
||||
/// Created by a quest or storyline event at runtime.
|
||||
QuestGenerated,
|
||||
/// Revealed by NPC testimony via knowledge grant.
|
||||
NpcRevealed,
|
||||
}
|
||||
|
||||
/// Rules governing when an observer can discover this POI.
|
||||
///
|
||||
/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge
|
||||
/// graph. The discovery system (#149) evaluates these rules each tick
|
||||
/// for POIs not yet known to the observer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PoiVisibility {
|
||||
/// Discoverable when within line of sight (standard LOS rules).
|
||||
LineOfSight,
|
||||
/// Discoverable only within a specific tile range (Manhattan distance).
|
||||
Proximity { range: u32 },
|
||||
/// Not discoverable by observation. Requires a `KnowledgeGranted`
|
||||
/// event from dialogue, evidence, or NPC testimony.
|
||||
KnowledgeOnly,
|
||||
/// Discoverable by LOS, but only if the observer already knows a
|
||||
/// prerequisite fact. Example: a hidden door visible only if the
|
||||
/// observer knows `"quest.secret_passage_hint"`.
|
||||
RequiresFact { fact_id: String },
|
||||
}
|
||||
|
||||
/// Point of Interest ECS component (#148).
|
||||
///
|
||||
/// Attached to world entities that represent discoverable locations.
|
||||
/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added
|
||||
/// to their `KnowledgeGraph` via the discovery system (#149).
|
||||
///
|
||||
/// BTreeMap ordering note: POI entities use `StableEntityId` like all
|
||||
/// other entities. The `poi_id` string is for the fact namespace only.
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointOfInterest {
|
||||
/// Unique identifier within the `poi.*` fact namespace.
|
||||
/// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`.
|
||||
/// Must be unique across all POIs in the world.
|
||||
pub poi_id: String,
|
||||
/// Display name shown to the player after discovery.
|
||||
pub name: String,
|
||||
/// World position of the POI (center tile).
|
||||
pub position: TilePosition,
|
||||
/// Category for client-side rendering (icon, minimap marker).
|
||||
pub category: PoiCategory,
|
||||
/// Content provenance — how this POI was placed in the world.
|
||||
pub discovery_source: PoiDiscoverySource,
|
||||
/// Rules for when/how an observer can discover this POI.
|
||||
pub visibility: PoiVisibility,
|
||||
}
|
||||
|
||||
impl PointOfInterest {
|
||||
/// Generate the `FactId` for this POI in the knowledge graph.
|
||||
/// Format: `"poi.{poi_id}"` per D-079 namespace convention.
|
||||
pub fn fact_id(&self) -> FactId {
|
||||
FactId(format!("poi.{}", self.poi_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest {
|
||||
PointOfInterest {
|
||||
poi_id: id.to_string(),
|
||||
name: format!("Test POI {}", id),
|
||||
position: TilePosition::new(10, 20, 0),
|
||||
category,
|
||||
discovery_source: PoiDiscoverySource::MapTemplate,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_uses_poi_namespace() {
|
||||
let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_format_is_deterministic() {
|
||||
let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
assert_eq!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_poi_ids_produce_different_fact_ids() {
|
||||
let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_ne!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_visibility_stores_range() {
|
||||
let poi = make_poi(
|
||||
"hidden_cache",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::Proximity { range: 5 },
|
||||
);
|
||||
match poi.visibility {
|
||||
PoiVisibility::Proximity { range } => assert_eq!(range, 5),
|
||||
_ => panic!("Expected Proximity visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_visibility_stores_fact_id() {
|
||||
let poi = make_poi(
|
||||
"secret_door",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_passage_hint".to_string(),
|
||||
},
|
||||
);
|
||||
match &poi.visibility {
|
||||
PoiVisibility::RequiresFact { fact_id } => {
|
||||
assert_eq!(fact_id, "quest.secret_passage_hint");
|
||||
}
|
||||
_ => panic!("Expected RequiresFact visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_categories_are_distinct() {
|
||||
assert_ne!(PoiCategory::Location, PoiCategory::Service);
|
||||
assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden);
|
||||
assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_discovery_sources_are_distinct() {
|
||||
assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural);
|
||||
assert_ne!(
|
||||
PoiDiscoverySource::QuestGenerated,
|
||||
PoiDiscoverySource::NpcRevealed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_serialization_roundtrip() {
|
||||
let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight);
|
||||
let serialized = serde_yaml::to_string(&poi).expect("serialize");
|
||||
let deserialized: PointOfInterest =
|
||||
serde_yaml::from_str(&serialized).expect("deserialize");
|
||||
assert_eq!(deserialized.poi_id, "med_bay");
|
||||
assert_eq!(deserialized.category, PoiCategory::Service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! 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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user