feat(simulation): add line previewer CLI (#407)

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>
This commit is contained in:
2026-02-13 01:19:46 +01:00
co-authored by Claude Opus 4.6
parent 67fef58020
commit 661769f028
7 changed files with 1385 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.181"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
[[package]]
name = "line-previewer"
version = "0.1.0"
dependencies = [
"clap",
"rand",
"rand_chacha",
"serde",
"serde_yaml",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "line-previewer"
version = "0.1.0"
edition = "2021"
description = "CLI tool to preview dialogue/monologue line selection for a given game state."
[[bin]]
name = "line-previewer"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
clap = { version = "4", features = ["derive"] }
rand = "0.9"
rand_chacha = "0.9"
+234
View File
@@ -0,0 +1,234 @@
//! line-previewer: CLI tool for previewing dialogue/monologue line selection.
//!
//! Answers: "Given this NPC state and player state, what line fires?"
//! Authoring tool for content authors to test tag behavior without running the game.
//! Implements the 4-layer dialogue selection pipeline (D-028) in standalone mode.
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod pipeline;
mod types;
use pipeline::{
evaluate_dialogue, evaluate_monologue, print_coverage_report, print_dialogue_results,
print_monologue_results, DialogueContext, MonologueContext,
};
use types::{DialoguePool, MonologuePool};
#[derive(Parser)]
#[command(
name = "line-previewer",
about = "Preview dialogue/monologue line selection"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Preview dialogue line selection
Dialogue {
/// Path to dialogue YAML file
#[arg(short, long)]
file: PathBuf,
/// Access tier(s): public, insider, authority, peer, hostile
#[arg(short, long, value_delimiter = ',')]
access: Vec<String>,
/// Trust level: surface, real, secret
#[arg(short, long, default_value = "surface")]
trust: String,
/// Active situation(s): arrival, shift_start, shift_end, etc.
#[arg(short, long, value_delimiter = ',')]
situation: Vec<String>,
/// Topic filter(s): colleague, routine, cargo, etc.
#[arg(long, value_delimiter = ',')]
topic: Vec<String>,
/// Mood filter(s): fond, comfortable, worried, etc.
#[arg(long, value_delimiter = ',')]
mood: Vec<String>,
/// Show detailed filter pass/fail for each line
#[arg(short, long)]
verbose: bool,
/// RNG seed for weighted selection (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
/// Preview monologue line selection
Monologue {
/// Path to monologue YAML file
#[arg(short, long)]
file: PathBuf,
/// Character: smuggler or detective
#[arg(short, long)]
character: String,
/// Trigger type: enter_location, observe_npc, etc.
#[arg(short, long)]
trigger: String,
/// Known fact IDs (for prerequisite evaluation)
#[arg(long, value_delimiter = ',')]
known_facts: Vec<String>,
/// Show detailed prerequisite evaluation for each line
#[arg(short, long)]
verbose: bool,
/// RNG seed for weighted selection (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
/// Show coverage report — which filter combinations have zero eligible lines
Coverage {
/// Path to dialogue or monologue YAML file
#[arg(short, long)]
file: PathBuf,
},
/// Preview a sequence of monologue lines (walk-through simulation)
Sequence {
/// Path to monologue YAML file
#[arg(short, long)]
file: PathBuf,
/// Character: smuggler or detective
#[arg(short, long)]
character: String,
/// Trigger sequence (comma-separated): enter_location,observe_npc,time_idle
#[arg(short, long, value_delimiter = ',')]
triggers: Vec<String>,
/// RNG seed (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Dialogue {
file,
access,
trust,
situation,
topic,
mood,
verbose,
seed,
} => {
let pool = load_dialogue(&file);
let ctx = DialogueContext {
access,
trust,
situation,
topic,
mood,
seed,
};
let results = evaluate_dialogue(&pool, &ctx);
print_dialogue_results(&results, verbose, seed);
}
Command::Monologue {
file,
character,
trigger,
known_facts,
verbose,
seed,
} => {
let pool = load_monologue(&file);
if pool.character != character {
eprintln!(
"Warning: file character '{}' doesn't match requested '{}'",
pool.character, character
);
}
let ctx = MonologueContext {
trigger,
known_facts,
seed,
};
let results = evaluate_monologue(&pool, &ctx);
print_monologue_results(&results, verbose, seed);
}
Command::Coverage { file } => {
let yaml_str = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", file.display());
std::process::exit(1);
});
print_coverage_report(&yaml_str, &file);
}
Command::Sequence {
file,
character,
triggers,
seed,
} => {
let pool = load_monologue(&file);
if pool.character != character {
eprintln!(
"Warning: file character '{}' doesn't match requested '{}'",
pool.character, character
);
}
println!("[Sequence: {}{}]", file.display(), character);
let mut used_ids: Vec<String> = Vec::new();
for (i, trigger) in triggers.iter().enumerate() {
let ctx = MonologueContext {
trigger: trigger.clone(),
known_facts: vec![],
seed: seed.wrapping_add(i as u64),
};
let results = evaluate_monologue(&pool, &ctx);
// Filter out recently used lines
let eligible: Vec<_> = results
.iter()
.filter(|r| r.passed && !used_ids.contains(&r.line_id))
.collect();
if let Some(selected) = eligible.first() {
println!("{}. ({}) {:?}", i + 1, trigger, selected.text);
used_ids.push(selected.line_id.clone());
} else {
println!("{}. ({}) [no eligible line]", i + 1, trigger);
}
}
}
}
}
fn load_dialogue(path: &PathBuf) -> DialoguePool {
let yaml_str = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", path.display());
std::process::exit(1);
});
serde_yaml::from_str(&yaml_str).unwrap_or_else(|e| {
eprintln!("Error parsing dialogue YAML: {e}");
std::process::exit(1);
})
}
fn load_monologue(path: &PathBuf) -> MonologuePool {
let yaml_str = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", path.display());
std::process::exit(1);
});
serde_yaml::from_str(&yaml_str).unwrap_or_else(|e| {
eprintln!("Error parsing monologue YAML: {e}");
std::process::exit(1);
})
}
+585
View File
@@ -0,0 +1,585 @@
//! 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
);
}
+90
View File
@@ -0,0 +1,90 @@
//! Content types for dialogue and monologue pools.
//! Mirrors the JSON Schema definitions in content/_schema/.
//! Fields that appear unused are required for YAML deserialization fidelity.
#![allow(dead_code)]
use serde::Deserialize;
// --- Dialogue Pool ---
#[derive(Debug, Deserialize)]
pub struct DialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<DialogueLine>,
}
#[derive(Debug, Deserialize)]
pub struct DialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<String>,
pub trust: String,
pub situation: Vec<String>,
#[serde(default)]
pub topic: Vec<String>,
#[serde(default)]
pub mood: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[derive(Debug, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
// --- Monologue Pool ---
#[derive(Debug, Deserialize)]
pub struct MonologuePool {
pub character: String,
pub location: String,
pub lines: Vec<MonologueLine>,
}
#[derive(Debug, Deserialize)]
pub struct MonologueLine {
pub id: String,
pub text: String,
pub trigger: String,
pub prerequisites: Option<Prerequisites>,
#[serde(default)]
pub priority: Option<i32>,
#[serde(default)]
pub cooldown: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct RelationshipPrerequisite {
pub target: Option<String>,
pub state: Option<String>,
}
@@ -0,0 +1,48 @@
# Test fixture: dock-worker dialogue at The Terminal
location: the-terminal
role: dock-worker
lines:
- id: "terminal_d_001"
text: "Morning. Manifest's ready if you need it."
role: dock-worker
access: [public, insider]
trust: surface
situation: [shift_start, routine]
topic: [routine, cargo]
mood: [comfortable]
- id: "terminal_d_002"
text: "You're new around here? Or just... visiting?"
role: dock-worker
access: [public, authority]
trust: surface
situation: [arrival, social]
topic: [colleague]
mood: [comfortable, suspicious]
- id: "terminal_d_003"
text: "Between you and me, the shift change is when things get... creative."
role: dock-worker
access: [insider]
trust: real
situation: [shift_transition, social]
topic: [cargo, money]
mood: [comfortable]
- id: "terminal_d_004"
text: "I know what's in those crates. And I know you know."
role: dock-worker
access: [insider]
trust: secret
situation: [confrontation, investigation]
topic: [cargo, trust, danger]
mood: [worried, conflicted]
- id: "terminal_d_005"
text: "Just another shift. Nothing special."
role: dock-worker
access: [public]
trust: surface
situation: [routine, shift_start, shift_end]
topic: [routine]
mood: [comfortable]
@@ -0,0 +1,36 @@
# Test fixture: smuggler monologue at The Terminal
character: smuggler
location: the-terminal
lines:
- id: "terminal_m_s_001"
text: "The Terminal. Same hum, same faces, same game."
trigger: enter_location
priority: 5
- id: "terminal_m_s_002"
text: "Kael's at his station. Looks tense today."
trigger: observe_npc
priority: 7
- id: "terminal_m_s_003"
text: "That manifest doesn't add up. Kael knows something."
trigger: observe_anomaly
prerequisites:
facts:
- fact_id: manifest_discrepancy
min_confidence: suspects
priority: 9
- id: "terminal_m_s_004"
text: "Should check in on the next shipment. Can't be too careful."
trigger: time_idle
priority: 3
- id: "terminal_m_s_005"
text: "Something's different today. The usual rhythm is off."
trigger: enter_location
prerequisites:
facts:
- fact_id: ring_pressure_increasing
min_confidence: suspects
priority: 8