//! 4-layer dialogue selection pipeline (D-028) and monologue trigger evaluation. //! //! Layer 1: Access tier filter (hard filter) //! Layer 2: Situation filter (hard filter) //! Layer 3: Trust level filter (hard filter) //! Layer 4: Weighted selection by topic + mood match use crate::types::{DialogueLine, DialoguePool, MonologueLine, MonologuePool}; use rand::prelude::*; use rand_chacha::ChaCha20Rng; use std::path::Path; // --- Dialogue pipeline --- pub struct DialogueContext { pub access: Vec, pub trust: String, pub situation: Vec, pub topic: Vec, pub mood: Vec, #[allow(dead_code)] pub seed: u64, } pub struct DialogueResult { pub line_id: String, pub text: String, pub passed: bool, pub weight: f64, pub access_pass: bool, pub trust_pass: bool, pub situation_pass: bool, pub topic_match: f64, pub mood_match: f64, pub access_detail: String, pub trust_detail: String, pub situation_detail: String, } pub fn evaluate_dialogue(pool: &DialoguePool, ctx: &DialogueContext) -> Vec { pool.lines .iter() .map(|line| evaluate_dialogue_line(line, ctx)) .collect() } fn evaluate_dialogue_line(line: &DialogueLine, ctx: &DialogueContext) -> DialogueResult { // Layer 1: Access tier — at least one of the player's access tiers must match let access_pass = line.access.iter().any(|a| ctx.access.contains(a)); let access_detail = if access_pass { let matched: Vec<_> = line .access .iter() .filter(|a| ctx.access.contains(a)) .collect(); format!( "matched: {}", matched .iter() .map(|s| s.as_str()) .collect::>() .join(", ") ) } else { format!( "line requires [{}], player has [{}]", line.access.join(", "), ctx.access.join(", ") ) }; // Layer 2: Situation — at least one active situation must match let situation_pass = if ctx.situation.is_empty() { true // no situation filter = all situations match } else { line.situation.iter().any(|s| ctx.situation.contains(s)) }; let situation_detail = if situation_pass { if ctx.situation.is_empty() { "no filter applied".to_string() } else { let matched: Vec<_> = line .situation .iter() .filter(|s| ctx.situation.contains(s)) .collect(); format!( "matched: {}", matched .iter() .map(|s| s.as_str()) .collect::>() .join(", ") ) } } else { format!( "line requires [{}], active: [{}]", line.situation.join(", "), ctx.situation.join(", ") ) }; // Layer 3: Trust — line's trust level must be <= player's trust level let trust_order = trust_rank(&line.trust); let player_trust = trust_rank(&ctx.trust); let trust_pass = trust_order <= player_trust; let trust_detail = if trust_pass { format!("line={} <= player={}", line.trust, ctx.trust) } else { format!( "line requires {} but player only has {}", line.trust, ctx.trust ) }; // Layer 4: Weighted selection by topic + mood overlap let topic_match = if ctx.topic.is_empty() || line.topic.is_empty() { 0.5 // neutral weight when no topic filter } else { let matches = line.topic.iter().filter(|t| ctx.topic.contains(t)).count(); if matches > 0 { 0.5 + 0.5 * (matches as f64 / line.topic.len().max(1) as f64) } else { 0.3 // slight penalty for topic mismatch but not exclusion } }; let mood_match = if ctx.mood.is_empty() || line.mood.is_empty() { 0.5 } else { let matches = line.mood.iter().filter(|m| ctx.mood.contains(m)).count(); if matches > 0 { 0.5 + 0.5 * (matches as f64 / line.mood.len().max(1) as f64) } else { 0.3 } }; let passed = access_pass && situation_pass && trust_pass; let weight = if passed { topic_match * mood_match } else { 0.0 }; DialogueResult { line_id: line.id.clone(), text: line.text.clone(), passed, weight, access_pass, trust_pass, situation_pass, topic_match, mood_match, access_detail, trust_detail, situation_detail, } } fn trust_rank(trust: &str) -> u8 { match trust { "surface" => 0, "real" => 1, "secret" => 2, _ => 0, } } pub fn print_dialogue_results(results: &[DialogueResult], verbose: bool, seed: u64) { let passing: Vec<_> = results.iter().filter(|r| r.passed).collect(); let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect(); println!( "Dialogue evaluation: {} total, {} eligible, {} filtered out\n", results.len(), passing.len(), failing.len() ); for r in &passing { println!(" [PASS] {}: {:?}", r.line_id, r.text); println!( " weight={:.2} topic={:.2} mood={:.2}", r.weight, r.topic_match, r.mood_match ); if verbose { println!(" access: {}", r.access_detail); println!(" trust: {}", r.trust_detail); println!(" situation: {}", r.situation_detail); } } if verbose && !failing.is_empty() { println!("\n Filtered out:"); for r in &failing { let reasons: Vec<&str> = [ if !r.access_pass { Some("access") } else { None }, if !r.trust_pass { Some("trust") } else { None }, if !r.situation_pass { Some("situation") } else { None }, ] .iter() .filter_map(|x| *x) .collect(); println!( " [FAIL] {}: {:?} (failed: {})", r.line_id, r.text, reasons.join(", ") ); if !r.access_pass { println!(" access: {}", r.access_detail); } if !r.trust_pass { println!(" trust: {}", r.trust_detail); } if !r.situation_pass { println!(" situation: {}", r.situation_detail); } } } // Weighted selection if !passing.is_empty() { let total_weight: f64 = passing.iter().map(|r| r.weight).sum(); if total_weight > 0.0 { let mut rng = ChaCha20Rng::seed_from_u64(seed); let roll: f64 = rng.random::() * total_weight; let mut cumulative = 0.0; for r in &passing { cumulative += r.weight; if cumulative >= roll { println!( "\n Selected: {} (weight={:.2}/{:.2})", r.line_id, r.weight, total_weight ); break; } } } } else { println!("\n No eligible lines for this context."); } } // --- Monologue pipeline --- pub struct MonologueContext { pub trigger: String, pub known_facts: Vec, #[allow(dead_code)] pub seed: u64, } pub struct MonologueResult { pub line_id: String, pub text: String, pub passed: bool, pub trigger_pass: bool, pub prereq_pass: bool, pub priority: i32, pub trigger_detail: String, pub prereq_detail: String, } pub fn evaluate_monologue(pool: &MonologuePool, ctx: &MonologueContext) -> Vec { let mut results: Vec<_> = pool .lines .iter() .map(|line| evaluate_monologue_line(line, ctx)) .collect(); // Sort by priority (higher first), then by line_id for determinism results.sort_by(|a, b| { b.priority .cmp(&a.priority) .then_with(|| a.line_id.cmp(&b.line_id)) }); results } fn evaluate_monologue_line(line: &MonologueLine, ctx: &MonologueContext) -> MonologueResult { // Trigger match let trigger_pass = line.trigger == ctx.trigger; let trigger_detail = if trigger_pass { format!("matched: {}", ctx.trigger) } else { format!("line={}, context={}", line.trigger, ctx.trigger) }; // Prerequisite evaluation let (prereq_pass, prereq_detail) = if let Some(ref prereqs) = line.prerequisites { evaluate_prerequisites(prereqs, &ctx.known_facts) } else { (true, "no prerequisites".to_string()) }; let passed = trigger_pass && prereq_pass; let priority = line.priority.unwrap_or(5); MonologueResult { line_id: line.id.clone(), text: line.text.clone(), passed, trigger_pass, prereq_pass, priority, trigger_detail, prereq_detail, } } fn evaluate_prerequisites( prereqs: &crate::types::Prerequisites, known_facts: &[String], ) -> (bool, String) { let mut details = Vec::new(); let mut all_pass = true; for fact_req in &prereqs.facts { let has_fact = known_facts.contains(&fact_req.fact_id); if has_fact { details.push(format!( "fact:{} >= {} [PASS]", fact_req.fact_id, fact_req.min_confidence )); } else { details.push(format!( "fact:{} >= {} [FAIL: not known]", fact_req.fact_id, fact_req.min_confidence )); all_pass = false; } } // Entity attributes and relationships are not yet checkable without full game state if !prereqs.entity_attributes.is_empty() { details.push(format!( "{} entity_attribute prereqs (skipped: no game state)", prereqs.entity_attributes.len() )); } if prereqs.relationship.is_some() { details.push("relationship prereq (skipped: no game state)".to_string()); } if details.is_empty() { (true, "no prerequisites".to_string()) } else { (all_pass, details.join("; ")) } } pub fn print_monologue_results(results: &[MonologueResult], verbose: bool, _seed: u64) { let passing: Vec<_> = results.iter().filter(|r| r.passed).collect(); let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect(); println!( "Monologue evaluation: {} total, {} eligible, {} filtered out\n", results.len(), passing.len(), failing.len() ); for r in &passing { println!( " [PASS] {} (priority={}): {:?}", r.line_id, r.priority, r.text ); if verbose { println!(" trigger: {}", r.trigger_detail); println!(" prereqs: {}", r.prereq_detail); } } if verbose && !failing.is_empty() { println!("\n Filtered out:"); for r in &failing { let reasons: Vec<&str> = [ if !r.trigger_pass { Some("trigger") } else { None }, if !r.prereq_pass { Some("prerequisites") } else { None }, ] .iter() .filter_map(|x| *x) .collect(); println!( " [FAIL] {}: {:?} (failed: {})", r.line_id, r.text, reasons.join(", ") ); if !r.trigger_pass { println!(" trigger: {}", r.trigger_detail); } if !r.prereq_pass { println!(" prereqs: {}", r.prereq_detail); } } } // Selection: highest priority eligible line if let Some(selected) = passing.first() { println!( "\n Selected: {} (priority={})", selected.line_id, selected.priority ); } else { println!("\n No eligible lines for this trigger."); } } // --- Coverage report --- pub fn print_coverage_report(yaml_str: &str, path: &Path) { // Try dialogue first, then monologue if let Ok(pool) = serde_yaml::from_str::(yaml_str) { print_dialogue_coverage(&pool, path); } else if let Ok(pool) = serde_yaml::from_str::(yaml_str) { print_monologue_coverage(&pool, path); } else { eprintln!("Error: file is neither dialogue nor monologue YAML"); std::process::exit(1); } } fn print_dialogue_coverage(pool: &DialoguePool, path: &Path) { let access_tiers = ["public", "insider", "authority", "peer", "hostile"]; let trust_levels = ["surface", "real", "secret"]; let situations = [ "arrival", "shift_start", "shift_end", "shift_transition", "bar_evening", "night_shift", "investigation", "confrontation", "social", "alone", "emergency", "routine", "observation", ]; println!( "Coverage report: {} (role: {}, location: {})\n", path.display(), pool.role, pool.location ); let mut gaps = Vec::new(); for access in &access_tiers { for trust in &trust_levels { for situation in &situations { let eligible = pool .lines .iter() .filter(|line| { line.access.iter().any(|a| a == access) && trust_rank(&line.trust) <= trust_rank(trust) && line.situation.iter().any(|s| s == situation) }) .count(); if eligible == 0 { gaps.push(format!( " access={:<10} trust={:<8} situation={:<18} -> 0 lines", access, trust, situation )); } } } } if gaps.is_empty() { println!(" Full coverage! Every access/trust/situation combination has at least one eligible line."); } else { println!( " {} gaps found (access/trust/situation combos with zero eligible lines):\n", gaps.len() ); // Show first 20 gaps for gap in gaps.iter().take(20) { println!("{gap}"); } if gaps.len() > 20 { println!(" ... and {} more", gaps.len() - 20); } } println!( "\n Total lines: {}, Access tiers used: {:?}, Trust levels used: {:?}", pool.lines.len(), pool.lines .iter() .flat_map(|l| l.access.iter()) .collect::>(), pool.lines .iter() .map(|l| l.trust.as_str()) .collect::>(), ); } fn print_monologue_coverage(pool: &MonologuePool, path: &Path) { let triggers = [ "enter_location", "observe_npc", "hear_sound", "observe_anomaly", "post_conversation", "discover_evidence", "witness_interaction", "time_idle", "return_visit", ]; println!( "Coverage report: {} (character: {}, location: {})\n", path.display(), pool.character, pool.location ); let mut covered = Vec::new(); let mut uncovered = Vec::new(); for trigger in &triggers { let count = pool .lines .iter() .filter(|line| line.trigger == *trigger) .count(); if count > 0 { covered.push(format!(" {:<25} {} lines", trigger, count)); } else { uncovered.push(format!(" {:<25} 0 lines", trigger)); } } if !covered.is_empty() { println!(" Covered triggers:"); for line in &covered { println!("{line}"); } } if !uncovered.is_empty() { println!("\n Uncovered triggers:"); for line in &uncovered { println!("{line}"); } } let with_prereqs = pool .lines .iter() .filter(|l| l.prerequisites.is_some()) .count(); println!( "\n Total lines: {}, With prerequisites: {}", pool.lines.len(), with_prereqs ); }