//! 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); } }