//! Trait modifier system for unprompted disclosure (#173, D-081). //! //! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool, //! Stage 2 (HOW) weights line selection via delivery tags. //! //! Traits map to filter predicates via content-authorable YAML config — //! not hard-coded enum dispatch. Content authors define what each trait //! does to the candidate pool and which delivery tags it prefers. use std::collections::BTreeMap; use bevy_ecs::prelude::*; use serde::Deserialize; use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource}; // --------------------------------------------------------------------------- // YAML-authored trait modifier config // --------------------------------------------------------------------------- /// Full trait modifier configuration resource. Loaded from YAML. /// /// Keys are trait names (lowercase, matching `PersonalityTrait` string /// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc. /// /// BTreeMap for deterministic iteration (D-010 principle 4). #[derive(Resource, Debug, Clone, Default, Deserialize)] pub struct TraitModifierConfig { /// Trait name → modifier rules. Trait names are lowercase_snake_case. #[serde(default)] pub modifiers: BTreeMap, } /// A single trait's filter and scoring rules. #[derive(Debug, Clone, Default, Deserialize)] pub struct TraitModifier { /// Stage 1: candidate pool filter (WHAT gets disclosed). #[serde(default)] pub stage1: Stage1Filter, /// Stage 2: line pool scoring (HOW it's delivered). #[serde(default)] pub stage2: Stage2Scoring, } /// Stage 1 filter predicates — modify the disclosure candidate pool. /// /// Applied per-fact during candidate selection in `DisclosureCandidates` /// (#551). Multiple traits compose additively: if any trait includes a /// candidate that would otherwise be excluded, it's included. /// /// Default values (all false/None) produce no modification to the /// baseline filter, which requires KnowsOf minimum confidence and /// includes all source types. #[derive(Debug, Clone, Default, Deserialize)] pub struct Stage1Filter { /// Minimum confidence to enter the disclosure pool. /// Parsed at load time: "suspects", "knows_of", "knows_details", "direct". /// None = use system default (KnowsOf). #[serde(default)] pub min_confidence: Option, /// If true, exclude facts with `ToldBy` source (won't pass on rumors). /// Cautious trait behavior. #[serde(default)] pub exclude_told_by: bool, /// If true, exclude facts linked to entities with trust_level >= Real /// in NPC Relationships. Loyal trait behavior. #[serde(default)] pub exclude_high_trust_entities: bool, /// If true, override the witness inhibition gate. Talkative trait behavior. #[serde(default)] pub override_witness_inhibition: bool, } /// Stage 2 scoring — influence line selection weighting. /// /// Delivery tags in `IndexedDialogueLine.tags` are matched against /// the NPC's trait-derived preferred tags. Lines with matching tags /// receive a scoring bonus during Layer 4 selection. #[derive(Debug, Clone, Default, Deserialize)] pub struct Stage2Scoring { /// Preferred delivery tags for line selection weighting. /// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`. /// Lines with matching tags receive a scoring bonus. #[serde(default)] pub delivery_tags: Vec, } // --------------------------------------------------------------------------- // Filter predicate evaluation // --------------------------------------------------------------------------- impl Stage1Filter { /// Parse the min_confidence string into a `KnowledgeConfidence` value. /// Returns `None` (use system default) for unparseable or absent values. pub fn min_confidence_level(&self) -> Option { self.min_confidence.as_deref().and_then(parse_confidence) } /// Evaluate whether a fact passes this trait's Stage 1 filter. /// /// Returns `false` if the fact should be excluded by this trait. /// The caller (#551) composes multiple trait filters: a fact is /// included if it passes the composite filter. pub fn allows_fact(&self, fact: &FactKnowledge) -> bool { // Check minimum confidence if let Some(min) = self.min_confidence_level() { if fact.confidence < min { return false; } } // Exclude ToldBy-source facts (Cautious behavior) if self.exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) { return false; } true } } impl Stage2Scoring { /// Check if a line's tags contain any of this trait's preferred delivery tags. /// Returns the number of matching tags (0 = no bonus). pub fn tag_match_count(&self, line_tags: &[String]) -> usize { self.delivery_tags .iter() .filter(|dt| line_tags.contains(dt)) .count() } /// Check if a line has at least one matching delivery tag. pub fn has_matching_tag(&self, line_tags: &[String]) -> bool { self.tag_match_count(line_tags) > 0 } } impl TraitModifierConfig { /// Look up the modifier for a trait by name. pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> { self.modifiers.get(trait_name) } /// Collect all Stage 2 delivery tags for a set of trait names. /// Returns a deduplicated, sorted list for deterministic matching. pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec { let mut tags: Vec = trait_names .iter() .filter_map(|name| self.modifiers.get(name.as_str())) .flat_map(|m| m.stage2.delivery_tags.iter().cloned()) .collect(); tags.sort(); tags.dedup(); tags } /// Check if any trait in the set overrides witness inhibition. pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool { trait_names.iter().any(|name| { self.modifiers .get(name.as_str()) .is_some_and(|m| m.stage1.override_witness_inhibition) }) } /// Check if any trait in the set excludes high-trust entity facts. pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool { trait_names.iter().any(|name| { self.modifiers .get(name.as_str()) .is_some_and(|m| m.stage1.exclude_high_trust_entities) }) } /// Get the most permissive (lowest) min_confidence across all traits. /// Returns None if no traits specify a minimum (use system default). pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option { trait_names .iter() .filter_map(|name| self.modifiers.get(name.as_str())) .filter_map(|m| m.stage1.min_confidence_level()) .min() } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Parse a confidence string from YAML config to enum value. /// Delegates to `KnowledgeConfidence::try_from` (which accepts both /// camelCase and underscore forms) rather than duplicating the match. fn parse_confidence(s: &str) -> Option { KnowledgeConfidence::try_from(s) .map_err(|e| { tracing::warn!("Unknown confidence level in trait config: {}", e); }) .ok() } /// Convert a `PersonalityTrait` to its lowercase YAML key. /// Used to look up trait modifiers from the config. pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str { match trait_val { super::PersonalityTrait::Cautious => "cautious", super::PersonalityTrait::Bold => "bold", super::PersonalityTrait::Honest => "honest", super::PersonalityTrait::Deceptive => "deceptive", super::PersonalityTrait::Compassionate => "compassionate", super::PersonalityTrait::Ruthless => "ruthless", super::PersonalityTrait::Curious => "curious", super::PersonalityTrait::Incurious => "incurious", super::PersonalityTrait::Social => "social", super::PersonalityTrait::Reclusive => "reclusive", } } /// Convert an NPC's personality trait list to YAML config keys. pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec { traits.iter().map(|t| trait_to_key(t).to_string()).collect() } #[cfg(test)] mod tests { use super::*; fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge { FactKnowledge { confidence, source, state: crate::knowledge::types::KnowledgeState::Active, acquired_tick: 100, disclosure_blocked: false, } } fn cautious_config() -> TraitModifierConfig { let yaml = r#" modifiers: cautious: stage1: min_confidence: "knows_details" exclude_told_by: true stage2: delivery_tags: ["cautious_delivery"] gossipy: stage1: min_confidence: "suspects" stage2: delivery_tags: ["gossip_delivery", "casual_delivery"] loyal: stage1: exclude_high_trust_entities: true stage2: delivery_tags: ["professional_delivery"] talkative: stage1: override_witness_inhibition: true min_confidence: "suspects" stage2: delivery_tags: ["casual_delivery", "gossip_delivery"] "#; serde_yaml::from_str(yaml).expect("valid trait config YAML") } #[test] fn parse_config_from_yaml() { let config = cautious_config(); assert_eq!(config.modifiers.len(), 4); assert!(config.modifiers.contains_key("cautious")); assert!(config.modifiers.contains_key("gossipy")); assert!(config.modifiers.contains_key("loyal")); assert!(config.modifiers.contains_key("talkative")); } #[test] fn cautious_excludes_low_confidence() { let config = cautious_config(); let cautious = &config.modifiers["cautious"].stage1; let suspects_fact = make_fact( KnowledgeConfidence::Suspects, KnowledgeSource::DirectObservation { tick: 50 }, ); let details_fact = make_fact( KnowledgeConfidence::KnowsDetails, KnowledgeSource::DirectObservation { tick: 50 }, ); assert!( !cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects" ); assert!( cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails" ); } #[test] fn cautious_excludes_told_by() { let config = cautious_config(); let cautious = &config.modifiers["cautious"].stage1; let told_fact = make_fact( KnowledgeConfidence::KnowsDetails, KnowledgeSource::ToldBy { source_id: crate::knowledge::types::StableId(42), tick: 50, }, ); assert!( !cautious.allows_fact(&told_fact), "Cautious excludes ToldBy" ); } #[test] fn gossipy_includes_suspects() { let config = cautious_config(); let gossipy = &config.modifiers["gossipy"].stage1; let suspects_fact = make_fact( KnowledgeConfidence::Suspects, KnowledgeSource::DirectObservation { tick: 50 }, ); assert!( gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects" ); } #[test] fn talkative_overrides_witness_inhibition() { let config = cautious_config(); let traits = vec!["talkative".to_string()]; assert!(config.any_overrides_witness_inhibition(&traits)); let traits = vec!["cautious".to_string()]; assert!(!config.any_overrides_witness_inhibition(&traits)); } #[test] fn loyal_excludes_high_trust() { let config = cautious_config(); let traits = vec!["loyal".to_string()]; assert!(config.any_excludes_high_trust(&traits)); let traits = vec!["gossipy".to_string()]; assert!(!config.any_excludes_high_trust(&traits)); } #[test] fn lowest_min_confidence_picks_most_permissive() { let config = cautious_config(); // Gossipy (suspects) + Cautious (knows_details) → suspects wins let traits = vec!["gossipy".to_string(), "cautious".to_string()]; assert_eq!( config.lowest_min_confidence(&traits), Some(KnowledgeConfidence::Suspects) ); } #[test] fn delivery_tags_deduped_and_sorted() { let config = cautious_config(); // Gossipy + Talkative both have "casual_delivery" and "gossip_delivery" let traits = vec!["gossipy".to_string(), "talkative".to_string()]; let tags = config.delivery_tags_for(&traits); assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]); } #[test] fn stage2_tag_matching() { let config = cautious_config(); let scoring = &config.modifiers["cautious"].stage2; let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()]; assert!(scoring.has_matching_tag(&line_tags)); assert_eq!(scoring.tag_match_count(&line_tags), 1); let no_match_tags = vec!["gossip_delivery".to_string()]; assert!(!scoring.has_matching_tag(&no_match_tags)); } #[test] fn unknown_trait_returns_none() { let config = cautious_config(); assert!(config.modifier_for("unknown_trait").is_none()); } #[test] fn trait_to_key_roundtrip() { use super::super::PersonalityTrait; assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious"); assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold"); assert_eq!(trait_to_key(&PersonalityTrait::Social), "social"); } #[test] fn traits_to_keys_conversion() { use super::super::PersonalityTrait; let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social]; let keys = traits_to_keys(&traits); assert_eq!(keys, vec!["cautious", "social"]); } #[test] fn empty_config_is_no_op() { let config = TraitModifierConfig::default(); let traits = vec!["cautious".to_string()]; assert!(!config.any_overrides_witness_inhibition(&traits)); assert!(!config.any_excludes_high_trust(&traits)); assert_eq!(config.lowest_min_confidence(&traits), None); assert!(config.delivery_tags_for(&traits).is_empty()); } #[test] fn default_filter_allows_everything() { let filter = Stage1Filter::default(); let fact = make_fact( KnowledgeConfidence::Suspects, KnowledgeSource::ToldBy { source_id: crate::knowledge::types::StableId(1), tick: 10, }, ); assert!(filter.allows_fact(&fact), "Default filter allows all facts"); } #[test] fn parse_confidence_values() { assert_eq!( parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects) ); assert_eq!( parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf) ); assert_eq!( parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails) ); assert_eq!( parse_confidence("direct"), Some(KnowledgeConfidence::Direct) ); assert_eq!(parse_confidence("invalid"), None); } // --- Coverage gap closure tests --- #[test] fn cautious_excludes_knows_of_below_threshold() { // Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails, // so a KnowsOf fact must be excluded (not just Suspects). let config = cautious_config(); let cautious = &config.modifiers["cautious"].stage1; let knows_of_fact = make_fact( KnowledgeConfidence::KnowsOf, KnowledgeSource::DirectObservation { tick: 50 }, ); assert!( !cautious.allows_fact(&knows_of_fact), "Cautious should exclude KnowsOf (below knows_details threshold)" ); } #[test] fn cautious_allows_direct_confidence() { // Direct > KnowsDetails, so Direct passes cautious min_confidence. let config = cautious_config(); let cautious = &config.modifiers["cautious"].stage1; let direct_fact = make_fact( KnowledgeConfidence::Direct, KnowledgeSource::DirectObservation { tick: 50 }, ); assert!( cautious.allows_fact(&direct_fact), "Cautious should allow Direct confidence (above threshold)" ); } #[test] fn gossipy_allows_all_confidence_levels() { // Gossipy min_confidence is "suspects" — all confidence levels pass. let config = cautious_config(); let gossipy = &config.modifiers["gossipy"].stage1; for (confidence, label) in [ (KnowledgeConfidence::Suspects, "Suspects"), (KnowledgeConfidence::KnowsOf, "KnowsOf"), (KnowledgeConfidence::KnowsDetails, "KnowsDetails"), (KnowledgeConfidence::Direct, "Direct"), ] { let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 }); assert!( gossipy.allows_fact(&fact), "Gossipy should allow {} confidence", label ); } } #[test] fn all_personality_traits_map_to_unique_keys() { use super::super::PersonalityTrait; let all_traits = vec![ PersonalityTrait::Cautious, PersonalityTrait::Bold, PersonalityTrait::Honest, PersonalityTrait::Deceptive, PersonalityTrait::Compassionate, PersonalityTrait::Ruthless, PersonalityTrait::Curious, PersonalityTrait::Incurious, PersonalityTrait::Social, PersonalityTrait::Reclusive, ]; let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect(); // All 10 traits produce a non-empty key for (trait_, key) in all_traits.iter().zip(keys.iter()) { assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_); } // All keys are unique (no two traits share a key) let mut sorted = keys.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!( sorted.len(), all_traits.len(), "All personality traits must map to distinct keys" ); } #[test] fn stage2_multiple_tag_matches_counts_correctly() { // When a line has two matching delivery tags, tag_match_count returns 2. let config = cautious_config(); // Talkative has: ["casual_delivery", "gossip_delivery"] let talkative_scoring = &config.modifiers["talkative"].stage2; let line_tags = vec![ "casual_delivery".to_string(), "gossip_delivery".to_string(), "unrelated_tag".to_string(), ]; assert_eq!( talkative_scoring.tag_match_count(&line_tags), 2, "Both delivery tags should match" ); assert!(talkative_scoring.has_matching_tag(&line_tags)); } #[test] fn gossipy_does_not_exclude_told_by() { // Gossipy has no exclude_told_by restriction — it should pass ToldBy facts. let config = cautious_config(); let gossipy = &config.modifiers["gossipy"].stage1; let told_fact = make_fact( KnowledgeConfidence::Suspects, KnowledgeSource::ToldBy { source_id: crate::knowledge::types::StableId(5), tick: 10, }, ); assert!( gossipy.allows_fact(&told_fact), "Gossipy should not exclude ToldBy-source facts" ); } }