//! 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. Built from the raw ContentStore after YAML deserialization. //! //! 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 crate::content::loader::ContentStore; use crate::content::types; // --------------------------------------------------------------------------- // 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. #[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, /// 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), "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. /// D-035 amendment (Sprint 8): `Focused` added as 9th variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Mood { Fond, Comfortable, Worried, Suspicious, Analytical, Conflicted, Concerned, 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 { "fond" => Ok(Self::Fond), "comfortable" => Ok(Self::Comfortable), "worried" => Ok(Self::Worried), "suspicious" => Ok(Self::Suspicious), "analytical" => Ok(Self::Analytical), "conflicted" => Ok(Self::Conflicted), "concerned" => Ok(Self::Concerned), "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. /// /// Built from ContentStore at startup (and rebuilt on hot-reload). /// 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 { /// Build the index from a ContentStore. /// /// Parses string tags into typed enums. Lines with invalid required tags /// are skipped with a warning log. pub fn build(store: &ContentStore) -> Self { let mut index = Self::default(); for district in store.districts.values() { for pool in &district.dialogue_pools { index.index_dialogue_pool(pool); } for pool in &district.monologue_pools { index.index_monologue_pool(pool); } } index } fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) { if pool.location.is_empty() { tracing::warn!( "Skipping dialogue pool with empty location (role={})", pool.role, ); return; } let key = (pool.location.clone(), pool.role.clone()); let indexed_lines: Vec = pool.lines.iter().filter_map(parse_dialogue_line).collect(); let entry = self .dialogue .entry(key) .or_insert_with(|| IndexedDialoguePool { location: pool.location.clone(), role: pool.role.clone(), lines: Vec::new(), }); entry.lines.extend(indexed_lines); } fn index_monologue_pool(&mut self, pool: &types::MonologuePool) { if pool.location.is_empty() { tracing::warn!( "Skipping monologue pool with empty location (character={})", pool.character, ); return; } let character = match pool.character.parse::() { Ok(c) => c, Err(e) => { tracing::warn!("Skipping monologue pool: {}", e); return; } }; let key = (character, pool.location.clone()); let entry = self .monologue .entry(key) .or_insert_with(|| IndexedMonologuePool { character, location: pool.location.clone(), by_trigger: BTreeMap::new(), }); for line in &pool.lines { if let Some(indexed) = parse_monologue_line(line) { entry .by_trigger .entry(indexed.trigger) .or_default() .push(indexed); } } } /// 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> { 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() } } // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- /// Parse a raw DialogueLine into an indexed line with typed enums. /// Returns None if any required enum field fails to parse. fn parse_dialogue_line(line: &types::DialogueLine) -> Option { let access: Vec = line .access .iter() .filter_map(|s| { s.parse() .map_err(|e: ParseEnumError| { tracing::warn!("Line {}: {}", line.id, e); }) .ok() }) .collect(); if access.is_empty() { tracing::warn!("Line {}: no valid access tiers, skipping", line.id); return None; } let trust = match line.trust.parse::() { Ok(t) => t, Err(e) => { tracing::warn!("Line {}: {}, skipping", line.id, e); return None; } }; let situation: Vec = line .situation .iter() .filter_map(|s| { s.parse() .map_err(|e: ParseEnumError| { tracing::warn!("Line {}: {}", line.id, e); }) .ok() }) .collect(); if situation.is_empty() { tracing::warn!("Line {}: no valid situations, skipping", line.id); return None; } let topic: Vec = line .topic .iter() .filter_map(|s| { s.parse() .map_err(|e: ParseEnumError| { tracing::warn!("Line {}: {}", line.id, e); }) .ok() }) .collect(); let mood: Vec = line .mood .iter() .filter_map(|s| { s.parse() .map_err(|e: ParseEnumError| { tracing::warn!("Line {}: {}", line.id, e); }) .ok() }) .collect(); Some(IndexedDialogueLine { id: line.id.clone(), text: line.text.clone(), role: line.role.clone(), access, trust, situation, topic, mood, tags: line.tags.clone(), knowledge_grant: line.knowledge_grant.clone(), }) } /// Parse a raw MonologueLine into an indexed line with typed enums. /// Returns None if the trigger fails to parse. fn parse_monologue_line(line: &types::MonologueLine) -> Option { let trigger = match line.trigger.parse::() { Ok(t) => t, Err(e) => { tracing::warn!("Line {}: {}, skipping", line.id, e); return None; } }; Some(IndexedMonologueLine { id: line.id.clone(), text: line.text.clone(), trigger, prerequisites: line.prerequisites.clone(), priority: line.priority.unwrap_or(5).clamp(0, 10) as u8, cooldown: line.cooldown.unwrap_or(0).max(0) as u32, tags: line.tags.clone(), }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::content::loader::{ContentStore, DistrictContent}; // -- Enum parsing tests -------------------------------------------------- #[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", ]; 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 = [ "fond", "comfortable", "worried", "suspicious", "analytical", "conflicted", "concerned", "relieved", ]; 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()); } // -- Helper: build a test ContentStore ----------------------------------- fn make_dialogue_line( id: &str, access: &[&str], trust: &str, situations: &[&str], ) -> types::DialogueLine { types::DialogueLine { id: id.to_string(), text: format!("Text for {}", id), role: "worker".to_string(), access: access.iter().map(|s| s.to_string()).collect(), trust: trust.to_string(), situation: situations.iter().map(|s| s.to_string()).collect(), topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, } } fn make_monologue_line(id: &str, trigger: &str, priority: i32) -> types::MonologueLine { types::MonologueLine { id: id.to_string(), text: format!("Monologue {}", id), trigger: trigger.to_string(), prerequisites: None, priority: Some(priority), cooldown: None, tags: vec![], } } fn test_store() -> ContentStore { let mut store = ContentStore::default(); let dialogue_pools = vec![types::DialoguePool { location: "the-terminal".to_string(), role: "dock-worker".to_string(), lines: vec![ make_dialogue_line( "the-terminal_d_001", &["public"], "surface", &["arrival", "social"], ), make_dialogue_line( "the-terminal_d_002", &["insider", "peer"], "real", &["bar_evening"], ), make_dialogue_line( "the-terminal_d_003", &["insider"], "secret", &["investigation"], ), ], }]; let monologue_pools = vec![ types::MonologuePool { character: "smuggler".to_string(), location: "the-terminal".to_string(), lines: vec![ make_monologue_line("the-terminal_m_s_001", "enter_location", 5), make_monologue_line("the-terminal_m_s_002", "observe_npc", 7), ], }, types::MonologuePool { character: "smuggler".to_string(), location: "general".to_string(), lines: vec![make_monologue_line("general_m_s_001", "enter_location", 3)], }, ]; let district = DistrictContent { dialogue_pools, monologue_pools, ..Default::default() }; store .districts .insert("test.district".to_string(), district); store } // -- Index building tests ------------------------------------------------ #[test] fn build_indexes_dialogue_pools() { let store = test_store(); let index = LinePoolIndex::build(&store); assert_eq!(index.dialogue.len(), 1); let pool = index .dialogue .get(&("the-terminal".to_string(), "dock-worker".to_string())) .unwrap(); assert_eq!(pool.lines.len(), 3); } #[test] fn build_indexes_monologue_pools() { let store = test_store(); let index = LinePoolIndex::build(&store); assert_eq!(index.monologue.len(), 2); let loc_pool = index .monologue .get(&(Character::Smuggler, "the-terminal".to_string())) .unwrap(); assert_eq!(loc_pool.by_trigger.len(), 2); let general_pool = index .monologue .get(&(Character::Smuggler, "general".to_string())) .unwrap(); assert_eq!(general_pool.by_trigger.len(), 1); } #[test] fn line_counts() { let store = test_store(); let index = LinePoolIndex::build(&store); assert_eq!(index.dialogue_line_count(), 3); assert_eq!(index.monologue_line_count(), 3); } // -- Dialogue query tests (D-028 Layers 1-3) ---------------------------- #[test] fn query_dialogue_layer1_access_filter() { let store = test_store(); let index = LinePoolIndex::build(&store); // Public access should only get the public line let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Public, &[Situation::Arrival], TrustTier::Secret, ); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "the-terminal_d_001"); } #[test] fn query_dialogue_layer2_situation_filter() { let store = test_store(); let index = LinePoolIndex::build(&store); // Insider + secret trust but only bar_evening situation let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Insider, &[Situation::BarEvening], TrustTier::Secret, ); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "the-terminal_d_002"); } #[test] fn query_dialogue_layer3_trust_filter() { let store = test_store(); let index = LinePoolIndex::build(&store); // Insider + investigation but only surface trust — should miss line 003 (secret) let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Insider, &[Situation::Investigation], TrustTier::Surface, ); assert_eq!(results.len(), 0); // With real trust — still no (line 003 requires secret) let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Insider, &[Situation::Investigation], TrustTier::Real, ); assert_eq!(results.len(), 0); // With secret trust — line 003 passes let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Insider, &[Situation::Investigation], TrustTier::Secret, ); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "the-terminal_d_003"); } #[test] fn query_dialogue_multiple_situations() { let store = test_store(); let index = LinePoolIndex::build(&store); // Insider with multiple situations should get lines from both let results = index.query_dialogue( "the-terminal", "dock-worker", AccessTier::Insider, &[Situation::Arrival, Situation::BarEvening], TrustTier::Real, ); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "the-terminal_d_002"); } #[test] fn query_dialogue_nonexistent_pool() { let store = test_store(); let index = LinePoolIndex::build(&store); let results = index.query_dialogue( "nonexistent", "worker", AccessTier::Public, &[Situation::Arrival], TrustTier::Surface, ); assert!(results.is_empty()); } // -- Monologue query tests ----------------------------------------------- #[test] fn query_monologue_location_specific() { let store = test_store(); let index = LinePoolIndex::build(&store); let results = index.query_monologue(Character::Smuggler, "the-terminal", Trigger::ObserveNpc); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "the-terminal_m_s_002"); } #[test] fn query_monologue_includes_general_pool() { let store = test_store(); let index = LinePoolIndex::build(&store); // enter_location: 1 from the-terminal + 1 from general let results = index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); assert_eq!(results.len(), 2); } #[test] fn query_monologue_general_only() { let store = test_store(); let index = LinePoolIndex::build(&store); // Unknown location — only general pool matches let results = index.query_monologue( Character::Smuggler, "unknown-location", Trigger::EnterLocation, ); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "general_m_s_001"); } #[test] fn query_monologue_wrong_character() { let store = test_store(); let index = LinePoolIndex::build(&store); // Detective character — no pools exist let results = index.query_monologue(Character::Detective, "the-terminal", Trigger::EnterLocation); assert!(results.is_empty()); } #[test] fn query_monologue_no_trigger_match() { let store = test_store(); let index = LinePoolIndex::build(&store); let results = index.query_monologue( Character::Smuggler, "the-terminal", Trigger::DiscoverEvidence, ); assert!(results.is_empty()); } #[test] fn query_monologue_line_with_prerequisite_excluded_when_fact_absent() { // H10: Verify prerequisite field is populated through query so callers // can filter. query_monologue returns ALL matching lines (prerequisite // evaluation is caller's responsibility per D-028), but a line with a // prerequisite should carry that data through for the caller to check. let mut store = ContentStore::default(); let monologue_pools = vec![types::MonologuePool { character: "smuggler".to_string(), location: "the-terminal".to_string(), lines: vec![ // Line WITHOUT prerequisite — should always be available types::MonologueLine { id: "prereq_none".to_string(), text: "No prereq line".to_string(), trigger: "enter_location".to_string(), prerequisites: None, priority: Some(5), cooldown: None, tags: vec![], }, // Line WITH prerequisite — caller must check before using types::MonologueLine { id: "prereq_fact".to_string(), text: "Requires cargo_manifest_seen".to_string(), trigger: "enter_location".to_string(), prerequisites: Some(types::Prerequisites { facts: vec![types::FactPrerequisite { fact_id: "cargo_manifest_seen".to_string(), min_confidence: "confirmed".to_string(), }], entity_attributes: vec![], relationship: None, }), priority: Some(8), cooldown: None, tags: vec![], }, ], }]; let district = DistrictContent { monologue_pools, ..Default::default() }; store .districts .insert("test.district".to_string(), district); let index = LinePoolIndex::build(&store); let results = index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); // Both lines are returned (query doesn't filter prerequisites) assert_eq!(results.len(), 2); // Verify the prerequisite-bearing line carries its prerequisites through let prereq_line = results.iter().find(|l| l.id == "prereq_fact").unwrap(); assert!( prereq_line.prerequisites.is_some(), "prerequisite field should be populated for caller to evaluate" ); let prereqs = prereq_line.prerequisites.as_ref().unwrap(); assert_eq!(prereqs.facts.len(), 1); assert_eq!(prereqs.facts[0].fact_id, "cargo_manifest_seen"); // The no-prerequisite line should have None let no_prereq_line = results.iter().find(|l| l.id == "prereq_none").unwrap(); assert!( no_prereq_line.prerequisites.is_none(), "line without prerequisites should have None" ); // Simulate caller-side filtering: if fact is absent, exclude the line let player_known_facts: Vec<&str> = vec![]; // empty — fact not known let available: Vec<_> = results .iter() .filter(|line| { match &line.prerequisites { None => true, // no prerequisites = always available Some(prereqs) => prereqs .facts .iter() .all(|f| player_known_facts.contains(&f.fact_id.as_str())), } }) .collect(); assert_eq!( available.len(), 1, "only the no-prerequisite line should pass when fact is absent" ); assert_eq!(available[0].id, "prereq_none"); } // -- Parse edge cases ---------------------------------------------------- #[test] fn dialogue_line_with_invalid_access_is_skipped() { let line = types::DialogueLine { id: "test_d_001".to_string(), text: "test".to_string(), role: "worker".to_string(), access: vec!["invalid".to_string()], trust: "surface".to_string(), situation: vec!["arrival".to_string()], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }; assert!(parse_dialogue_line(&line).is_none()); } #[test] fn dialogue_line_with_invalid_trust_is_skipped() { let line = types::DialogueLine { id: "test_d_001".to_string(), text: "test".to_string(), role: "worker".to_string(), access: vec!["public".to_string()], trust: "invalid".to_string(), situation: vec!["arrival".to_string()], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }; assert!(parse_dialogue_line(&line).is_none()); } #[test] fn monologue_line_defaults() { let line = types::MonologueLine { id: "test_m_s_001".to_string(), text: "test".to_string(), trigger: "enter_location".to_string(), prerequisites: None, priority: None, cooldown: None, tags: vec![], }; let indexed = parse_monologue_line(&line).unwrap(); assert_eq!(indexed.priority, 5); // default assert_eq!(indexed.cooldown, 0); // default } #[test] fn monologue_priority_clamped() { let line = types::MonologueLine { id: "test_m_s_001".to_string(), text: "test".to_string(), trigger: "enter_location".to_string(), prerequisites: None, priority: Some(15), // over max cooldown: None, tags: vec![], }; let indexed = parse_monologue_line(&line).unwrap(); assert_eq!(indexed.priority, 10); // clamped } }