feat(simulation): sprint 12 server — tier system, sound events, KG access, line previewer

Implements 4 completed tickets + partial progress on 2 more:

- #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin)
- #138 Information tag schema (ObserverAccess enum in knowledge/types.rs)
- #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring)
- #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes)
- #94 Active tier simulation (in progress — With<ActiveSim> filters)
- #139 Component-level access control (in progress — filter_by_access)

Updates snapshot fixtures and test golden files for new sound_events field.

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