Files
settled-reach/tooling/line-previewer/src/main.rs
T
jpmschweitzerandClaude Opus 4.7 9a10c6ffd6 chore(deps): rand 0.9.4 + compatible sweep + serde_yaml→serde_norway (#966)
Focused Rust dependency-maintenance pass from the 2026-05-23 security/freshness
review. No CVEs; one advisory cleared and one deprecated crate replaced.

- rand 0.9.2 → 0.9.4 (lockfile): clears RUSTSEC-2026-0097 (unsound with a
  custom logger using rand::rng()). Semver-compatible; rand 0.10 is a separate
  major.
- Compatible-update sweep: ~90 lockfile-only patch/minor bumps (bevy 0.18.0→
  0.18.1, clap 4.5→4.6, rayon 1.11→1.12, pathfinding 4.14→4.15, uuid 1.20→1.23,
  zerocopy, serde_json, tracing-subscriber, etc.). cargo test green.
- serde_yaml 0.9 (deprecated/archived upstream) → serde_norway 0.9, an actively
  maintained drop-in fork. In the server it is test-only (poi.rs round-trip,
  trait_modifiers.rs fixture, tests/news_ticker.rs) so it moves to
  dev-dependencies; line-previewer parses dialogue/monologue pool YAML at
  runtime, so it keeps it as a normal dependency. API is identical (from_str/
  to_string).

news_ticker.rs also picks up its share of the #967 clippy sweep (HashSet/HashMap
→ BTree, doc-list indent) since it is the same file as the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:02:57 +02: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_norway::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_norway::from_str(&yaml_str).unwrap_or_else(|e| {
eprintln!("Error parsing monologue YAML: {e}");
std::process::exit(1);
})
}