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>
235 lines
7.1 KiB
Rust
235 lines
7.1 KiB
Rust
//! 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);
|
|
})
|
|
}
|