diff --git a/tooling/line-previewer/Cargo.lock b/tooling/line-previewer/Cargo.lock new file mode 100644 index 000000000..5d59c4985 --- /dev/null +++ b/tooling/line-previewer/Cargo.lock @@ -0,0 +1,376 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "line-previewer" +version = "0.1.0" +dependencies = [ + "clap", + "rand", + "rand_chacha", + "serde", + "serde_yaml", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tooling/line-previewer/Cargo.toml b/tooling/line-previewer/Cargo.toml new file mode 100644 index 000000000..74f03d3be --- /dev/null +++ b/tooling/line-previewer/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "line-previewer" +version = "0.1.0" +edition = "2021" +description = "CLI tool to preview dialogue/monologue line selection for a given game state." + +[[bin]] +name = "line-previewer" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +clap = { version = "4", features = ["derive"] } +rand = "0.9" +rand_chacha = "0.9" diff --git a/tooling/line-previewer/src/main.rs b/tooling/line-previewer/src/main.rs new file mode 100644 index 000000000..27e952dbb --- /dev/null +++ b/tooling/line-previewer/src/main.rs @@ -0,0 +1,234 @@ +//! 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, + + /// 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, + + /// Topic filter(s): colleague, routine, cargo, etc. + #[arg(long, value_delimiter = ',')] + topic: Vec, + + /// Mood filter(s): fond, comfortable, worried, etc. + #[arg(long, value_delimiter = ',')] + mood: Vec, + + /// 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, + + /// 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, + + /// 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 = 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); + }) +} diff --git a/tooling/line-previewer/src/pipeline.rs b/tooling/line-previewer/src/pipeline.rs new file mode 100644 index 000000000..3a8025b3f --- /dev/null +++ b/tooling/line-previewer/src/pipeline.rs @@ -0,0 +1,585 @@ +//! 4-layer dialogue selection pipeline (D-028) and monologue trigger evaluation. +//! +//! Layer 1: Access tier filter (hard filter) +//! Layer 2: Situation filter (hard filter) +//! Layer 3: Trust level filter (hard filter) +//! Layer 4: Weighted selection by topic + mood match + +use crate::types::{DialogueLine, DialoguePool, MonologueLine, MonologuePool}; +use rand::prelude::*; +use rand_chacha::ChaCha20Rng; +use std::path::Path; + +// --- Dialogue pipeline --- + +pub struct DialogueContext { + pub access: Vec, + pub trust: String, + pub situation: Vec, + pub topic: Vec, + pub mood: Vec, + #[allow(dead_code)] + pub seed: u64, +} + +pub struct DialogueResult { + pub line_id: String, + pub text: String, + pub passed: bool, + pub weight: f64, + pub access_pass: bool, + pub trust_pass: bool, + pub situation_pass: bool, + pub topic_match: f64, + pub mood_match: f64, + pub access_detail: String, + pub trust_detail: String, + pub situation_detail: String, +} + +pub fn evaluate_dialogue(pool: &DialoguePool, ctx: &DialogueContext) -> Vec { + pool.lines + .iter() + .map(|line| evaluate_dialogue_line(line, ctx)) + .collect() +} + +fn evaluate_dialogue_line(line: &DialogueLine, ctx: &DialogueContext) -> DialogueResult { + // Layer 1: Access tier — at least one of the player's access tiers must match + let access_pass = line.access.iter().any(|a| ctx.access.contains(a)); + let access_detail = if access_pass { + let matched: Vec<_> = line + .access + .iter() + .filter(|a| ctx.access.contains(a)) + .collect(); + format!( + "matched: {}", + matched + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ) + } else { + format!( + "line requires [{}], player has [{}]", + line.access.join(", "), + ctx.access.join(", ") + ) + }; + + // Layer 2: Situation — at least one active situation must match + let situation_pass = if ctx.situation.is_empty() { + true // no situation filter = all situations match + } else { + line.situation.iter().any(|s| ctx.situation.contains(s)) + }; + let situation_detail = if situation_pass { + if ctx.situation.is_empty() { + "no filter applied".to_string() + } else { + let matched: Vec<_> = line + .situation + .iter() + .filter(|s| ctx.situation.contains(s)) + .collect(); + format!( + "matched: {}", + matched + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ) + } + } else { + format!( + "line requires [{}], active: [{}]", + line.situation.join(", "), + ctx.situation.join(", ") + ) + }; + + // Layer 3: Trust — line's trust level must be <= player's trust level + let trust_order = trust_rank(&line.trust); + let player_trust = trust_rank(&ctx.trust); + let trust_pass = trust_order <= player_trust; + let trust_detail = if trust_pass { + format!("line={} <= player={}", line.trust, ctx.trust) + } else { + format!( + "line requires {} but player only has {}", + line.trust, ctx.trust + ) + }; + + // Layer 4: Weighted selection by topic + mood overlap + let topic_match = if ctx.topic.is_empty() || line.topic.is_empty() { + 0.5 // neutral weight when no topic filter + } else { + let matches = line.topic.iter().filter(|t| ctx.topic.contains(t)).count(); + if matches > 0 { + 0.5 + 0.5 * (matches as f64 / line.topic.len().max(1) as f64) + } else { + 0.3 // slight penalty for topic mismatch but not exclusion + } + }; + + let mood_match = if ctx.mood.is_empty() || line.mood.is_empty() { + 0.5 + } else { + let matches = line.mood.iter().filter(|m| ctx.mood.contains(m)).count(); + if matches > 0 { + 0.5 + 0.5 * (matches as f64 / line.mood.len().max(1) as f64) + } else { + 0.3 + } + }; + + let passed = access_pass && situation_pass && trust_pass; + let weight = if passed { + topic_match * mood_match + } else { + 0.0 + }; + + DialogueResult { + line_id: line.id.clone(), + text: line.text.clone(), + passed, + weight, + access_pass, + trust_pass, + situation_pass, + topic_match, + mood_match, + access_detail, + trust_detail, + situation_detail, + } +} + +fn trust_rank(trust: &str) -> u8 { + match trust { + "surface" => 0, + "real" => 1, + "secret" => 2, + _ => 0, + } +} + +pub fn print_dialogue_results(results: &[DialogueResult], verbose: bool, seed: u64) { + let passing: Vec<_> = results.iter().filter(|r| r.passed).collect(); + let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect(); + + println!( + "Dialogue evaluation: {} total, {} eligible, {} filtered out\n", + results.len(), + passing.len(), + failing.len() + ); + + for r in &passing { + println!(" [PASS] {}: {:?}", r.line_id, r.text); + println!( + " weight={:.2} topic={:.2} mood={:.2}", + r.weight, r.topic_match, r.mood_match + ); + if verbose { + println!(" access: {}", r.access_detail); + println!(" trust: {}", r.trust_detail); + println!(" situation: {}", r.situation_detail); + } + } + + if verbose && !failing.is_empty() { + println!("\n Filtered out:"); + for r in &failing { + let reasons: Vec<&str> = [ + if !r.access_pass { Some("access") } else { None }, + if !r.trust_pass { Some("trust") } else { None }, + if !r.situation_pass { + Some("situation") + } else { + None + }, + ] + .iter() + .filter_map(|x| *x) + .collect(); + + println!( + " [FAIL] {}: {:?} (failed: {})", + r.line_id, + r.text, + reasons.join(", ") + ); + if !r.access_pass { + println!(" access: {}", r.access_detail); + } + if !r.trust_pass { + println!(" trust: {}", r.trust_detail); + } + if !r.situation_pass { + println!(" situation: {}", r.situation_detail); + } + } + } + + // Weighted selection + if !passing.is_empty() { + let total_weight: f64 = passing.iter().map(|r| r.weight).sum(); + if total_weight > 0.0 { + let mut rng = ChaCha20Rng::seed_from_u64(seed); + let roll: f64 = rng.random::() * total_weight; + let mut cumulative = 0.0; + for r in &passing { + cumulative += r.weight; + if cumulative >= roll { + println!( + "\n Selected: {} (weight={:.2}/{:.2})", + r.line_id, r.weight, total_weight + ); + break; + } + } + } + } else { + println!("\n No eligible lines for this context."); + } +} + +// --- Monologue pipeline --- + +pub struct MonologueContext { + pub trigger: String, + pub known_facts: Vec, + #[allow(dead_code)] + pub seed: u64, +} + +pub struct MonologueResult { + pub line_id: String, + pub text: String, + pub passed: bool, + pub trigger_pass: bool, + pub prereq_pass: bool, + pub priority: i32, + pub trigger_detail: String, + pub prereq_detail: String, +} + +pub fn evaluate_monologue(pool: &MonologuePool, ctx: &MonologueContext) -> Vec { + let mut results: Vec<_> = pool + .lines + .iter() + .map(|line| evaluate_monologue_line(line, ctx)) + .collect(); + + // Sort by priority (higher first), then by line_id for determinism + results.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then_with(|| a.line_id.cmp(&b.line_id)) + }); + + results +} + +fn evaluate_monologue_line(line: &MonologueLine, ctx: &MonologueContext) -> MonologueResult { + // Trigger match + let trigger_pass = line.trigger == ctx.trigger; + let trigger_detail = if trigger_pass { + format!("matched: {}", ctx.trigger) + } else { + format!("line={}, context={}", line.trigger, ctx.trigger) + }; + + // Prerequisite evaluation + let (prereq_pass, prereq_detail) = if let Some(ref prereqs) = line.prerequisites { + evaluate_prerequisites(prereqs, &ctx.known_facts) + } else { + (true, "no prerequisites".to_string()) + }; + + let passed = trigger_pass && prereq_pass; + let priority = line.priority.unwrap_or(5); + + MonologueResult { + line_id: line.id.clone(), + text: line.text.clone(), + passed, + trigger_pass, + prereq_pass, + priority, + trigger_detail, + prereq_detail, + } +} + +fn evaluate_prerequisites( + prereqs: &crate::types::Prerequisites, + known_facts: &[String], +) -> (bool, String) { + let mut details = Vec::new(); + let mut all_pass = true; + + for fact_req in &prereqs.facts { + let has_fact = known_facts.contains(&fact_req.fact_id); + if has_fact { + details.push(format!( + "fact:{} >= {} [PASS]", + fact_req.fact_id, fact_req.min_confidence + )); + } else { + details.push(format!( + "fact:{} >= {} [FAIL: not known]", + fact_req.fact_id, fact_req.min_confidence + )); + all_pass = false; + } + } + + // Entity attributes and relationships are not yet checkable without full game state + if !prereqs.entity_attributes.is_empty() { + details.push(format!( + "{} entity_attribute prereqs (skipped: no game state)", + prereqs.entity_attributes.len() + )); + } + + if prereqs.relationship.is_some() { + details.push("relationship prereq (skipped: no game state)".to_string()); + } + + if details.is_empty() { + (true, "no prerequisites".to_string()) + } else { + (all_pass, details.join("; ")) + } +} + +pub fn print_monologue_results(results: &[MonologueResult], verbose: bool, _seed: u64) { + let passing: Vec<_> = results.iter().filter(|r| r.passed).collect(); + let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect(); + + println!( + "Monologue evaluation: {} total, {} eligible, {} filtered out\n", + results.len(), + passing.len(), + failing.len() + ); + + for r in &passing { + println!( + " [PASS] {} (priority={}): {:?}", + r.line_id, r.priority, r.text + ); + if verbose { + println!(" trigger: {}", r.trigger_detail); + println!(" prereqs: {}", r.prereq_detail); + } + } + + if verbose && !failing.is_empty() { + println!("\n Filtered out:"); + for r in &failing { + let reasons: Vec<&str> = [ + if !r.trigger_pass { + Some("trigger") + } else { + None + }, + if !r.prereq_pass { + Some("prerequisites") + } else { + None + }, + ] + .iter() + .filter_map(|x| *x) + .collect(); + + println!( + " [FAIL] {}: {:?} (failed: {})", + r.line_id, + r.text, + reasons.join(", ") + ); + if !r.trigger_pass { + println!(" trigger: {}", r.trigger_detail); + } + if !r.prereq_pass { + println!(" prereqs: {}", r.prereq_detail); + } + } + } + + // Selection: highest priority eligible line + if let Some(selected) = passing.first() { + println!( + "\n Selected: {} (priority={})", + selected.line_id, selected.priority + ); + } else { + println!("\n No eligible lines for this trigger."); + } +} + +// --- Coverage report --- + +pub fn print_coverage_report(yaml_str: &str, path: &Path) { + // Try dialogue first, then monologue + if let Ok(pool) = serde_yaml::from_str::(yaml_str) { + print_dialogue_coverage(&pool, path); + } else if let Ok(pool) = serde_yaml::from_str::(yaml_str) { + print_monologue_coverage(&pool, path); + } else { + eprintln!("Error: file is neither dialogue nor monologue YAML"); + std::process::exit(1); + } +} + +fn print_dialogue_coverage(pool: &DialoguePool, path: &Path) { + let access_tiers = ["public", "insider", "authority", "peer", "hostile"]; + let trust_levels = ["surface", "real", "secret"]; + let situations = [ + "arrival", + "shift_start", + "shift_end", + "shift_transition", + "bar_evening", + "night_shift", + "investigation", + "confrontation", + "social", + "alone", + "emergency", + "routine", + "observation", + ]; + + println!( + "Coverage report: {} (role: {}, location: {})\n", + path.display(), + pool.role, + pool.location + ); + + let mut gaps = Vec::new(); + + for access in &access_tiers { + for trust in &trust_levels { + for situation in &situations { + let eligible = pool + .lines + .iter() + .filter(|line| { + line.access.iter().any(|a| a == access) + && trust_rank(&line.trust) <= trust_rank(trust) + && line.situation.iter().any(|s| s == situation) + }) + .count(); + + if eligible == 0 { + gaps.push(format!( + " access={:<10} trust={:<8} situation={:<18} -> 0 lines", + access, trust, situation + )); + } + } + } + } + + if gaps.is_empty() { + println!(" Full coverage! Every access/trust/situation combination has at least one eligible line."); + } else { + println!( + " {} gaps found (access/trust/situation combos with zero eligible lines):\n", + gaps.len() + ); + // Show first 20 gaps + for gap in gaps.iter().take(20) { + println!("{gap}"); + } + if gaps.len() > 20 { + println!(" ... and {} more", gaps.len() - 20); + } + } + + println!( + "\n Total lines: {}, Access tiers used: {:?}, Trust levels used: {:?}", + pool.lines.len(), + pool.lines + .iter() + .flat_map(|l| l.access.iter()) + .collect::>(), + pool.lines + .iter() + .map(|l| l.trust.as_str()) + .collect::>(), + ); +} + +fn print_monologue_coverage(pool: &MonologuePool, path: &Path) { + let triggers = [ + "enter_location", + "observe_npc", + "hear_sound", + "observe_anomaly", + "post_conversation", + "discover_evidence", + "witness_interaction", + "time_idle", + "return_visit", + ]; + + println!( + "Coverage report: {} (character: {}, location: {})\n", + path.display(), + pool.character, + pool.location + ); + + let mut covered = Vec::new(); + let mut uncovered = Vec::new(); + + for trigger in &triggers { + let count = pool + .lines + .iter() + .filter(|line| line.trigger == *trigger) + .count(); + if count > 0 { + covered.push(format!(" {:<25} {} lines", trigger, count)); + } else { + uncovered.push(format!(" {:<25} 0 lines", trigger)); + } + } + + if !covered.is_empty() { + println!(" Covered triggers:"); + for line in &covered { + println!("{line}"); + } + } + + if !uncovered.is_empty() { + println!("\n Uncovered triggers:"); + for line in &uncovered { + println!("{line}"); + } + } + + let with_prereqs = pool + .lines + .iter() + .filter(|l| l.prerequisites.is_some()) + .count(); + println!( + "\n Total lines: {}, With prerequisites: {}", + pool.lines.len(), + with_prereqs + ); +} diff --git a/tooling/line-previewer/src/types.rs b/tooling/line-previewer/src/types.rs new file mode 100644 index 000000000..afa76e975 --- /dev/null +++ b/tooling/line-previewer/src/types.rs @@ -0,0 +1,90 @@ +//! Content types for dialogue and monologue pools. +//! Mirrors the JSON Schema definitions in content/_schema/. +//! Fields that appear unused are required for YAML deserialization fidelity. + +#![allow(dead_code)] + +use serde::Deserialize; + +// --- Dialogue Pool --- + +#[derive(Debug, Deserialize)] +pub struct DialoguePool { + pub location: String, + pub role: String, + pub lines: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct DialogueLine { + pub id: String, + pub text: String, + pub role: String, + pub access: Vec, + pub trust: String, + pub situation: Vec, + #[serde(default)] + pub topic: Vec, + #[serde(default)] + pub mood: Vec, + #[serde(default)] + pub tags: Vec, + pub knowledge_grant: Option, +} + +#[derive(Debug, Deserialize)] +pub struct KnowledgeGrant { + pub fact_id: String, + pub confidence: String, +} + +// --- Monologue Pool --- + +#[derive(Debug, Deserialize)] +pub struct MonologuePool { + pub character: String, + pub location: String, + pub lines: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MonologueLine { + pub id: String, + pub text: String, + pub trigger: String, + pub prerequisites: Option, + #[serde(default)] + pub priority: Option, + #[serde(default)] + pub cooldown: Option, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Prerequisites { + #[serde(default)] + pub facts: Vec, + #[serde(default)] + pub entity_attributes: Vec, + pub relationship: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FactPrerequisite { + pub fact_id: String, + pub min_confidence: String, +} + +#[derive(Debug, Deserialize)] +pub struct AttributePrerequisite { + pub entity: String, + pub key: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +pub struct RelationshipPrerequisite { + pub target: Option, + pub state: Option, +} diff --git a/tooling/line-previewer/test-fixtures/sample-dialogue.yaml b/tooling/line-previewer/test-fixtures/sample-dialogue.yaml new file mode 100644 index 000000000..bcaae2733 --- /dev/null +++ b/tooling/line-previewer/test-fixtures/sample-dialogue.yaml @@ -0,0 +1,48 @@ +# Test fixture: dock-worker dialogue at The Terminal +location: the-terminal +role: dock-worker +lines: + - id: "terminal_d_001" + text: "Morning. Manifest's ready if you need it." + role: dock-worker + access: [public, insider] + trust: surface + situation: [shift_start, routine] + topic: [routine, cargo] + mood: [comfortable] + + - id: "terminal_d_002" + text: "You're new around here? Or just... visiting?" + role: dock-worker + access: [public, authority] + trust: surface + situation: [arrival, social] + topic: [colleague] + mood: [comfortable, suspicious] + + - id: "terminal_d_003" + text: "Between you and me, the shift change is when things get... creative." + role: dock-worker + access: [insider] + trust: real + situation: [shift_transition, social] + topic: [cargo, money] + mood: [comfortable] + + - id: "terminal_d_004" + text: "I know what's in those crates. And I know you know." + role: dock-worker + access: [insider] + trust: secret + situation: [confrontation, investigation] + topic: [cargo, trust, danger] + mood: [worried, conflicted] + + - id: "terminal_d_005" + text: "Just another shift. Nothing special." + role: dock-worker + access: [public] + trust: surface + situation: [routine, shift_start, shift_end] + topic: [routine] + mood: [comfortable] diff --git a/tooling/line-previewer/test-fixtures/sample-monologue.yaml b/tooling/line-previewer/test-fixtures/sample-monologue.yaml new file mode 100644 index 000000000..a31d197d0 --- /dev/null +++ b/tooling/line-previewer/test-fixtures/sample-monologue.yaml @@ -0,0 +1,36 @@ +# Test fixture: smuggler monologue at The Terminal +character: smuggler +location: the-terminal +lines: + - id: "terminal_m_s_001" + text: "The Terminal. Same hum, same faces, same game." + trigger: enter_location + priority: 5 + + - id: "terminal_m_s_002" + text: "Kael's at his station. Looks tense today." + trigger: observe_npc + priority: 7 + + - id: "terminal_m_s_003" + text: "That manifest doesn't add up. Kael knows something." + trigger: observe_anomaly + prerequisites: + facts: + - fact_id: manifest_discrepancy + min_confidence: suspects + priority: 9 + + - id: "terminal_m_s_004" + text: "Should check in on the next shipment. Can't be too careful." + trigger: time_idle + priority: 3 + + - id: "terminal_m_s_005" + text: "Something's different today. The usual rhythm is off." + trigger: enter_location + prerequisites: + facts: + - fact_id: ring_pressure_increasing + min_confidence: suspects + priority: 8