diff --git a/.claude/skills/worktree-update/SKILL.md b/.claude/skills/worktree-update/SKILL.md index 28b205e7c..085797484 100644 --- a/.claude/skills/worktree-update/SKILL.md +++ b/.claude/skills/worktree-update/SKILL.md @@ -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 ``` - -Never push without explicit confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index dacf537cf..ad396cd2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed +- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker +- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering) +- Stale protocol version doc comment "Current: 9" corrected to 10 +- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying + +### Changed +- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests) +- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired) +- Observer test setup now inserts SoundEventQueue resource for integration coverage +- Added FactionOnly positive test case and Medium-range occlusion TODO + ## [v0.1.11] — 2026-02-19 ### Added diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3700cbd9c..3b4999391 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 8f350a950..56c001dcf 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 829fc4dab..6ae68786f 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 8723189c6..0822dc9c3 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 25a2615af..69657bc29 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3700cbd9c..3b4999391 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index c17c62118..673d30a77 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 89d928558..0d336a704 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 29bb08964..d22a72532 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index dea8f77dd..2aa4f71c8 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/Cargo.lock b/server/Cargo.lock index 40f0aa4bf..accac7522 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -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" diff --git a/server/Cargo.toml b/server/Cargo.toml index 1c68ade0c..bb360ef3f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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"] diff --git a/server/src/bin/line_preview.rs b/server/src/bin/line_preview.rs new file mode 100644 index 000000000..d6a93955b --- /dev/null +++ b/server/src/bin/line_preview.rs @@ -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, + + /// NPC role for dialogue mode (e.g., dock-worker, bar-owner) + #[arg(long)] + role: Option, + + // -- Shared -- + + /// Location filter + #[arg(long)] + location: Option, + + // -- Monologue options -- + + /// Trigger filter for monologue (enter_location, observe_npc, etc.) + #[arg(long)] + trigger: Option, + + /// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b) + #[arg(long)] + knows: Vec, + + /// 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, + + // -- 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 for monologue or --role --location 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 = 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 = 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::>() + .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::>() + .join(", "), + line.mood + .iter() + .map(mood_str) + .collect::>() + .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::>() + .join(", "), + access_str(&access), + if l1 { "+" } else { "-" } + ); + println!( + " L2 situation: requires [{}], active [{}] {}", + line.situation + .iter() + .map(situation_str) + .collect::>() + .join(", "), + situations + .iter() + .map(situation_str) + .collect::>() + .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::>() + .join(", "), + line.mood + .iter() + .map(mood_str) + .collect::>() + .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(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", + } +} diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 79d746029..a56d70b48 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -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")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index dee8ed37d..4f83b46e4 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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,10 +28,10 @@ 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. + /// Protocol version for forward compatibility. Current: 10. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -79,6 +79,12 @@ pub struct ObserverSnapshot { /// Sorted ascending for deterministic output. Client can safely ignore. #[serde(default)] pub blocked_entities: Vec, + /// 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, } /// Game time data for client display (D-031) diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 47be62c0e..190d99051 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -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); diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index 081194709..77e725155 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -231,6 +231,78 @@ 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| match v.parse::() { + Ok(id) => Some(id), + Err(_) => { + tracing::warn!( + target_id = target_id.0, + value = %v, + "FactionOnly: non-numeric faction_id attribute, denying access" + ); + None + } + }) + .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 +540,210 @@ mod tests { let ids: Vec = 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" + ); + } + + #[test] + fn filter_by_access_faction_only_passes_with_matching_faction() { + let observer = StableId(1); + let target = StableId(2); + let mut kg = KnowledgeGraph::new(); + + // Observer knows target's faction via known_attributes + kg.observe_entity(target, make_position(5, 5), 10); + kg.entities + .get_mut(&target) + .unwrap() + .known_attributes + .insert("faction_id".into(), "99".into()); + + let faction = StableId(99); + let rule = ObserverAccess::FactionOnly(faction); + + assert!( + filter_by_access(observer, target, &rule, &kg), + "FactionOnly must pass when observer knows the matching faction_id" + ); + } } diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index bb71fe3b8..77315be3d 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -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. diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index 8952c04d9..4a97e8adf 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -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, mut previous: ResMut, mut commands: Commands, - npcs: Query<(Entity, &TilePosition, &DailyRoutine), With>, + npcs: Query<(Entity, &TilePosition, &DailyRoutine), (With, With)>, ) { let current_phase = time.day_phase(); let current_day = time.day(); @@ -90,6 +94,7 @@ mod tests { let entity = world .spawn(( Npc, + crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(5, 5, 0), // Not at afternoon location DailyRoutine { entries: vec![RoutineEntry { @@ -120,6 +125,7 @@ mod tests { let entity = world .spawn(( Npc, + ActiveSim, TilePosition::new(5, 5, 0), DailyRoutine { entries: vec![RoutineEntry { @@ -148,6 +154,7 @@ mod tests { let entity = world .spawn(( Npc, + ActiveSim, loc, // Already at afternoon location DailyRoutine { entries: vec![RoutineEntry { @@ -176,6 +183,7 @@ mod tests { let entity = world .spawn(( Npc, + ActiveSim, TilePosition::new(5, 5, 0), DailyRoutine { entries: vec![RoutineEntry { @@ -212,6 +220,7 @@ mod tests { let entity = world .spawn(( Npc, + crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(20, 20, 0), DailyRoutine { entries: vec![RoutineEntry { diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index aa7887317..2fe1619ad 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -10,7 +10,8 @@ use bevy_ecs::prelude::*; use std::collections::BTreeSet; use crate::bridge::types::*; -use crate::knowledge::types::KnowledgeState; +use crate::knowledge::graph::filter_by_access; +use crate::knowledge::types::{AccessRule, KnowledgeState}; use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; @@ -21,6 +22,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 +58,7 @@ pub fn compute_observer_snapshot( time: Res, geometry: Res, registry: Res, + sound_queue: Option>, mut observer_query: Query< ( Entity, @@ -78,13 +81,14 @@ pub fn compute_observer_snapshot( &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, + Option<&AccessRule>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { let Ok(( observer_entity, - _observer_pos, + observer_pos, facing_opt, observer_kg, mut interaction_buffer, @@ -119,8 +123,13 @@ pub fn compute_observer_snapshot( }) .unwrap_or_default(); + // Resolve observer's StableId for component-level access control (#139, D-010) + let observer_stable_id = registry + .to_stable(observer_entity) + .unwrap_or(StableId(0)); + let (mut entities, visible_ids, blocked_entities) = - filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); + filter_visible_entities(&geometry, ®istry, observer_kg, observer_stable_id, &all_entities); collect_remembered_entities( observer_kg, @@ -176,6 +185,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 +232,7 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, + sound_events, }); } @@ -226,18 +244,20 @@ fn filter_visible_entities( geometry: &VisibilityGeometry, registry: &EntityRegistry, observer_kg: &KnowledgeGraph, + observer_stable_id: StableId, all_entities: &Query<( Entity, &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, + Option<&AccessRule>, )>, ) -> (Vec, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); let mut blocked_ids: BTreeSet = BTreeSet::new(); - for (entity, pos, is_player, is_npc) in all_entities.iter() { + for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() { if pos.z != geometry.observer_z { continue; } @@ -278,7 +298,16 @@ fn filter_visible_entities( let relationship = if is_player.is_some() { RelationshipState::Known // Self } else if let Some(stable_id) = registry.to_stable(entity) { - observer_kg.relationship_with(&stable_id) + // D-010 principle 2: check access control before exposing relationship (#139) + let access_granted = match access_rule { + Some(rule) => filter_by_access(observer_stable_id, stable_id, &rule.0, observer_kg), + None => true, // No AccessRule → Public (default) + }; + if access_granted { + observer_kg.relationship_with(&stable_id) + } else { + RelationshipState::Unknown // Access denied — redact relationship data + } } else { RelationshipState::Unknown }; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index be4b3560c..f2b1d3024 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::knowledge::types::KnowledgeState; +use crate::knowledge::types::{KnowledgeState, ObserverAccess}; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; @@ -15,6 +15,7 @@ fn setup_world(width: i32, height: i32) -> World { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } @@ -2178,6 +2179,234 @@ fn different_z_level_not_in_blocked_entities() { ); } +// ----------------------------------------------------------------------- +// Component-level access control tests (#139, D-010 principle 2) +// ----------------------------------------------------------------------- + +#[test] +fn access_rule_owner_only_redacts_relationship() { + // THE critical negative test for #139: NPC with OwnerOnly access rule + // is physically visible (in LOS) but relationship data is redacted. + use crate::knowledge::types::AccessRule; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC with OwnerOnly access rule — only the NPC itself can read its data + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::OwnerOnly), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows NPC as Hostile — but access should be denied + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // NPC should be visible (physically in LOS) + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible even with OwnerOnly access"); + + // But relationship must be redacted to Unknown (access denied) + assert_eq!( + npc_entity.relationship, + RelationshipState::Unknown, + "OwnerOnly access should redact relationship to Unknown for non-owner observer" + ); + assert_eq!(npc_entity.observation, EntityVisibility::Visible); +} + +#[test] +fn access_rule_knowledge_gated_passes_with_matching_fact() { + // Positive test: observer has the required fact, relationship visible. + use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC gated on a specific fact + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::KnowledgeGated("contraband.ring_exists".into())), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows the required fact AND has a relationship with the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + kg.facts.insert( + FactId("contraband.ring_exists".into()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + }, + ); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + + // Observer has the required fact — relationship should be visible + assert_eq!( + npc_entity.relationship, + RelationshipState::PersonOfInterest, + "KnowledgeGated access should pass when observer has the required fact" + ); +} + +#[test] +fn access_rule_knowledge_gated_redacts_without_fact() { + // Negative test: observer lacks the required fact, relationship redacted. + use crate::knowledge::types::AccessRule; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC gated on a fact the observer doesn't have + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::KnowledgeGated("conspiracy.mastermind".into())), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has relationship but NOT the required fact + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible (in LOS)"); + + assert_eq!( + npc_entity.relationship, + RelationshipState::Unknown, + "KnowledgeGated access should redact relationship when observer lacks the fact" + ); +} + +#[test] +fn no_access_rule_defaults_to_public() { + // Existing behavior: entities without AccessRule are fully visible. + // This is a regression guard — existing tests also cover this implicitly. + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC with NO AccessRule component + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Friendly); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + + assert_eq!( + npc_entity.relationship, + RelationshipState::Friendly, + "No AccessRule should default to Public — relationship fully visible" + ); +} + #[test] fn blocked_entities_sorted_ascending() { // Multiple blocked NPCs should appear in ascending entity_id order diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 30ac14d67..b85bc6a63 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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::() .insert_resource(rng::SimRng::new(0)) .init_resource::() .init_resource::() + .init_resource::() .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), ), ); diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 5fd5e7aec..2c1ca9407 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -8,6 +8,11 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::bridge::types::MovementStance; +use crate::knowledge::types::SoundRange; +use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; +use crate::simulation::stance::Stance; + /// Chunk size in tiles (32x32 per chunk) pub const CHUNK_SIZE: i32 = 32; @@ -266,12 +271,13 @@ pub fn validate_movement( &MoveIntent, &mut TilePosition, Option<&TilePresence>, + Option<&Stance>, )>, stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { let Some(map) = walkability else { tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); - for (entity, _, _, _) in movers.iter() { + for (entity, _, _, _, _) in movers.iter() { commands.entity(entity).remove::(); } return; @@ -286,11 +292,11 @@ pub fn validate_movement( } // Sort movers by Entity::to_bits() for deterministic collision resolution (#458) - let mut mover_entities: Vec = movers.iter().map(|(e, _, _, _)| e).collect(); + let mut mover_entities: Vec = movers.iter().map(|(e, _, _, _, _)| e).collect(); mover_entities.sort_by_key(|e| e.to_bits()); for entity in mover_entities { - let Ok((_, intent, mut position, presence)) = movers.get_mut(entity) else { + let Ok((_, intent, mut position, presence, stance_opt)) = movers.get_mut(entity) else { continue; }; let target = intent.target; @@ -318,6 +324,23 @@ pub fn validate_movement( occupied.remove(&(*position, layer)); *position = target; occupied.insert(slot, entity); + + // Emit Footstep sound event (#124, D-018) + let intensity = match stance_opt.map(|s| s.0) { + Some(MovementStance::Sprint) => 0.8, + Some(MovementStance::Walk) | None => 0.5, + Some(MovementStance::Careful) => 0.3, + Some(MovementStance::Crouch) => 0.15, + }; + commands.entity(entity).insert(SoundEventEmitter::new( + SoundEvent::at( + &target, + SoundEventKind::Footstep, + intensity, + SoundRange::Close, + None, + ), + )); } commands.entity(entity).remove::(); } @@ -975,4 +998,104 @@ mod tests { "Seated should share tile with Fixture + Prone + Standing" ); } + + // --- Footstep sound emission tests (#124, D-018) --- + + #[test] + fn successful_move_emits_footstep_sound() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn((TilePosition::new(5, 5, 0), MoveIntent { target })) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world + .get::(entity) + .expect("successful move should insert SoundEventEmitter"); + assert_eq!(emitter.pending.len(), 1); + assert_eq!(emitter.pending[0].kind, SoundEventKind::Footstep); + assert_eq!(emitter.pending[0].range, SoundRange::Close); + // Default stance (None) → Walk intensity 0.5 + assert!((emitter.pending[0].intensity - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn blocked_move_does_not_emit_footstep() { + let mut world = bevy_ecs::world::World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + map.set_walkable(&TilePosition::new(5, 4, 0), false); + world.insert_resource(map); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert!( + world.get::(entity).is_none(), + "blocked move should not emit sound" + ); + } + + #[test] + fn sprint_stance_produces_louder_footstep() { + use crate::simulation::stance::Stance; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { target }, + Stance(MovementStance::Sprint), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world.get::(entity).unwrap(); + assert!((emitter.pending[0].intensity - 0.8).abs() < f32::EPSILON); + } + + #[test] + fn crouch_stance_produces_quieter_footstep() { + use crate::simulation::stance::Stance; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { target }, + Stance(MovementStance::Crouch), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world.get::(entity).unwrap(); + assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON); + } } diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs index 64a11c3fe..9e156a2fa 100644 --- a/server/src/simulation/path_follow.rs +++ b/server/src/simulation/path_follow.rs @@ -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>, + mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With, With)>, ) { for (entity, mut path, speed_opt) in query.iter_mut() { if let Some(mut speed) = speed_opt { @@ -93,6 +98,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(0, 0, 0), ComputedPath { steps: vec![ @@ -124,6 +130,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(2, 0, 0), ComputedPath { steps: vec![TilePosition::new(3, 0, 0)], @@ -149,6 +156,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(0, 0, 0), ComputedPath { steps: vec![TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0)], diff --git a/server/src/simulation/sound.rs b/server/src/simulation/sound.rs new file mode 100644 index 000000000..3b3e638fa --- /dev/null +++ b/server/src/simulation/sound.rs @@ -0,0 +1,502 @@ +// 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, +} + +impl SoundEvent { + /// Create an event at a tile position. + pub fn at( + pos: &TilePosition, + kind: SoundEventKind, + intensity: f32, + range: SoundRange, + source_entity_id: Option, + ) -> 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 — no wall/obstruction occlusion. + /// TODO: Medium-range sounds should be attenuated or blocked by walls + /// per D-018. Requires LOS integration (backlog — not in v0.1 scope). + 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, +} + +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, +} + +impl SoundEventQueue { + /// Drain all queued events, leaving the queue empty. + pub fn drain(&mut self) -> Vec { + 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 { + 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`. +/// +/// Producers: `validate_movement` inserts SoundEventEmitter with Footstep +/// events on every successful move. Voice events (dialogue) are future scope. +pub fn collect_sound_events( + mut commands: Commands, + mut queue: ResMut, + 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::(); + } +} + +#[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::(); + 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::(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::().events.len(), 1); + + // Tick 2: no new emitters → queue cleared + schedule.run(&mut world); + assert_eq!(world.resource::().events.len(), 0); + } + + // --- Multi-emitter and multi-event tests --- + + #[test] + fn multiple_emitters_collected_in_one_tick() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + world.spawn(SoundEventEmitter::new(close_event(&tile(1, 1)))); + world.spawn(SoundEventEmitter::new(close_event(&tile(2, 2)))); + world.spawn(SoundEventEmitter::new(medium_event(&tile(3, 3)))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 3, "all three emitters collected"); + } + + #[test] + fn emitter_with_multiple_pending_events_all_drained() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let emitter = SoundEventEmitter::new(close_event(&pos)) + .with(medium_event(&pos)) + .with(SoundEvent::at( + &pos, + SoundEventKind::Alert, + 1.0, + SoundRange::Long, + None, + )); + world.spawn(emitter); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!( + queue.events.len(), + 3, + "all three pending events from one emitter collected" + ); + } + + // --- Long range audibility tests (D-018) --- + + #[test] + fn long_range_sound_audible_within_20_tiles() { + let source = tile(0, 0); + let event = + SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None); + + // Manhattan distance 20 — exactly at boundary + let listener = tile(10, 10); + assert!( + event.audible_at(&listener), + "Long range sound audible at manhattan 20" + ); + } + + #[test] + fn long_range_sound_not_audible_beyond_20_tiles() { + let source = tile(0, 0); + let event = + SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None); + + let listener = tile(11, 10); // manhattan 21 + assert!( + !event.audible_at(&listener), + "Long range sound not audible at manhattan 21" + ); + } + + #[test] + fn long_range_audible_at_origin() { + let source = tile(5, 5); + let event = + SoundEvent::at(&source, SoundEventKind::Machinery, 0.5, SoundRange::Long, None); + assert!(event.audible_at(&source), "audible at source position"); + } + + // --- Sound event field preservation tests --- + + #[test] + fn source_entity_id_preserved_through_collection() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let event = SoundEvent::at( + &pos, + SoundEventKind::Voice, + 0.8, + SoundRange::Close, + Some(42), + ); + world.spawn(SoundEventEmitter::new(event)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!( + queue.events[0].source_entity_id, + Some(42), + "source_entity_id must be preserved through collection" + ); + } + + #[test] + fn intensity_preserved_through_collection() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let event = SoundEvent::at( + &pos, + SoundEventKind::Footstep, + 0.37, + SoundRange::Close, + None, + ); + world.spawn(SoundEventEmitter::new(event)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert!( + (queue.events[0].intensity - 0.37).abs() < f32::EPSILON, + "intensity must be preserved through collection" + ); + } + + // --- All sound event kinds --- + + #[test] + fn all_sound_event_kinds_can_be_emitted_and_collected() { + let kinds = [ + SoundEventKind::Footstep, + SoundEventKind::Voice, + SoundEventKind::Machinery, + SoundEventKind::Alert, + SoundEventKind::Ambient, + ]; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + let pos = tile(5, 5); + + for kind in kinds { + world.spawn(SoundEventEmitter::new(SoundEvent::at( + &pos, + kind, + 0.5, + SoundRange::Close, + None, + ))); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!( + queue.events.len(), + 5, + "all five SoundEventKind variants collected" + ); + + let collected_kinds: std::collections::HashSet = + queue.events.iter().map(|e| e.kind).collect(); + for kind in [ + SoundEventKind::Footstep, + SoundEventKind::Voice, + SoundEventKind::Machinery, + SoundEventKind::Alert, + SoundEventKind::Ambient, + ] { + assert!(collected_kinds.contains(&kind), "{kind:?} not collected"); + } + } + + // --- max_range_tiles spec verification (D-018) --- + + #[test] + fn max_range_tiles_values_match_d018() { + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Close), 3); + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Medium), 8); + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Long), 20); + } + + // --- SoundEventQueue::drain test --- + + #[test] + fn queue_drain_empties_the_queue() { + let mut queue = SoundEventQueue::default(); + let pos = tile(5, 5); + queue.events.push(close_event(&pos)); + queue.events.push(medium_event(&pos)); + assert_eq!(queue.events.len(), 2); + + let drained = queue.drain(); + assert_eq!(drained.len(), 2, "drain returns all events"); + assert_eq!(queue.events.len(), 0, "queue empty after drain"); + } +} diff --git a/server/src/simulation/tier.rs b/server/src/simulation/tier.rs index f002cf899..bcb645bce 100644 --- a/server/src/simulation/tier.rs +++ b/server/src/simulation/tier.rs @@ -1,58 +1,435 @@ // Simulation tier system // Implements D-026: Active/Background/State-saved/Ungenerated tiers -// Timestamp-based LRU eviction for simulation space management +// Tier transitions based on player approach distance (#99). +use bevy_app::prelude::*; use bevy_ecs::prelude::*; -use serde::{Deserialize, Serialize}; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum SimulationTier { - Active, - Background, - StateSaved, - Ungenerated, +// --- Tier radius constants (D-026) --- +// These thresholds define the distance bands at which entities transition +// between simulation tiers. Manhattan distance in tiles. + +/// Entities within this radius receive full Active simulation (D-026). +pub const ACTIVE_RADIUS: u32 = 40; + +/// Entities within this radius (and beyond ACTIVE_RADIUS) receive +/// lightweight Background schedule-keeping (D-026). +pub const BACKGROUND_RADIUS: u32 = 120; + +// --- Zero-sized marker components (D-026) --- +// Tag-based tier identification. Systems query With 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 the tier transition system. +pub struct TierPlugin; + +impl Plugin for TierPlugin { + fn build(&self, app: &mut App) { + // Tier transition runs after movement so positions are current. + app.add_systems( + Update, + update_tier_markers.after(crate::simulation::movement::validate_movement), + ); + tracing::debug!("TierPlugin initialized"); + } } -#[derive(Component, Debug, Clone)] -pub struct LastInteraction { - pub tick: u64, +// --- Tier transition system (D-026, #99) --- + +/// Manhattan tile distance between two positions, returning `u32::MAX` for +/// entities on different z-levels (they are effectively unreachable). +fn tile_distance(a: &TilePosition, b: &TilePosition) -> u32 { + if a.z != b.z { + return u32::MAX; + } + a.x.abs_diff(b.x) + a.y.abs_diff(b.y) } -#[derive(Component, Debug, Clone)] -pub struct ScopeTag { - pub tags: Vec, -} +/// System: promote/demote NPC tier markers based on player distance (D-026, #99). +/// +/// Each tick, after movement has settled positions: +/// - Entities within `ACTIVE_RADIUS` → `ActiveSim` +/// - Entities within `BACKGROUND_RADIUS` → `BackgroundSim` +/// - Entities beyond `BACKGROUND_RADIUS` → `StateSaved` +/// +/// No-op when there is no `PlayerCharacter` entity (headless tests, no observer +/// spawned). Entities that are already in the correct tier are left unchanged. +pub fn update_tier_markers( + mut commands: Commands, + player_query: Query<&TilePosition, With>, + active_npcs: Query<(Entity, &TilePosition), With>, + background_npcs: Query<(Entity, &TilePosition), With>, + state_saved_npcs: Query<(Entity, &TilePosition), With>, +) { + let Ok(player_pos) = player_query.single() else { + return; + }; -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ScopeKind { - Neighborhood, - ActiveQuest, - Colleague, - KnownContact, + for (entity, pos) in &active_npcs { + let dist = tile_distance(player_pos, pos); + if dist > BACKGROUND_RADIUS { + commands + .entity(entity) + .remove::() + .insert(StateSaved); + } else if dist > ACTIVE_RADIUS { + commands + .entity(entity) + .remove::() + .insert(BackgroundSim); + } + } + + for (entity, pos) in &background_npcs { + let dist = tile_distance(player_pos, pos); + if dist <= ACTIVE_RADIUS { + commands + .entity(entity) + .remove::() + .insert(ActiveSim); + } else if dist > BACKGROUND_RADIUS { + commands + .entity(entity) + .remove::() + .insert(StateSaved); + } + } + + for (entity, pos) in &state_saved_npcs { + let dist = tile_distance(player_pos, pos); + if dist <= ACTIVE_RADIUS { + commands + .entity(entity) + .remove::() + .insert(ActiveSim); + } else if dist <= BACKGROUND_RADIUS { + commands + .entity(entity) + .remove::() + .insert(BackgroundSim); + } + } } #[cfg(test)] mod tests { use super::*; + use bevy_ecs::world::World; + + // --- Marker component query correctness (D-026, #94) --- + // These tests verify that With / With / With + // filter correctly — the core guarantee that behavior systems only run for the + // intended tier. #[test] - fn tier_can_be_added_and_queried() { - let mut world = bevy_ecs::world::World::new(); - let entity = world.spawn(SimulationTier::Active).id(); + 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::>(); + let results: Vec = 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::>(); + let results: Vec = 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::>(); + let results: Vec = 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::>(); + let mut results: Vec = 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::>(); + let results: Vec = 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::() + .insert(ActiveSim); + + assert!(world.get::(entity).is_some(), "ActiveSim added"); + assert!( + world.get::(entity).is_none(), + "StateSaved removed" + ); + + // Must appear in ActiveSim query after promotion + let mut query = world.query_filtered::>(); + let results: Vec = 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::() + .insert(BackgroundSim); + + assert!( + world.get::(entity).is_some(), + "BackgroundSim added" + ); + assert!(world.get::(entity).is_none(), "ActiveSim removed"); + + // Must NOT appear in ActiveSim query after demotion + let mut active_query = + world.query_filtered::>(); assert_eq!( - *world.get::(entity).unwrap(), - SimulationTier::Active + active_query.iter(&world).count(), + 0, + "demoted entity not in ActiveSim query" ); } #[test] - fn tier_can_transition() { - let mut world = bevy_ecs::world::World::new(); - let entity = world.spawn(SimulationTier::Active).id(); - world.entity_mut(entity).insert(SimulationTier::Background); - assert_eq!( - *world.get::(entity).unwrap(), - SimulationTier::Background - ); + fn demote_active_to_state_saved() { + let mut world = World::new(); + let entity = world.spawn(ActiveSim).id(); + + world + .entity_mut(entity) + .remove::() + .insert(StateSaved); + + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_none()); + } + + // --- 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 + } + + // --- update_tier_markers system tests (D-026, #99) --- + + fn make_pos(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + fn run_tier_update(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_tier_markers); + schedule.run(world); + } + + #[test] + fn no_op_when_no_player_entity() { + // The system should be a no-op if there is no PlayerCharacter. + let mut world = World::new(); + let npc = world.spawn((ActiveSim, make_pos(200, 200))).id(); + run_tier_update(&mut world); + // NPC should still be ActiveSim — no player to compare against. + assert!(world.get::(npc).is_some()); + } + + #[test] + fn active_npc_within_active_radius_unchanged() { + let mut world = World::new(); + // Player at origin; NPC at distance 10 (< ACTIVE_RADIUS=40) + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((ActiveSim, make_pos(10, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_some(), "stays Active"); + assert!(world.get::(npc).is_none()); + } + + #[test] + fn active_npc_in_background_band_demotes_to_background() { + // NPC at distance 60 → beyond ACTIVE_RADIUS(40), within BACKGROUND_RADIUS(120) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((ActiveSim, make_pos(60, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "ActiveSim removed"); + assert!(world.get::(npc).is_some(), "BackgroundSim added"); + } + + #[test] + fn active_npc_beyond_background_radius_demotes_to_state_saved() { + // NPC at distance 150 → beyond BACKGROUND_RADIUS(120) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((ActiveSim, make_pos(150, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "ActiveSim removed"); + assert!(world.get::(npc).is_some(), "StateSaved added"); + } + + #[test] + fn background_npc_within_active_radius_promotes_to_active() { + // NPC at distance 20 (< ACTIVE_RADIUS=40) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((BackgroundSim, make_pos(20, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "BackgroundSim removed"); + assert!(world.get::(npc).is_some(), "ActiveSim added"); + } + + #[test] + fn background_npc_beyond_background_radius_demotes_to_state_saved() { + // NPC at distance 200 → beyond BACKGROUND_RADIUS(120) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((BackgroundSim, make_pos(200, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "BackgroundSim removed"); + assert!(world.get::(npc).is_some(), "StateSaved added"); + } + + #[test] + fn state_saved_npc_within_active_radius_promotes_to_active() { + // NPC at distance 5 (< ACTIVE_RADIUS=40) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((StateSaved, make_pos(5, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "StateSaved removed"); + assert!(world.get::(npc).is_some(), "ActiveSim added"); + } + + #[test] + fn state_saved_npc_in_background_band_promotes_to_background() { + // NPC at distance 80 (> ACTIVE_RADIUS, < BACKGROUND_RADIUS) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((StateSaved, make_pos(80, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "StateSaved removed"); + assert!(world.get::(npc).is_some(), "BackgroundSim added"); + } + + #[test] + fn state_saved_npc_beyond_background_radius_unchanged() { + // NPC at distance 200 → stays StateSaved + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((StateSaved, make_pos(200, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_some(), "stays StateSaved"); + assert!(world.get::(npc).is_none()); + } + + #[test] + fn different_z_level_treated_as_infinite_distance() { + // NPC on z=1 is unreachable from player on z=0 + let mut world = World::new(); + world.spawn((PlayerCharacter, TilePosition::new(0, 0, 0))); + // Spawn as ActiveSim at same x/y but different floor + let npc = world + .spawn((ActiveSim, TilePosition::new(0, 0, 1))) + .id(); + run_tier_update(&mut world); + // Should demote: u32::MAX > BACKGROUND_RADIUS → StateSaved + assert!(world.get::(npc).is_none(), "ActiveSim removed"); + assert!(world.get::(npc).is_some(), "StateSaved due to z-distance"); + } + + #[test] + fn npc_at_exact_active_radius_boundary_stays_active() { + // Distance = ACTIVE_RADIUS exactly → should stay Active (threshold is >) + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_some(), "stays Active at exact boundary"); + } + + #[test] + fn npc_one_tile_beyond_active_radius_demotes() { + let mut world = World::new(); + world.spawn((PlayerCharacter, make_pos(0, 0))); + let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32 + 1, 0))).id(); + run_tier_update(&mut world); + assert!(world.get::(npc).is_none(), "demoted to Background"); + assert!(world.get::(npc).is_some()); } } diff --git a/server/src/test_world/rooms/confrontation_stage.rs b/server/src/test_world/rooms/confrontation_stage.rs index 719eb1f8d..9f2af86da 100644 --- a/server/src/test_world/rooms/confrontation_stage.rs +++ b/server/src/test_world/rooms/confrontation_stage.rs @@ -24,6 +24,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 84; @@ -62,6 +63,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/crowd_plaza.rs b/server/src/test_world/rooms/crowd_plaza.rs index 7c0fa712b..443c69a78 100644 --- a/server/src/test_world/rooms/crowd_plaza.rs +++ b/server/src/test_world/rooms/crowd_plaza.rs @@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 80; @@ -57,6 +58,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/dialogue_room.rs b/server/src/test_world/rooms/dialogue_room.rs index 8474588c8..fd6e28ba2 100644 --- a/server/src/test_world/rooms/dialogue_room.rs +++ b/server/src/test_world/rooms/dialogue_room.rs @@ -22,6 +22,7 @@ use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 36; @@ -90,6 +91,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/eavesdrop_alcove.rs b/server/src/test_world/rooms/eavesdrop_alcove.rs index 3816cff3e..32b25c9ed 100644 --- a/server/src/test_world/rooms/eavesdrop_alcove.rs +++ b/server/src/test_world/rooms/eavesdrop_alcove.rs @@ -26,6 +26,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 74; @@ -65,6 +66,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/fog_theater.rs b/server/src/test_world/rooms/fog_theater.rs index 30d26d640..ef66529d7 100644 --- a/server/src/test_world/rooms/fog_theater.rs +++ b/server/src/test_world/rooms/fog_theater.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 28; @@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/inventory_warehouse.rs b/server/src/test_world/rooms/inventory_warehouse.rs index efd2dbbd5..95036a08a 100644 --- a/server/src/test_world/rooms/inventory_warehouse.rs +++ b/server/src/test_world/rooms/inventory_warehouse.rs @@ -20,6 +20,7 @@ use crate::simulation::interaction::Interactable; use crate::simulation::inventory::ItemName; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 2; @@ -66,6 +67,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, npc_pos, Want { diff --git a/server/src/test_world/rooms/occlusion_corridor.rs b/server/src/test_world/rooms/occlusion_corridor.rs index 49638465f..14ac27940 100644 --- a/server/src/test_world/rooms/occlusion_corridor.rs +++ b/server/src/test_world/rooms/occlusion_corridor.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 74; @@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/pause_chamber.rs b/server/src/test_world/rooms/pause_chamber.rs index fb8aefe4d..1e9bbdff4 100644 --- a/server/src/test_world/rooms/pause_chamber.rs +++ b/server/src/test_world/rooms/pause_chamber.rs @@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 42; @@ -29,6 +30,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/sprint_gauntlet.rs b/server/src/test_world/rooms/sprint_gauntlet.rs index bd49958a1..c81902519 100644 --- a/server/src/test_world/rooms/sprint_gauntlet.rs +++ b/server/src/test_world/rooms/sprint_gauntlet.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 0; @@ -51,6 +52,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, npc_pos, Want { diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index d913aa905..6bbf5a3a0 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -61,6 +61,7 @@ fn snapshot_roundtrip_over_unix_socket() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + sound_events: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 676589b86..95a62e734 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -47,6 +47,7 @@ fn snapshot_roundtrip_over_tcp() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + sound_events: vec![], }; bridge diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 2c8bc879c..d621d05bc 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -37,6 +37,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index ac0bd2d5d..b22c54827 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -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", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 6bc7e0d3e..677cd29df 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -26,6 +26,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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");