//! 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, /// NPC role for dialogue mode (e.g., dock-worker, bar-owner) #[arg(long)] role: Option, // -- Shared -- /// Location filter #[arg(long)] location: Option, // -- Monologue options -- /// Trigger filter for monologue (enter_location, observe_npc, etc.) #[arg(long)] trigger: Option, /// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b) #[arg(long)] knows: Vec, /// 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, // -- 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 for monologue or --role --location 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 = 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 = 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::>() .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::>() .join(", "), line.mood .iter() .map(mood_str) .collect::>() .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::>() .join(", "), access_str(&access), if l1 { "+" } else { "-" } ); println!( " L2 situation: requires [{}], active [{}] {}", line.situation .iter() .map(situation_str) .collect::>() .join(", "), situations .iter() .map(situation_str) .collect::>() .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::>() .join(", "), line.mood .iter() .map(mood_str) .collect::>() .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(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", } }