Schema and Rust Mood enum renamed for author-friendly vocabulary: fond→warm, comfortable→content, worried→anxious, concerned→frustrated. Dropped: analytical (merged into focused), conflicted (modeled as suspicious+warm collision). Added: hostile. Final 8 moods: anxious, frustrated, content, suspicious, warm, hostile, relieved, focused. Neutral = untagged. Resolves Gestalt's blocking issue on #121. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
643 lines
20 KiB
Rust
643 lines
20 KiB
Rust
//! Line previewer CLI — content authoring tool (#193).
|
|
//!
|
|
//! Loads YAML content packs and previews dialogue/monologue lines with
|
|
//! simulated filter context. Designed for content authors to verify line
|
|
//! gating, prerequisite logic, and selection ordering before runtime.
|
|
//!
|
|
//! # Examples
|
|
//!
|
|
//! ```sh
|
|
//! # Monologue: show lines for smuggler character
|
|
//! cargo run --bin line_preview -- --character smuggler
|
|
//!
|
|
//! # Monologue with knowledge context and explain mode
|
|
//! cargo run --bin line_preview -- --character smuggler --knows smuggling_operation --explain
|
|
//!
|
|
//! # Dialogue: show lines for dock-worker at the-last-shift
|
|
//! cargo run --bin line_preview -- --role dock-worker --location the-last-shift \
|
|
//! --access insider --trust real --situation bar_evening
|
|
//!
|
|
//! # Monologue sequence (priority-ordered)
|
|
//! cargo run --bin line_preview -- --character smuggler --location the-terminal --sequence
|
|
//! ```
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::PathBuf;
|
|
use std::process;
|
|
|
|
use clap::Parser;
|
|
|
|
use settled_reach_server::content::line_pool::*;
|
|
use settled_reach_server::content::loader;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "line_preview",
|
|
about = "Preview dialogue and monologue lines from content packs"
|
|
)]
|
|
struct Args {
|
|
/// Content directory root (must contain content.yaml)
|
|
#[arg(long, default_value = "content")]
|
|
content_root: PathBuf,
|
|
|
|
// -- Mode detection --
|
|
|
|
/// Character for monologue mode (smuggler, detective)
|
|
#[arg(long)]
|
|
character: Option<String>,
|
|
|
|
/// NPC role for dialogue mode (e.g., dock-worker, bar-owner)
|
|
#[arg(long)]
|
|
role: Option<String>,
|
|
|
|
// -- Shared --
|
|
|
|
/// Location filter
|
|
#[arg(long)]
|
|
location: Option<String>,
|
|
|
|
// -- Monologue options --
|
|
|
|
/// Trigger filter for monologue (enter_location, observe_npc, etc.)
|
|
#[arg(long)]
|
|
trigger: Option<String>,
|
|
|
|
/// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b)
|
|
#[arg(long)]
|
|
knows: Vec<String>,
|
|
|
|
/// Show priority-ordered monologue sequence
|
|
#[arg(long)]
|
|
sequence: bool,
|
|
|
|
// -- Dialogue options --
|
|
|
|
/// Player access tier for dialogue (public, insider, authority, peer, hostile)
|
|
#[arg(long, default_value = "public")]
|
|
access: String,
|
|
|
|
/// Player trust tier for dialogue (surface, real, secret)
|
|
#[arg(long, default_value = "surface")]
|
|
trust: String,
|
|
|
|
/// Active situations for dialogue (comma-separated: --situation arrival,bar_evening)
|
|
#[arg(long, value_delimiter = ',')]
|
|
situation: Vec<String>,
|
|
|
|
// -- Output control --
|
|
|
|
/// Show filter reasoning for each line
|
|
#[arg(long)]
|
|
explain: bool,
|
|
}
|
|
|
|
fn main() {
|
|
let args = Args::parse();
|
|
|
|
// Load content
|
|
let store = match loader::load_content(&args.content_root) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Error: failed to load content from {:?}: {}",
|
|
args.content_root, e
|
|
);
|
|
process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Build line pool index
|
|
let index = LinePoolIndex::build(&store);
|
|
eprintln!(
|
|
"Loaded: {} dialogue lines, {} monologue lines",
|
|
index.dialogue_line_count(),
|
|
index.monologue_line_count()
|
|
);
|
|
|
|
// Route to mode based on flags
|
|
if args.character.is_some() {
|
|
run_monologue(&index, &args);
|
|
} else if args.role.is_some() {
|
|
run_dialogue(&index, &args);
|
|
} else {
|
|
print_summary(&index);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Summary mode — no mode flags, show what's available
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn print_summary(index: &LinePoolIndex) {
|
|
println!("=== Content Summary ===\n");
|
|
|
|
if !index.dialogue.is_empty() {
|
|
println!("Dialogue pools:");
|
|
for ((loc, role), pool) in &index.dialogue {
|
|
println!(" {loc} / {role}: {} lines", pool.lines.len());
|
|
}
|
|
}
|
|
|
|
if !index.monologue.is_empty() {
|
|
println!("\nMonologue pools:");
|
|
for ((character, loc), pool) in &index.monologue {
|
|
let line_count: usize = pool.by_trigger.values().map(|v| v.len()).sum();
|
|
let triggers: Vec<&str> = pool.by_trigger.keys().map(trigger_str).collect();
|
|
println!(
|
|
" {} @ {loc}: {line_count} lines [{triggers}]",
|
|
character_str(character),
|
|
triggers = triggers.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
println!("\nUse --character <name> for monologue or --role <role> --location <loc> for dialogue.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Monologue mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn run_monologue(index: &LinePoolIndex, args: &Args) {
|
|
let char_str = args.character.as_deref().unwrap();
|
|
let character: Character = parse_or_exit(char_str, "character", "smuggler, detective");
|
|
let known_facts: BTreeSet<&str> = args.knows.iter().map(|s| s.as_str()).collect();
|
|
|
|
let trigger_filter: Option<Trigger> = args.trigger.as_deref().map(|t| {
|
|
parse_or_exit(
|
|
t,
|
|
"trigger",
|
|
"enter_location, observe_npc, hear_sound, observe_anomaly, \
|
|
post_conversation, discover_evidence, witness_interaction, time_idle, return_visit",
|
|
)
|
|
});
|
|
|
|
// Header
|
|
println!("Mode: monologue");
|
|
println!("Character: {}", character_str(&character));
|
|
if let Some(loc) = &args.location {
|
|
println!("Location: {}", loc);
|
|
}
|
|
if let Some(tf) = &trigger_filter {
|
|
println!("Trigger: {}", trigger_str(tf));
|
|
}
|
|
if !known_facts.is_empty() {
|
|
println!("Known facts: {}", args.knows.join(", "));
|
|
}
|
|
println!();
|
|
|
|
// Collect matching pools
|
|
let pools: Vec<_> = index
|
|
.monologue
|
|
.iter()
|
|
.filter(|((c, loc), _)| {
|
|
*c == character && args.location.as_ref().map_or(true, |l| loc == l)
|
|
})
|
|
.collect();
|
|
|
|
if pools.is_empty() {
|
|
println!("No monologue pools found for {}", character_str(&character));
|
|
if let Some(loc) = &args.location {
|
|
println!(" (location filter: {})", loc);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if args.sequence {
|
|
run_monologue_sequence(&pools, &known_facts, trigger_filter.as_ref());
|
|
return;
|
|
}
|
|
|
|
let mut pass_count = 0u32;
|
|
let mut fail_count = 0u32;
|
|
|
|
for ((_, loc), pool) in &pools {
|
|
println!("--- {} ---", loc);
|
|
|
|
for (trigger, lines) in &pool.by_trigger {
|
|
let trigger_match = trigger_filter.as_ref().map_or(true, |tf| trigger == tf);
|
|
|
|
for line in lines {
|
|
let prereq_pass = check_prerequisites(line, &known_facts);
|
|
let overall = trigger_match && prereq_pass;
|
|
|
|
if args.explain {
|
|
let mark = if overall { "PASS" } else { "FAIL" };
|
|
println!(
|
|
"\n [{}] {} (pri:{} cd:{})",
|
|
mark, line.id, line.priority, line.cooldown
|
|
);
|
|
if trigger_filter.is_some() {
|
|
println!(
|
|
" trigger: {} {}",
|
|
trigger_str(trigger),
|
|
if trigger_match { "+" } else { "- (filtered)" }
|
|
);
|
|
} else {
|
|
println!(" trigger: {}", trigger_str(trigger));
|
|
}
|
|
print_prereq_detail(line, &known_facts);
|
|
if !line.tags.is_empty() {
|
|
println!(" tags: [{}]", line.tags.join(", "));
|
|
}
|
|
println!(" \"{}\"", line.text);
|
|
} else if overall {
|
|
println!(
|
|
" [{:>2}] [{}] {} \"{}\"",
|
|
line.priority,
|
|
trigger_str(trigger),
|
|
line.id,
|
|
line.text
|
|
);
|
|
}
|
|
|
|
if overall {
|
|
pass_count += 1;
|
|
} else {
|
|
fail_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
println!("\n{} matched, {} filtered", pass_count, fail_count);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Monologue sequence mode — priority-ordered preview
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn run_monologue_sequence(
|
|
pools: &[(&(Character, String), &IndexedMonologuePool)],
|
|
known_facts: &BTreeSet<&str>,
|
|
trigger_filter: Option<&Trigger>,
|
|
) {
|
|
println!("=== Sequence Preview (priority order) ===\n");
|
|
|
|
// Collect all passing lines across pools and triggers
|
|
let mut all_lines: Vec<(&str, &Trigger, &IndexedMonologueLine)> = Vec::new();
|
|
|
|
for ((_, loc), pool) in pools {
|
|
for (trigger, lines) in &pool.by_trigger {
|
|
if let Some(tf) = trigger_filter {
|
|
if trigger != tf {
|
|
continue;
|
|
}
|
|
}
|
|
for line in lines {
|
|
if check_prerequisites(line, known_facts) {
|
|
all_lines.push((loc.as_str(), trigger, line));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by priority descending, then by id for determinism
|
|
all_lines.sort_by(|a, b| {
|
|
b.2.priority
|
|
.cmp(&a.2.priority)
|
|
.then_with(|| a.2.id.cmp(&b.2.id))
|
|
});
|
|
|
|
if all_lines.is_empty() {
|
|
println!(" (no matching lines)");
|
|
return;
|
|
}
|
|
|
|
for (i, (loc, trigger, line)) in all_lines.iter().enumerate() {
|
|
println!(
|
|
" {:>2}. [pri:{:>2}] [{}] [{}] {}",
|
|
i + 1,
|
|
line.priority,
|
|
trigger_str(trigger),
|
|
loc,
|
|
line.id,
|
|
);
|
|
println!(" \"{}\"", line.text);
|
|
}
|
|
|
|
println!("\n{} lines in sequence", all_lines.len());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dialogue mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn run_dialogue(index: &LinePoolIndex, args: &Args) {
|
|
let role = args.role.as_deref().unwrap();
|
|
let location = args.location.as_deref().unwrap_or_else(|| {
|
|
eprintln!("Error: --location is required for dialogue mode");
|
|
process::exit(1)
|
|
});
|
|
|
|
let access: AccessTier = parse_or_exit(
|
|
&args.access,
|
|
"access",
|
|
"public, insider, authority, peer, hostile",
|
|
);
|
|
let trust: TrustTier = parse_or_exit(&args.trust, "trust", "surface, real, secret");
|
|
|
|
let situations: Vec<Situation> = if args.situation.is_empty() {
|
|
vec![Situation::Arrival]
|
|
} else {
|
|
args.situation
|
|
.iter()
|
|
.map(|s| {
|
|
parse_or_exit(
|
|
s,
|
|
"situation",
|
|
"arrival, shift_start, shift_end, shift_transition, bar_evening, \
|
|
night_shift, investigation, confrontation, social, alone, \
|
|
emergency, routine, observation, greeting",
|
|
)
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// Header
|
|
println!("Mode: dialogue");
|
|
println!("Location: {}, Role: {}", location, role);
|
|
println!(
|
|
"Access: {}, Trust: {}",
|
|
access_str(&access),
|
|
trust_str(&trust)
|
|
);
|
|
println!(
|
|
"Situations: [{}]",
|
|
situations
|
|
.iter()
|
|
.map(situation_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
println!();
|
|
|
|
let key = (location.to_string(), role.to_string());
|
|
let Some(pool) = index.dialogue.get(&key) else {
|
|
println!(
|
|
"No dialogue pool found for {} / {}",
|
|
location, role
|
|
);
|
|
return;
|
|
};
|
|
|
|
if args.explain {
|
|
run_dialogue_explain(pool, access, trust, &situations);
|
|
} else {
|
|
let results = index.query_dialogue(location, role, access, &situations, trust);
|
|
|
|
if results.is_empty() {
|
|
println!("No matching lines.");
|
|
return;
|
|
}
|
|
|
|
for line in &results {
|
|
println!(" {} \"{}\"", line.id, line.text);
|
|
if !line.topic.is_empty() || !line.mood.is_empty() {
|
|
println!(
|
|
" topic: [{}] mood: [{}]",
|
|
line.topic
|
|
.iter()
|
|
.map(topic_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
line.mood
|
|
.iter()
|
|
.map(mood_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
println!("\n{} lines matched", results.len());
|
|
}
|
|
}
|
|
|
|
fn run_dialogue_explain(
|
|
pool: &IndexedDialoguePool,
|
|
access: AccessTier,
|
|
trust: TrustTier,
|
|
situations: &[Situation],
|
|
) {
|
|
let mut pass_count = 0u32;
|
|
let mut fail_count = 0u32;
|
|
|
|
for line in &pool.lines {
|
|
let l1 = line.access.contains(&access);
|
|
let l2 = line.situation.iter().any(|s| situations.contains(s));
|
|
let l3 = trust.meets(line.trust);
|
|
let overall = l1 && l2 && l3;
|
|
let mark = if overall { "PASS" } else { "FAIL" };
|
|
|
|
println!("[{}] {}", mark, line.id);
|
|
println!(
|
|
" L1 access: requires [{}], player has {} {}",
|
|
line.access
|
|
.iter()
|
|
.map(access_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
access_str(&access),
|
|
if l1 { "+" } else { "-" }
|
|
);
|
|
println!(
|
|
" L2 situation: requires [{}], active [{}] {}",
|
|
line.situation
|
|
.iter()
|
|
.map(situation_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
situations
|
|
.iter()
|
|
.map(situation_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
if l2 { "+" } else { "-" }
|
|
);
|
|
println!(
|
|
" L3 trust: requires {}, player has {} {}",
|
|
trust_str(&line.trust),
|
|
trust_str(&trust),
|
|
if l3 { "+" } else { "-" }
|
|
);
|
|
if !line.topic.is_empty() || !line.mood.is_empty() {
|
|
println!(
|
|
" L4 topic: [{}], mood: [{}]",
|
|
line.topic
|
|
.iter()
|
|
.map(topic_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
line.mood
|
|
.iter()
|
|
.map(mood_str)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
println!(" \"{}\"", line.text);
|
|
println!();
|
|
|
|
if overall {
|
|
pass_count += 1;
|
|
} else {
|
|
fail_count += 1;
|
|
}
|
|
}
|
|
|
|
println!("{} passed, {} filtered", pass_count, fail_count);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prerequisite checking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Check monologue line prerequisites against known facts.
|
|
///
|
|
/// Fact prerequisites pass if the fact_id is in the known set.
|
|
/// Entity attributes and relationships require runtime state and are
|
|
/// treated as passing (shown as unchecked in explain mode).
|
|
fn check_prerequisites(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) -> bool {
|
|
let Some(prereqs) = &line.prerequisites else {
|
|
return true;
|
|
};
|
|
|
|
prereqs
|
|
.facts
|
|
.iter()
|
|
.all(|f| known_facts.contains(f.fact_id.as_str()))
|
|
}
|
|
|
|
/// Print prerequisite detail for explain mode.
|
|
fn print_prereq_detail(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) {
|
|
let Some(prereqs) = &line.prerequisites else {
|
|
println!(" prerequisites: none");
|
|
return;
|
|
};
|
|
|
|
println!(" prerequisites:");
|
|
|
|
for fact in &prereqs.facts {
|
|
let has_it = known_facts.contains(fact.fact_id.as_str());
|
|
println!(
|
|
" fact {} >= {} {}",
|
|
fact.fact_id,
|
|
fact.min_confidence,
|
|
if has_it { "+" } else { "- (not in --knows)" }
|
|
);
|
|
}
|
|
|
|
for attr in &prereqs.entity_attributes {
|
|
println!(
|
|
" entity_attr {}.{} == {} ? (unchecked — needs runtime)",
|
|
attr.entity, attr.key, attr.value
|
|
);
|
|
}
|
|
|
|
if let Some(rel) = &prereqs.relationship {
|
|
let target = rel.target.as_deref().unwrap_or("?");
|
|
let state = rel.state.as_deref().unwrap_or("?");
|
|
println!(
|
|
" relationship {} state={} ? (unchecked — needs runtime)",
|
|
target, state
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enum → string helpers (mirrors FromStr in line_pool.rs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn parse_or_exit<T: std::str::FromStr>(s: &str, kind: &str, valid: &str) -> T {
|
|
s.parse().unwrap_or_else(|_| {
|
|
eprintln!("Error: invalid {} '{}'. Valid: {}", kind, s, valid);
|
|
process::exit(1)
|
|
})
|
|
}
|
|
|
|
fn character_str(c: &Character) -> &'static str {
|
|
match c {
|
|
Character::Smuggler => "smuggler",
|
|
Character::Detective => "detective",
|
|
}
|
|
}
|
|
|
|
fn access_str(t: &AccessTier) -> &'static str {
|
|
match t {
|
|
AccessTier::Public => "public",
|
|
AccessTier::Insider => "insider",
|
|
AccessTier::Authority => "authority",
|
|
AccessTier::Peer => "peer",
|
|
AccessTier::Hostile => "hostile",
|
|
}
|
|
}
|
|
|
|
fn trust_str(t: &TrustTier) -> &'static str {
|
|
match t {
|
|
TrustTier::Surface => "surface",
|
|
TrustTier::Real => "real",
|
|
TrustTier::Secret => "secret",
|
|
}
|
|
}
|
|
|
|
fn situation_str(s: &Situation) -> &'static str {
|
|
match s {
|
|
Situation::Arrival => "arrival",
|
|
Situation::ShiftStart => "shift_start",
|
|
Situation::ShiftEnd => "shift_end",
|
|
Situation::ShiftTransition => "shift_transition",
|
|
Situation::BarEvening => "bar_evening",
|
|
Situation::NightShift => "night_shift",
|
|
Situation::Investigation => "investigation",
|
|
Situation::Confrontation => "confrontation",
|
|
Situation::Social => "social",
|
|
Situation::Alone => "alone",
|
|
Situation::Emergency => "emergency",
|
|
Situation::Routine => "routine",
|
|
Situation::Observation => "observation",
|
|
Situation::Greeting => "greeting",
|
|
}
|
|
}
|
|
|
|
fn trigger_str(t: &Trigger) -> &'static str {
|
|
match t {
|
|
Trigger::EnterLocation => "enter_location",
|
|
Trigger::ObserveNpc => "observe_npc",
|
|
Trigger::HearSound => "hear_sound",
|
|
Trigger::ObserveAnomaly => "observe_anomaly",
|
|
Trigger::PostConversation => "post_conversation",
|
|
Trigger::DiscoverEvidence => "discover_evidence",
|
|
Trigger::WitnessInteraction => "witness_interaction",
|
|
Trigger::TimeIdle => "time_idle",
|
|
Trigger::ReturnVisit => "return_visit",
|
|
}
|
|
}
|
|
|
|
fn topic_str(t: &Topic) -> &'static str {
|
|
match t {
|
|
Topic::Colleague => "colleague",
|
|
Topic::Routine => "routine",
|
|
Topic::Cargo => "cargo",
|
|
Topic::Money => "money",
|
|
Topic::Trust => "trust",
|
|
Topic::Danger => "danger",
|
|
Topic::Institution => "institution",
|
|
Topic::Personal => "personal",
|
|
Topic::Investigation => "investigation",
|
|
}
|
|
}
|
|
|
|
fn mood_str(m: &Mood) -> &'static str {
|
|
match m {
|
|
Mood::Anxious => "anxious",
|
|
Mood::Frustrated => "frustrated",
|
|
Mood::Content => "content",
|
|
Mood::Suspicious => "suspicious",
|
|
Mood::Warm => "warm",
|
|
Mood::Hostile => "hostile",
|
|
Mood::Relieved => "relieved",
|
|
Mood::Focused => "focused",
|
|
}
|
|
}
|