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>
192 lines
7.0 KiB
Rust
192 lines
7.0 KiB
Rust
//! 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);
|
|
}
|
|
}
|