Files
settled-reach/tooling/line-previewer/src/main.rs
T
jpmschweitzerandClaude Opus 4.6 661769f028 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>
2026-02-13 01:19:46 +01:00

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);
})
}