Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
581 lines
20 KiB
Rust
581 lines
20 KiB
Rust
//! 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<String, TraitModifier>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<KnowledgeConfidence> {
|
|
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<String> {
|
|
let mut tags: Vec<String> = 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<KnowledgeConfidence> {
|
|
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> {
|
|
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<String> {
|
|
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"
|
|
);
|
|
}
|
|
}
|