CLI tool to preview monologue/dialogue line selection for a given game state. 4 subcommands: dialogue (D-028 4-layer pipeline), monologue (trigger + prerequisite evaluation), coverage (dead conversation detection), sequence (walk-through simulation). Authoring tool for content team — tests line trigger logic without running the full game. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
586 lines
16 KiB
Rust
586 lines
16 KiB
Rust
//! 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<String>,
|
|
pub trust: String,
|
|
pub situation: Vec<String>,
|
|
pub topic: Vec<String>,
|
|
pub mood: Vec<String>,
|
|
#[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<DialogueResult> {
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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::<f64>() * 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<String>,
|
|
#[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<MonologueResult> {
|
|
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::<DialoguePool>(yaml_str) {
|
|
print_dialogue_coverage(&pool, path);
|
|
} else if let Ok(pool) = serde_yaml::from_str::<MonologuePool>(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::<std::collections::BTreeSet<_>>(),
|
|
pool.lines
|
|
.iter()
|
|
.map(|l| l.trust.as_str())
|
|
.collect::<std::collections::BTreeSet<_>>(),
|
|
);
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|