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:
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user