//! Indexed line pool data structures and query API (D-028 four-layer filtering). //! //! Provides typed, pre-indexed runtime representations of dialogue and monologue //! content pools. Populated by the v0.2 generator pipeline. //! //! Indexing strategy (D-041 determinism): //! - All maps use BTreeMap for deterministic iteration order //! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering //! - Monologue: BTreeMap<(character, location), pool> with trigger grouping use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; use bevy_ecs::prelude::*; use tracing::trace; use crate::simulation::knowledge_grant::{KnowledgeGrant, Prerequisites}; // --------------------------------------------------------------------------- // Tag enums (D-035 converged taxonomy) // --------------------------------------------------------------------------- /// D-028 Layer 1: Access tier — hard filter on who can hear this line. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum AccessTier { Public, Insider, Authority, Peer, Hostile, } impl FromStr for AccessTier { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "public" => Ok(Self::Public), "insider" => Ok(Self::Insider), "authority" => Ok(Self::Authority), "peer" => Ok(Self::Peer), "hostile" => Ok(Self::Hostile), _ => Err(ParseEnumError { kind: "AccessTier", value: s.to_string(), }), } } } /// D-028 Layer 3: Trust tier — hard filter on relationship depth. /// /// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TrustTier { Surface, Real, Secret, } impl TrustTier { /// Returns true if `self` meets or exceeds the `required` tier. pub fn meets(self, required: TrustTier) -> bool { self >= required } } impl FromStr for TrustTier { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "surface" => Ok(Self::Surface), "real" => Ok(Self::Real), "secret" => Ok(Self::Secret), _ => Err(ParseEnumError { kind: "TrustTier", value: s.to_string(), }), } } } /// D-028 Layer 2: Situation context — when this line can fire. /// /// 14 v0.1 values: 13 original + Greeting added Sprint 8 (D-035 amendment) /// for PC dialogue pools initial contact lines. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Situation { Arrival, ShiftStart, ShiftEnd, ShiftTransition, BarEvening, NightShift, Investigation, Confrontation, Social, Alone, Emergency, Routine, Observation, /// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines. Greeting, /// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2). FirstMeeting, /// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2). RepeatedVisit, } impl FromStr for Situation { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "arrival" => Ok(Self::Arrival), "shift_start" => Ok(Self::ShiftStart), "shift_end" => Ok(Self::ShiftEnd), "shift_transition" => Ok(Self::ShiftTransition), "bar_evening" => Ok(Self::BarEvening), "night_shift" => Ok(Self::NightShift), "investigation" => Ok(Self::Investigation), "confrontation" => Ok(Self::Confrontation), "social" => Ok(Self::Social), "alone" => Ok(Self::Alone), "emergency" => Ok(Self::Emergency), "routine" => Ok(Self::Routine), "observation" => Ok(Self::Observation), "greeting" => Ok(Self::Greeting), "first_meeting" => Ok(Self::FirstMeeting), "repeated_visit" => Ok(Self::RepeatedVisit), _ => Err(ParseEnumError { kind: "Situation", value: s.to_string(), }), } } } /// D-028 Layer 4: Topic tag — influences weighted selection. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Topic { Colleague, Routine, Cargo, Money, Trust, Danger, Institution, Personal, Investigation, } impl FromStr for Topic { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "colleague" => Ok(Self::Colleague), "routine" => Ok(Self::Routine), "cargo" => Ok(Self::Cargo), "money" => Ok(Self::Money), "trust" => Ok(Self::Trust), "danger" => Ok(Self::Danger), "institution" => Ok(Self::Institution), "personal" => Ok(Self::Personal), "investigation" => Ok(Self::Investigation), _ => Err(ParseEnumError { kind: "Topic", value: s.to_string(), }), } } } /// D-028 Layer 4: Mood tag — influences weighted selection. /// /// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename). /// D-035 amendment (Sprint 8): `Focused` added as 9th variant. /// Neutral mood is represented by omitting the mood tag (untagged = baseline). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Mood { Anxious, Frustrated, Content, Suspicious, Warm, Hostile, Relieved, /// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal /// and maintenance corridors. Maps from NpcMood::Focused. Focused, } impl FromStr for Mood { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "anxious" => Ok(Self::Anxious), "frustrated" => Ok(Self::Frustrated), "content" => Ok(Self::Content), "suspicious" => Ok(Self::Suspicious), "warm" => Ok(Self::Warm), "hostile" => Ok(Self::Hostile), "relieved" => Ok(Self::Relieved), "focused" => Ok(Self::Focused), _ => Err(ParseEnumError { kind: "Mood", value: s.to_string(), }), } } } /// Monologue trigger type — what causes this line to fire. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Trigger { EnterLocation, ObserveNpc, HearSound, ObserveAnomaly, PostConversation, DiscoverEvidence, WitnessInteraction, TimeIdle, ReturnVisit, } impl FromStr for Trigger { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "enter_location" => Ok(Self::EnterLocation), "observe_npc" => Ok(Self::ObserveNpc), "hear_sound" => Ok(Self::HearSound), "observe_anomaly" => Ok(Self::ObserveAnomaly), "post_conversation" => Ok(Self::PostConversation), "discover_evidence" => Ok(Self::DiscoverEvidence), "witness_interaction" => Ok(Self::WitnessInteraction), "time_idle" => Ok(Self::TimeIdle), "return_visit" => Ok(Self::ReturnVisit), _ => Err(ParseEnumError { kind: "Trigger", value: s.to_string(), }), } } } /// Hard character partition for monologue pools (D-032). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Character { Smuggler, Detective, } impl FromStr for Character { type Err = ParseEnumError; fn from_str(s: &str) -> Result { match s { "smuggler" => Ok(Self::Smuggler), "detective" => Ok(Self::Detective), _ => Err(ParseEnumError { kind: "Character", value: s.to_string(), }), } } } /// Error type for enum parsing failures. #[derive(Debug)] pub struct ParseEnumError { pub kind: &'static str, pub value: String, } impl fmt::Display for ParseEnumError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "invalid {} value: {:?}", self.kind, self.value) } } impl std::error::Error for ParseEnumError {} // --------------------------------------------------------------------------- // Indexed line types — typed runtime representations // --------------------------------------------------------------------------- /// A dialogue line with typed enum fields, ready for filtering. #[derive(Debug, Clone)] pub struct IndexedDialogueLine { pub id: String, pub text: String, pub role: String, pub access: Vec, pub trust: TrustTier, pub situation: Vec, pub topic: Vec, pub mood: Vec, pub tags: Vec, pub knowledge_grant: Option, } /// A monologue line with typed enum fields, ready for filtering. #[derive(Debug, Clone)] pub struct IndexedMonologueLine { pub id: String, pub text: String, pub trigger: Trigger, pub prerequisites: Option, pub priority: u8, pub cooldown: u32, pub tags: Vec, } // --------------------------------------------------------------------------- // Pool index types // --------------------------------------------------------------------------- /// Dialogue pool indexed for querying. #[derive(Debug)] pub struct IndexedDialoguePool { pub location: String, pub role: String, pub lines: Vec, } /// Monologue pool indexed by trigger for fast lookup. #[derive(Debug)] pub struct IndexedMonologuePool { pub character: Character, pub location: String, /// Lines grouped by trigger type (BTreeMap for deterministic iteration). pub by_trigger: BTreeMap>, } // --------------------------------------------------------------------------- // Top-level index // --------------------------------------------------------------------------- /// Top-level line pool index — the queryable runtime data structure. /// /// All internal maps use BTreeMap per D-041 determinism requirement. #[derive(Debug, Default)] pub struct LinePoolIndex { /// Dialogue pools indexed by (location, role). pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>, /// Monologue pools indexed by (character, location). pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>, } impl LinePoolIndex { /// Query dialogue lines through Layers 1-3 of the D-028 pipeline. /// /// Returns lines that pass: /// - Layer 1: player's access tier is in line.access /// - Layer 2: any active situation is in line.situation /// - Layer 3: player's trust >= line.trust /// /// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305). pub fn query_dialogue( &self, location: &str, role: &str, player_access: AccessTier, active_situations: &[Situation], player_trust: TrustTier, ) -> Vec<&IndexedDialogueLine> { if active_situations.is_empty() { trace!( location, role, "query_dialogue: active_situations is empty — Layer 2 will filter all lines" ); } let key = (location.to_string(), role.to_string()); let Some(pool) = self.dialogue.get(&key) else { return Vec::new(); }; pool.lines .iter() .filter(|line| { // Layer 1: Access filter (hard) line.access.contains(&player_access) }) .filter(|line| { // Layer 2: Situation filter (context) line.situation.iter().any(|s| active_situations.contains(s)) }) .filter(|line| { // Layer 3: Trust filter (hard) player_trust.meets(line.trust) }) .collect() } /// Query monologue lines for a trigger event. /// /// Returns lines matching character + trigger from both: /// - Location-specific pool (exact match) /// - General pool (location = "general") /// /// Prerequisite evaluation and cooldown checking are the caller's /// responsibility (they require KG state and tick tracking). pub fn query_monologue( &self, character: Character, location: &str, trigger: Trigger, ) -> Vec<&IndexedMonologueLine> { let mut results = Vec::new(); // Location-specific pool let key = (character, location.to_string()); if let Some(pool) = self.monologue.get(&key) { if let Some(lines) = pool.by_trigger.get(&trigger) { results.extend(lines.iter()); } } // General pool fallback if location != "general" { let general_key = (character, "general".to_string()); if let Some(pool) = self.monologue.get(&general_key) { if let Some(lines) = pool.by_trigger.get(&trigger) { results.extend(lines.iter()); } } } results } /// Returns total number of indexed dialogue lines. pub fn dialogue_line_count(&self) -> usize { self.dialogue.values().map(|p| p.lines.len()).sum() } /// Returns total number of indexed monologue lines. pub fn monologue_line_count(&self) -> usize { self.monologue .values() .flat_map(|p| p.by_trigger.values()) .map(|lines| lines.len()) .sum() } } // --------------------------------------------------------------------------- // Wrapper resource // --------------------------------------------------------------------------- /// Wrapper resource holding the indexed line pools. /// Available for runtime systems that need to query dialogue/monologue lines /// through the D-028 four-layer filtering pipeline. #[derive(Resource, Debug)] pub struct LinePoolIndexResource(pub LinePoolIndex); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn access_tier_parse_all_values() { assert_eq!("public".parse::().unwrap(), AccessTier::Public); assert_eq!( "insider".parse::().unwrap(), AccessTier::Insider ); assert_eq!( "authority".parse::().unwrap(), AccessTier::Authority ); assert_eq!("peer".parse::().unwrap(), AccessTier::Peer); assert_eq!( "hostile".parse::().unwrap(), AccessTier::Hostile ); assert!("invalid".parse::().is_err()); } #[test] fn trust_tier_ordering() { assert!(TrustTier::Surface < TrustTier::Real); assert!(TrustTier::Real < TrustTier::Secret); assert!(TrustTier::Secret.meets(TrustTier::Secret)); assert!(TrustTier::Secret.meets(TrustTier::Surface)); assert!(!TrustTier::Surface.meets(TrustTier::Real)); } #[test] fn situation_parse_all_values() { let values = [ "arrival", "shift_start", "shift_end", "shift_transition", "bar_evening", "night_shift", "investigation", "confrontation", "social", "alone", "emergency", "routine", "observation", "greeting", "first_meeting", "repeated_visit", ]; for v in values { assert!( v.parse::().is_ok(), "Failed to parse situation: {}", v ); } assert!("invalid".parse::().is_err()); } #[test] fn topic_parse_all_values() { let values = [ "colleague", "routine", "cargo", "money", "trust", "danger", "institution", "personal", "investigation", ]; for v in values { assert!(v.parse::().is_ok(), "Failed to parse topic: {}", v); } } #[test] fn mood_parse_all_values() { let values = [ "anxious", "frustrated", "content", "suspicious", "warm", "hostile", "relieved", "focused", ]; for v in values { assert!(v.parse::().is_ok(), "Failed to parse mood: {}", v); } } #[test] fn trigger_parse_all_values() { let values = [ "enter_location", "observe_npc", "hear_sound", "observe_anomaly", "post_conversation", "discover_evidence", "witness_interaction", "time_idle", "return_visit", ]; for v in values { assert!( v.parse::().is_ok(), "Failed to parse trigger: {}", v ); } } #[test] fn character_parse() { assert_eq!( "smuggler".parse::().unwrap(), Character::Smuggler ); assert_eq!( "detective".parse::().unwrap(), Character::Detective ); assert!("other".parse::().is_err()); } }