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",
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,7 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +423,7 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 9;
|
||||
pub const PROTOCOL_VERSION: u8 = 10;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -28,7 +28,7 @@ pub const PROTOCOL_VERSION: u8 = 9;
|
||||
/// v7 adds: pending_recognitions (#423, D-060 cognitive delay).
|
||||
/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline).
|
||||
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
/// v10 adds: sound_events (#124, D-038 server sound event pipeline).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 9.
|
||||
@@ -79,6 +79,12 @@ pub struct ObserverSnapshot {
|
||||
/// Sorted ascending for deterministic output. Client can safely ignore.
|
||||
#[serde(default)]
|
||||
pub blocked_entities: Vec<u64>,
|
||||
/// Sound events audible to the observer this tick (#124, D-038).
|
||||
/// Filtered by D-018 range categories relative to player position.
|
||||
/// Client AudioManager maps each event's kind to an audio asset.
|
||||
/// Empty when no sounds are in range.
|
||||
#[serde(default)]
|
||||
pub sound_events: Vec<crate::simulation::sound::SoundEvent>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::knowledge::types::{
|
||||
use crate::npc;
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
|
||||
@@ -103,6 +104,10 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
// Default position — will be overridden by routine system on first phase transition
|
||||
entity_commands.insert(TilePosition::new(0, 0, 0));
|
||||
|
||||
// All spawned NPCs start in the Active tier (D-026, #94).
|
||||
// The tier transition system (#99) will demote NPCs that are far from the player.
|
||||
entity_commands.insert(ActiveSim);
|
||||
|
||||
// Mark NPC as interactable for proximity-based verb detection (#413)
|
||||
entity_commands.insert(Interactable);
|
||||
|
||||
|
||||
@@ -231,6 +231,68 @@ impl Default for KnowledgeGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Access control filter (#139, D-010 principle 2) ---
|
||||
|
||||
/// Component-level access control filter.
|
||||
///
|
||||
/// Called by the observer snapshot builder before including a component's
|
||||
/// sensitive data in the snapshot. Returns `true` if `observer_id` is
|
||||
/// permitted to read a component tagged with `rule` on entity `target_id`.
|
||||
///
|
||||
/// Design: coarse-grained component-level check. A component either passes
|
||||
/// or fails as a whole. See `ObserverAccess` for available rules.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `observer_id`: StableId of the entity requesting access.
|
||||
/// - `target_id`: StableId of the entity that owns the component.
|
||||
/// - `rule`: The access rule attached to the component via `AccessRule`.
|
||||
/// - `kg`: The observer's `KnowledgeGraph` (used for relationship and knowledge checks).
|
||||
pub fn filter_by_access(
|
||||
observer_id: StableId,
|
||||
target_id: StableId,
|
||||
rule: &ObserverAccess,
|
||||
kg: &KnowledgeGraph,
|
||||
) -> bool {
|
||||
match rule {
|
||||
// Public data is always readable.
|
||||
ObserverAccess::Public => true,
|
||||
|
||||
// OwnerOnly: only the entity that owns the component can read it.
|
||||
// Primary use case: player's own inventory (D-065).
|
||||
ObserverAccess::OwnerOnly => observer_id == target_id,
|
||||
|
||||
// FactionOnly: observer must have a recorded faction match with the target.
|
||||
// Stored as a "faction_id" key in the target's known_attributes.
|
||||
// Full faction system deferred; approximation via knowledge attributes.
|
||||
ObserverAccess::FactionOnly(faction_id) => kg
|
||||
.entities
|
||||
.get(&target_id)
|
||||
.and_then(|k| k.known_attributes.get("faction_id"))
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.is_some_and(|id| id == faction_id.0),
|
||||
|
||||
// RelationshipGated: observer must have a relationship score >= threshold.
|
||||
// Threshold is 0–100; maps to RelationshipState enum values.
|
||||
ObserverAccess::RelationshipGated(threshold) => {
|
||||
let score: i32 = match kg.relationship_with(&target_id) {
|
||||
RelationshipState::Unknown => 0,
|
||||
RelationshipState::Known => 25,
|
||||
RelationshipState::PersonOfInterest => 40,
|
||||
RelationshipState::Friendly => 75,
|
||||
RelationshipState::Hostile => 5,
|
||||
};
|
||||
score >= *threshold
|
||||
}
|
||||
|
||||
// KnowledgeGated: observer must have a specific fact in their knowledge graph.
|
||||
// Used for "you only see this if you know about it" information walls.
|
||||
ObserverAccess::KnowledgeGated(flag) => {
|
||||
let fact_id = FactId(flag.clone());
|
||||
kg.knows_fact(&fact_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -468,4 +530,187 @@ mod tests {
|
||||
let ids: Vec<u64> = g.known_entities_iter().map(|(id, _)| id.0).collect();
|
||||
assert_eq!(ids, vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
// --- filter_by_access tests (#139, D-010 principle 2) ---
|
||||
//
|
||||
// Sprint 12 test focus: "negative tests — blocked component not returned
|
||||
// for non-owner observer". These tests verify each ObserverAccess variant
|
||||
// and confirm the critical negative case: OwnerOnly blocks non-owner.
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_public_always_passes() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(
|
||||
filter_by_access(observer, target, &ObserverAccess::Public, &kg),
|
||||
"Public access rule must always return true"
|
||||
);
|
||||
|
||||
// Public is symmetric — even self-observation passes
|
||||
assert!(filter_by_access(observer, observer, &ObserverAccess::Public, &kg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_owner_only_blocks_non_owner() {
|
||||
// THE critical negative test (Sprint 12 joint briefing).
|
||||
// A non-owner observer must NOT get access to OwnerOnly data.
|
||||
let observer = StableId(1); // some other entity
|
||||
let target = StableId(2); // owns the component
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &ObserverAccess::OwnerOnly, &kg),
|
||||
"OwnerOnly must block a non-owner observer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_owner_only_allows_owner() {
|
||||
// The entity observing its own component must be allowed.
|
||||
let owner = StableId(5);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(
|
||||
filter_by_access(owner, owner, &ObserverAccess::OwnerOnly, &kg),
|
||||
"OwnerOnly must allow the owner to read their own component"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_owner_only_distinct_ids_always_block() {
|
||||
// Additional negative: even adjacent IDs are different owners.
|
||||
let kg = KnowledgeGraph::new();
|
||||
for id in 1u64..=10 {
|
||||
assert!(
|
||||
!filter_by_access(StableId(id), StableId(id + 1), &ObserverAccess::OwnerOnly, &kg),
|
||||
"StableId({id}) should not match StableId({})", id + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_knowledge_gated_blocks_without_knowledge() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let kg = KnowledgeGraph::new(); // empty — no facts known
|
||||
|
||||
let rule = ObserverAccess::KnowledgeGated("contraband.ring_exists".to_string());
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &rule, &kg),
|
||||
"KnowledgeGated must block when observer lacks the required fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_knowledge_gated_passes_with_knowledge() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let flag = "contraband.ring_exists";
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId(flag.to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
)]);
|
||||
|
||||
let rule = ObserverAccess::KnowledgeGated(flag.to_string());
|
||||
|
||||
assert!(
|
||||
filter_by_access(observer, target, &rule, &kg),
|
||||
"KnowledgeGated must pass when observer has the required fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_knowledge_gated_wrong_flag_blocks() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("contraband.ring_exists".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
)]);
|
||||
|
||||
// Gated on a DIFFERENT flag — observer doesn't have this one
|
||||
let rule = ObserverAccess::KnowledgeGated("conspiracy.mastermind".to_string());
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &rule, &kg),
|
||||
"KnowledgeGated must block when observer has a different fact, not this one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_relationship_gated_blocks_unknown() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let kg = KnowledgeGraph::new(); // observer has no knowledge of target
|
||||
|
||||
// Threshold 25 = Known level — Unknown (score=0) should fail
|
||||
let rule = ObserverAccess::RelationshipGated(25);
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &rule, &kg),
|
||||
"RelationshipGated must block when observer's relationship is Unknown (score 0)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_relationship_gated_passes_for_friendly() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(target, make_position(5, 5), 100);
|
||||
kg.set_relationship(&target, RelationshipState::Friendly);
|
||||
|
||||
// Threshold 50 — Friendly (score=75) should pass
|
||||
let rule = ObserverAccess::RelationshipGated(50);
|
||||
|
||||
assert!(
|
||||
filter_by_access(observer, target, &rule, &kg),
|
||||
"RelationshipGated must pass when observer has Friendly relationship (score 75 >= 50)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_relationship_gated_blocks_hostile() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(target, make_position(5, 5), 100);
|
||||
kg.set_relationship(&target, RelationshipState::Hostile);
|
||||
|
||||
// Threshold 25 — Hostile (score=5) should fail
|
||||
let rule = ObserverAccess::RelationshipGated(25);
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &rule, &kg),
|
||||
"RelationshipGated must block Hostile relationship (score 5 < threshold 25)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_access_faction_only_blocks_without_faction_attribute() {
|
||||
let observer = StableId(1);
|
||||
let target = StableId(2);
|
||||
let kg = KnowledgeGraph::new(); // no knowledge of target
|
||||
|
||||
let faction = StableId(99);
|
||||
let rule = ObserverAccess::FactionOnly(faction);
|
||||
|
||||
assert!(
|
||||
!filter_by_access(observer, target, &rule, &kg),
|
||||
"FactionOnly must block when faction attribute is not known"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,45 @@ impl Default for DecayThresholds {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Information Access Control (D-010 principle 2, #138) ---
|
||||
|
||||
/// Access rule governing who can read a component's sensitive data.
|
||||
///
|
||||
/// The observer snapshot builder checks `AccessRule` before including data
|
||||
/// in a snapshot. This is the schema; enforcement is in #139 (access control).
|
||||
///
|
||||
/// Design: coarse-grained component-level tags rather than per-field.
|
||||
/// A component either passes or fails its access check as a whole.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ObserverAccess {
|
||||
/// Anyone can observe this data. Default for non-sensitive components.
|
||||
Public,
|
||||
/// Only the entity that owns this component (e.g. player's own inventory).
|
||||
OwnerOnly,
|
||||
/// Members of a specific faction can observe this data.
|
||||
FactionOnly(StableId),
|
||||
/// Observers with a relationship score at or above the threshold can read.
|
||||
/// Threshold is on a 0–100 scale matching the NPC relationship axes (D-024).
|
||||
RelationshipGated(i32),
|
||||
/// Only observers who have a specific knowledge flag (FactId) can read.
|
||||
/// Used for "you only see this if you know about it" information walls.
|
||||
KnowledgeGated(String),
|
||||
}
|
||||
|
||||
impl Default for ObserverAccess {
|
||||
fn default() -> Self {
|
||||
Self::Public
|
||||
}
|
||||
}
|
||||
|
||||
/// Component that attaches an access rule to an entity's sensitive data.
|
||||
///
|
||||
/// When an observer snapshot is built, `filter_by_access` (implemented in
|
||||
/// #139) checks this rule before including component data in the snapshot.
|
||||
/// Components without `AccessRule` are treated as `ObserverAccess::Public`.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct AccessRule(pub ObserverAccess);
|
||||
|
||||
// --- Observer Snapshot Integration ---
|
||||
|
||||
/// How an entity appears in the observer snapshot.
|
||||
|
||||
@@ -8,6 +8,7 @@ use bevy_ecs::prelude::*;
|
||||
use crate::npc::{DailyRoutine, Npc};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::pathfinding::PathRequest;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::{DayPhase, SimulationTime};
|
||||
|
||||
/// Resource tracking the previous day phase for transition detection.
|
||||
@@ -28,11 +29,14 @@ impl Default for PreviousDayPhase {
|
||||
|
||||
/// System: detect day-phase transitions and issue PathRequests for NPC routines.
|
||||
/// Runs after advance_tick so the current phase is up-to-date.
|
||||
///
|
||||
/// Scoped to `ActiveSim` NPCs — only active-tier NPCs receive routine-based
|
||||
/// PathRequests on phase transitions (D-026, #94).
|
||||
pub fn check_phase_transition(
|
||||
time: Res<SimulationTime>,
|
||||
mut previous: ResMut<PreviousDayPhase>,
|
||||
mut commands: Commands,
|
||||
npcs: Query<(Entity, &TilePosition, &DailyRoutine), With<Npc>>,
|
||||
npcs: Query<(Entity, &TilePosition, &DailyRoutine), (With<Npc>, With<ActiveSim>)>,
|
||||
) {
|
||||
let current_phase = time.day_phase();
|
||||
let current_day = time.day();
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
@@ -56,6 +57,7 @@ pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
registry: Res<EntityRegistry>,
|
||||
sound_queue: Option<Res<SoundEventQueue>>,
|
||||
mut observer_query: Query<
|
||||
(
|
||||
Entity,
|
||||
@@ -176,6 +178,14 @@ pub fn compute_observer_snapshot(
|
||||
.map(|buf| buf.take())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect sound events audible to the observer (D-038, #124).
|
||||
// Filter by D-018 range: only events the player can hear based on distance.
|
||||
let sound_events = if let Some(ref queue) = sound_queue {
|
||||
queue.audible_at(_observer_pos).cloned().collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Build pending recognitions from CognitiveDelay (#423, D-060)
|
||||
let pending_recognitions = cognitive_delay_opt
|
||||
.map(|delay| {
|
||||
@@ -215,6 +225,7 @@ pub fn compute_observer_snapshot(
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
scan_events,
|
||||
sound_events,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod rng;
|
||||
pub mod sound;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
@@ -25,11 +26,15 @@ pub struct SimulationPlugin;
|
||||
|
||||
impl Plugin for SimulationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Tier marker components (D-026) — must register before behavior systems
|
||||
app.add_plugins(tier::TierPlugin);
|
||||
|
||||
// Initialize core simulation resources
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.init_resource::<sound::SoundEventQueue>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -42,6 +47,9 @@ impl Plugin for SimulationPlugin {
|
||||
contraband::check_contraband_scan
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
sound::collect_sound_events
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ use bevy_ecs::prelude::*;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::MoveIntent;
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Movement speed component. Controls ticks between path steps.
|
||||
/// Default: 1 step per tick. Higher values = slower movement.
|
||||
@@ -48,9 +49,13 @@ impl MovementSpeed {
|
||||
|
||||
/// System: NPC entities with ComputedPath advance along their path.
|
||||
/// Creates MoveIntent for the next step. Removes ComputedPath when complete.
|
||||
///
|
||||
/// Scoped to `ActiveSim` NPCs — only entities in the Active tier execute
|
||||
/// path movement each tick (D-026, #94). Background/StateSaved NPCs do not
|
||||
/// process path steps.
|
||||
pub fn follow_paths(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With<Npc>>,
|
||||
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With<Npc>, With<ActiveSim>)>,
|
||||
) {
|
||||
for (entity, mut path, speed_opt) in query.iter_mut() {
|
||||
if let Some(mut speed) = speed_opt {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Sound event system — server side (#124)
|
||||
// Implements D-038: SoundEventEmitter → SoundEventQueue → ObserverSnapshot.
|
||||
// Event-driven: emitters post events each tick, queue fans out to subscribers.
|
||||
//
|
||||
// Range model per D-018:
|
||||
// Close ≤ 3 tiles — always heard, spatial positioning
|
||||
// Medium ≤ 8 tiles — heard if not obstructed
|
||||
// Long ≤ 20 tiles — heard in quiet conditions
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
// --- Sound event type taxonomy ---
|
||||
|
||||
/// Typed sound event categories.
|
||||
/// Client maps each kind to its audio asset registry key (D-038).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SoundEventKind {
|
||||
/// Footstep — emitted by moving entities. Intensity varies by stance.
|
||||
Footstep,
|
||||
/// Voice — dialogue, monologue, NPC speech.
|
||||
Voice,
|
||||
/// Machinery — terminals, doors, consoles, mechanical activity.
|
||||
Machinery,
|
||||
/// Alert — alarms, warnings, emergency signals.
|
||||
Alert,
|
||||
/// Ambient — location atmosphere, background environment.
|
||||
Ambient,
|
||||
}
|
||||
|
||||
// --- Sound event ---
|
||||
|
||||
/// A single sound event emitted this tick.
|
||||
/// Produced by `SoundEventEmitter`, collected into `SoundEventQueue`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SoundEvent {
|
||||
/// What kind of sound this is.
|
||||
pub kind: SoundEventKind,
|
||||
/// World position where the sound originates (tile centre).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: i32,
|
||||
/// Normalized intensity in [0.0, 1.0]. Drives volume at the client.
|
||||
pub intensity: f32,
|
||||
/// How far this sound propagates (D-018 three-range model).
|
||||
pub range: SoundRange,
|
||||
/// Stable entity ID of the source, if any.
|
||||
/// None for procedural or world-generated events (e.g. Ambient).
|
||||
pub source_entity_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl SoundEvent {
|
||||
/// Create an event at a tile position.
|
||||
pub fn at(
|
||||
pos: &TilePosition,
|
||||
kind: SoundEventKind,
|
||||
intensity: f32,
|
||||
range: SoundRange,
|
||||
source_entity_id: Option<u64>,
|
||||
) -> Self {
|
||||
let (x, y, z) = pos.to_render_coords();
|
||||
Self {
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
intensity,
|
||||
range,
|
||||
source_entity_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Manhattan-distance range ceiling in tiles for each category (D-018).
|
||||
pub fn max_range_tiles(range: SoundRange) -> u32 {
|
||||
match range {
|
||||
SoundRange::Close => 3,
|
||||
SoundRange::Medium => 8,
|
||||
SoundRange::Long => 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this sound is audible at `listener_pos`.
|
||||
/// Simple tile-distance check; occlusion is a future concern (D-018 note).
|
||||
pub fn audible_at(&self, listener_pos: &TilePosition) -> bool {
|
||||
let ceil = Self::max_range_tiles(self.range);
|
||||
let dx = (self.x.floor() as i32).abs_diff(listener_pos.x);
|
||||
let dy = (self.y.floor() as i32).abs_diff(listener_pos.y);
|
||||
let dz = (self.z).abs_diff(listener_pos.z);
|
||||
dz == 0 && dx + dy <= ceil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Emitter component ---
|
||||
|
||||
/// Component: entity emits sound events this tick.
|
||||
///
|
||||
/// Attached transiently — systems add this component to entities when they
|
||||
/// produce sound (step taken, line spoken, door opened). The `collect_sound_events`
|
||||
/// system harvests all emitters each tick, drains their pending events into
|
||||
/// `SoundEventQueue`, and removes the component.
|
||||
///
|
||||
/// Usage pattern (illustrative):
|
||||
/// ```ignore
|
||||
/// commands.entity(npc).insert(SoundEventEmitter::new(
|
||||
/// SoundEvent::at(&pos, SoundEventKind::Footstep, 0.6, SoundRange::Close, Some(npc_id))
|
||||
/// ));
|
||||
/// ```
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct SoundEventEmitter {
|
||||
pub pending: Vec<SoundEvent>,
|
||||
}
|
||||
|
||||
impl SoundEventEmitter {
|
||||
pub fn new(event: SoundEvent) -> Self {
|
||||
Self {
|
||||
pending: vec![event],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with(mut self, event: SoundEvent) -> Self {
|
||||
self.pending.push(event);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// --- Queue resource ---
|
||||
|
||||
/// Resource: sound events produced this tick.
|
||||
///
|
||||
/// `collect_sound_events` drains all `SoundEventEmitter` components into this
|
||||
/// resource each tick. Consumers (observer snapshot builder, NPC awareness
|
||||
/// system) read from the queue. Cleared at the top of each tick.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SoundEventQueue {
|
||||
pub events: Vec<SoundEvent>,
|
||||
}
|
||||
|
||||
impl SoundEventQueue {
|
||||
/// Drain all queued events, leaving the queue empty.
|
||||
pub fn drain(&mut self) -> Vec<SoundEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Return events audible at a listener position, without draining.
|
||||
pub fn audible_at<'a>(&'a self, pos: &'a TilePosition) -> impl Iterator<Item = &'a SoundEvent> {
|
||||
self.events.iter().filter(move |e| e.audible_at(pos))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collection system ---
|
||||
|
||||
/// System: harvest SoundEventEmitters → SoundEventQueue.
|
||||
///
|
||||
/// Runs each tick after movement/monologue/dialogue systems have fired.
|
||||
/// Removes the emitter component after draining. Ordering: after movement,
|
||||
/// before `compute_observer_snapshot`.
|
||||
pub fn collect_sound_events(
|
||||
mut commands: Commands,
|
||||
mut queue: ResMut<SoundEventQueue>,
|
||||
mut emitters: Query<(Entity, &mut SoundEventEmitter)>,
|
||||
) {
|
||||
queue.events.clear();
|
||||
for (entity, mut emitter) in emitters.iter_mut() {
|
||||
queue.events.extend(emitter.pending.drain(..));
|
||||
commands.entity(entity).remove::<SoundEventEmitter>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tile(x: i32, y: i32) -> TilePosition {
|
||||
TilePosition::new(x, y, 0)
|
||||
}
|
||||
|
||||
fn close_event(pos: &TilePosition) -> SoundEvent {
|
||||
SoundEvent::at(pos, SoundEventKind::Footstep, 0.5, SoundRange::Close, None)
|
||||
}
|
||||
|
||||
fn medium_event(pos: &TilePosition) -> SoundEvent {
|
||||
SoundEvent::at(pos, SoundEventKind::Voice, 0.7, SoundRange::Medium, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_range_audible_within_3_tiles() {
|
||||
let source = tile(5, 5);
|
||||
let event = close_event(&source);
|
||||
assert!(event.audible_at(&tile(5, 5)), "audible at origin");
|
||||
assert!(event.audible_at(&tile(5, 8)), "audible at distance 3");
|
||||
assert!(!event.audible_at(&tile(5, 9)), "not audible at distance 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_range_audible_within_8_tiles() {
|
||||
let source = tile(0, 0);
|
||||
let event = medium_event(&source);
|
||||
assert!(event.audible_at(&tile(4, 4)), "audible at manhattan 8");
|
||||
assert!(!event.audible_at(&tile(5, 4)), "not audible at manhattan 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_audible() {
|
||||
let source = tile(5, 5);
|
||||
let event = close_event(&source);
|
||||
let above = TilePosition::new(5, 5, 1);
|
||||
assert!(!event.audible_at(&above), "different z not audible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_system_drains_emitters_into_queue() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
|
||||
let pos = tile(5, 5);
|
||||
let _entity = world
|
||||
.spawn(SoundEventEmitter::new(close_event(&pos)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<SoundEventQueue>();
|
||||
assert_eq!(queue.events.len(), 1);
|
||||
assert_eq!(queue.events[0].kind, SoundEventKind::Footstep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_system_removes_emitter_component() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
|
||||
let entity = world
|
||||
.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<SoundEventEmitter>(entity).is_none(),
|
||||
"emitter component should be removed after collection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_audible_at_filters_by_range() {
|
||||
let mut queue = SoundEventQueue::default();
|
||||
let close_pos = tile(5, 5);
|
||||
let far_pos = tile(20, 20);
|
||||
queue.events.push(close_event(&close_pos));
|
||||
queue.events.push(close_event(&far_pos));
|
||||
|
||||
let listener = tile(5, 6);
|
||||
let heard: Vec<&SoundEvent> = queue.audible_at(&listener).collect();
|
||||
assert_eq!(heard.len(), 1, "only close sound is audible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_clears_each_tick() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
world.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
|
||||
// Tick 1: event collected
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 1);
|
||||
|
||||
// Tick 2: no new emitters → queue cleared
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,44 @@
|
||||
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
|
||||
// Timestamp-based LRU eviction for simulation space management
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Zero-sized marker components (D-026) ---
|
||||
// Tag-based tier identification. Systems query With<ActiveSim> to scope work
|
||||
// to nearby NPCs only, avoiding full-world iteration every tick.
|
||||
|
||||
/// Marker: entity is in the Active simulation tier.
|
||||
/// Full behavior systems (movement, perception, dialogue, monologue) run for
|
||||
/// entities with this tag at 10–20 ticks/sec.
|
||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||
pub struct ActiveSim;
|
||||
|
||||
/// Marker: entity is in the Background simulation tier.
|
||||
/// Lightweight schedule-keeping only — no full perception or dialogue.
|
||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||
pub struct BackgroundSim;
|
||||
|
||||
/// Marker: entity is in the State-saved tier.
|
||||
/// ECS components preserved but no systems run. Re-promoted to Background
|
||||
/// or Active when player approaches.
|
||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||
pub struct StateSaved;
|
||||
|
||||
/// Plugin registering the tier marker components and associated resources.
|
||||
/// Systems that filter by tier (With<ActiveSim>, etc.) require these markers
|
||||
/// to exist in the type registry. Future: tier transition systems live here.
|
||||
pub struct TierPlugin;
|
||||
|
||||
impl Plugin for TierPlugin {
|
||||
fn build(&self, _app: &mut App) {
|
||||
// Marker components are zero-sized — no resources to initialize.
|
||||
// Tier transition systems will be added here in ticket #99.
|
||||
tracing::debug!("TierPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SimulationTier {
|
||||
Active,
|
||||
@@ -34,10 +69,13 @@ pub enum ScopeKind {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
// --- SimulationTier enum tests ---
|
||||
|
||||
#[test]
|
||||
fn tier_can_be_added_and_queried() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(SimulationTier::Active).id();
|
||||
assert_eq!(
|
||||
*world.get::<SimulationTier>(entity).unwrap(),
|
||||
@@ -47,7 +85,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tier_can_transition() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(SimulationTier::Active).id();
|
||||
world.entity_mut(entity).insert(SimulationTier::Background);
|
||||
assert_eq!(
|
||||
@@ -55,4 +93,217 @@ mod tests {
|
||||
SimulationTier::Background
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tier_variants_are_distinct() {
|
||||
assert_ne!(SimulationTier::Active, SimulationTier::Background);
|
||||
assert_ne!(SimulationTier::Background, SimulationTier::StateSaved);
|
||||
assert_ne!(SimulationTier::StateSaved, SimulationTier::Ungenerated);
|
||||
assert_ne!(SimulationTier::Active, SimulationTier::Ungenerated);
|
||||
}
|
||||
|
||||
// --- Marker component query correctness (D-026, #94) ---
|
||||
// These tests verify that With<ActiveSim> / With<BackgroundSim> / With<StateSaved>
|
||||
// filter correctly — the core guarantee that behavior systems only run for the
|
||||
// intended tier.
|
||||
|
||||
#[test]
|
||||
fn with_active_sim_query_excludes_background_entities() {
|
||||
let mut world = World::new();
|
||||
let active = world.spawn(ActiveSim).id();
|
||||
let _background = world.spawn(BackgroundSim).id();
|
||||
let _state_saved = world.spawn(StateSaved).id();
|
||||
|
||||
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
|
||||
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
|
||||
assert_eq!(results.len(), 1, "only one ActiveSim entity expected");
|
||||
assert_eq!(results[0], active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_background_sim_query_excludes_active_entities() {
|
||||
let mut world = World::new();
|
||||
let _active = world.spawn(ActiveSim).id();
|
||||
let background = world.spawn(BackgroundSim).id();
|
||||
let _state_saved = world.spawn(StateSaved).id();
|
||||
|
||||
let mut query =
|
||||
world.query_filtered::<bevy_ecs::entity::Entity, With<BackgroundSim>>();
|
||||
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
|
||||
assert_eq!(results.len(), 1, "only one BackgroundSim entity expected");
|
||||
assert_eq!(results[0], background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_state_saved_query_excludes_active_and_background() {
|
||||
let mut world = World::new();
|
||||
let _active = world.spawn(ActiveSim).id();
|
||||
let _background = world.spawn(BackgroundSim).id();
|
||||
let state_saved = world.spawn(StateSaved).id();
|
||||
|
||||
let mut query =
|
||||
world.query_filtered::<bevy_ecs::entity::Entity, With<StateSaved>>();
|
||||
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
|
||||
assert_eq!(results.len(), 1, "only one StateSaved entity expected");
|
||||
assert_eq!(results[0], state_saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_active_sim_entities_all_returned() {
|
||||
let mut world = World::new();
|
||||
let a = world.spawn(ActiveSim).id();
|
||||
let b = world.spawn(ActiveSim).id();
|
||||
let _c = world.spawn(BackgroundSim).id();
|
||||
|
||||
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
|
||||
let mut results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
results.sort(); // deterministic comparison
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.contains(&a));
|
||||
assert!(results.contains(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_without_tier_marker_not_returned_by_active_query() {
|
||||
let mut world = World::new();
|
||||
let _bare = world.spawn_empty().id();
|
||||
let active = world.spawn(ActiveSim).id();
|
||||
|
||||
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
|
||||
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], active);
|
||||
}
|
||||
|
||||
// --- Tier transition tests (#99) ---
|
||||
|
||||
#[test]
|
||||
fn promote_state_saved_to_active() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(StateSaved).id();
|
||||
|
||||
// Transition: StateSaved → ActiveSim
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.remove::<StateSaved>()
|
||||
.insert(ActiveSim);
|
||||
|
||||
assert!(world.get::<ActiveSim>(entity).is_some(), "ActiveSim added");
|
||||
assert!(
|
||||
world.get::<StateSaved>(entity).is_none(),
|
||||
"StateSaved removed"
|
||||
);
|
||||
|
||||
// Must appear in ActiveSim query after promotion
|
||||
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
|
||||
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], entity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demote_active_to_background() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(ActiveSim).id();
|
||||
|
||||
// Transition: ActiveSim → BackgroundSim
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.remove::<ActiveSim>()
|
||||
.insert(BackgroundSim);
|
||||
|
||||
assert!(
|
||||
world.get::<BackgroundSim>(entity).is_some(),
|
||||
"BackgroundSim added"
|
||||
);
|
||||
assert!(world.get::<ActiveSim>(entity).is_none(), "ActiveSim removed");
|
||||
|
||||
// Must NOT appear in ActiveSim query after demotion
|
||||
let mut active_query =
|
||||
world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
|
||||
assert_eq!(
|
||||
active_query.iter(&world).count(),
|
||||
0,
|
||||
"demoted entity not in ActiveSim query"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demote_active_to_state_saved() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(ActiveSim).id();
|
||||
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.remove::<ActiveSim>()
|
||||
.insert(StateSaved);
|
||||
|
||||
assert!(world.get::<StateSaved>(entity).is_some());
|
||||
assert!(world.get::<ActiveSim>(entity).is_none());
|
||||
}
|
||||
|
||||
// --- LastInteraction and ScopeTag ---
|
||||
|
||||
#[test]
|
||||
fn last_interaction_records_tick() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(LastInteraction { tick: 42 }).id();
|
||||
|
||||
let interaction = world.get::<LastInteraction>(entity).unwrap();
|
||||
assert_eq!(interaction.tick, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_interaction_tick_can_be_updated() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(LastInteraction { tick: 1 }).id();
|
||||
|
||||
world.entity_mut(entity).insert(LastInteraction { tick: 100 });
|
||||
|
||||
let interaction = world.get::<LastInteraction>(entity).unwrap();
|
||||
assert_eq!(interaction.tick, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_tag_neighborhood_kind() {
|
||||
let mut world = World::new();
|
||||
let entity = world
|
||||
.spawn(ScopeTag {
|
||||
tags: vec![ScopeKind::Neighborhood],
|
||||
})
|
||||
.id();
|
||||
|
||||
let tag = world.get::<ScopeTag>(entity).unwrap();
|
||||
assert!(tag.tags.contains(&ScopeKind::Neighborhood));
|
||||
assert!(!tag.tags.contains(&ScopeKind::ActiveQuest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_tag_multiple_kinds() {
|
||||
let entity = ScopeTag {
|
||||
tags: vec![
|
||||
ScopeKind::Neighborhood,
|
||||
ScopeKind::Colleague,
|
||||
ScopeKind::KnownContact,
|
||||
],
|
||||
};
|
||||
assert_eq!(entity.tags.len(), 3);
|
||||
assert!(entity.tags.contains(&ScopeKind::Colleague));
|
||||
assert!(entity.tags.contains(&ScopeKind::KnownContact));
|
||||
assert!(!entity.tags.contains(&ScopeKind::ActiveQuest));
|
||||
}
|
||||
|
||||
// --- TierPlugin smoke test ---
|
||||
|
||||
#[test]
|
||||
fn tier_plugin_builds_without_panic() {
|
||||
let mut app = bevy_app::App::new();
|
||||
app.add_plugins(TierPlugin);
|
||||
// Just verifying it doesn't panic on build
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user