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>
This commit is contained in:
Generated
+376
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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<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);
|
||||
})
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub trust: String,
|
||||
pub situation: Vec<String>,
|
||||
pub topic: Vec<String>,
|
||||
pub mood: Vec<String>,
|
||||
#[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<DialogueResult> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<f64>() * 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<String>,
|
||||
#[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<MonologueResult> {
|
||||
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::<DialoguePool>(yaml_str) {
|
||||
print_dialogue_coverage(&pool, path);
|
||||
} else if let Ok(pool) = serde_yaml::from_str::<MonologuePool>(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::<std::collections::BTreeSet<_>>(),
|
||||
pool.lines
|
||||
.iter()
|
||||
.map(|l| l.trust.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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<DialogueLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DialogueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub role: String,
|
||||
pub access: Vec<String>,
|
||||
pub trust: String,
|
||||
pub situation: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub topic: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mood: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub knowledge_grant: Option<KnowledgeGrant>,
|
||||
}
|
||||
|
||||
#[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<MonologueLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MonologueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub trigger: String,
|
||||
pub prerequisites: Option<Prerequisites>,
|
||||
#[serde(default)]
|
||||
pub priority: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub cooldown: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Prerequisites {
|
||||
#[serde(default)]
|
||||
pub facts: Vec<FactPrerequisite>,
|
||||
#[serde(default)]
|
||||
pub entity_attributes: Vec<AttributePrerequisite>,
|
||||
pub relationship: Option<RelationshipPrerequisite>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user