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
+2 -4
View File
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
If clean, report the result (fast-forward or merge commit, files changed).
If conflicts, report them and stop.
### 3. Push prompt
### 3. Push
After a successful merge, ask the user if they want to push:
After a successful merge, push the branch:
```bash
git push origin <current-branch>
```
Never push without explicit confirmation.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+128 -1
View File
@@ -11,6 +11,56 @@ dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -327,6 +377,52 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.5.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -597,6 +693,12 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -616,6 +718,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
@@ -722,6 +830,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking"
version = "2.2.1"
@@ -978,11 +1092,12 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"bevy_app",
"bevy_ecs",
"bincode",
"clap",
"pathfinding",
"rand",
"rand_chacha",
@@ -1049,6 +1164,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.114"
@@ -1210,6 +1331,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.20.0"
+1
View File
@@ -16,6 +16,7 @@ pathfinding = "4.11"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive"] }
[features]
default = ["gauntlet"]
+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",
}
}
+2
View File
@@ -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"));
+8 -2
View File
@@ -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)
+5
View File
@@ -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);
+245
View File
@@ -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 0100; 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"
);
}
}
+39
View File
@@ -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 0100 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.
+5 -1
View File
@@ -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();
+11
View File
@@ -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,
});
}
+8
View File
@@ -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),
),
);
+6 -1
View File
@@ -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 {
+281
View File
@@ -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);
}
}
+253 -2
View File
@@ -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 1020 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
}
}
+1
View File
@@ -61,6 +61,7 @@ fn snapshot_roundtrip_over_unix_socket() {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
};
bridge
+1
View File
@@ -47,6 +47,7 @@ fn snapshot_roundtrip_over_tcp() {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
};
bridge
+2
View File
@@ -37,6 +37,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
}
}
@@ -208,6 +209,7 @@ fn generate_msgpack_fixtures() {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
};
write_fixture(
"snapshot_v2_full",
+3 -2
View File
@@ -46,7 +46,7 @@
"pending_recognitions": [
{
"entity_id": 1,
"remaining_ticks": 1,
"remaining_ticks": 2,
"total_delay_ticks": 6,
"x": 16.5,
"y": 13.5,
@@ -65,8 +65,9 @@
"player_inventory": [],
"player_stance": "Sprint",
"scan_events": [],
"sound_events": [],
"tick": 8,
"version": 9,
"version": 10,
"visible_tiles": [
{
"tile_kind": "Wall",
+4 -1
View File
@@ -26,6 +26,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
}
}
@@ -254,6 +255,7 @@ fn snapshot_v2_fields_roundtrip() {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -308,7 +310,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 9,
PROTOCOL_VERSION, 10,
"bump this assertion when protocol version changes"
);
}
@@ -348,6 +350,7 @@ fn all_facing_direction_variants_roundtrip() {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");