From 066f8031fda028241d3400fbb2867b2bcb8ea88d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 13:33:23 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20sprint=2012=20server=20?= =?UTF-8?q?=E2=80=94=20tier=20system,=20sound=20events,=20KG=20access,=20l?= =?UTF-8?q?ine=20previewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 4 completed tickets + partial progress on 2 more: - #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin) - #138 Information tag schema (ObserverAccess enum in knowledge/types.rs) - #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring) - #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes) - #94 Active tier simulation (in progress — With filters) - #139 Component-level access control (in progress — filter_by_access) Updates snapshot fixtures and test golden files for new sound_events field. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/worktree-update/SKILL.md | 6 +- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 259 -> 286 bytes .../snapshot_boundary_tick_127.msgpack | Bin 259 -> 286 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 263 -> 290 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 267 -> 294 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 261 -> 288 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 259 -> 286 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 664 -> 691 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 357 -> 384 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 360 -> 387 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 506 -> 533 bytes server/Cargo.lock | 129 +++- server/Cargo.toml | 1 + server/src/bin/line_preview.rs | 641 ++++++++++++++++++ server/src/bridge/text_renderer.rs | 2 + server/src/bridge/types.rs | 10 +- server/src/content/spawn.rs | 5 + server/src/knowledge/graph.rs | 245 +++++++ server/src/knowledge/types.rs | 39 ++ server/src/npc/routine.rs | 6 +- server/src/perception/observer/mod.rs | 11 + server/src/simulation/mod.rs | 8 + server/src/simulation/path_follow.rs | 7 +- server/src/simulation/sound.rs | 281 ++++++++ server/src/simulation/tier.rs | 255 ++++++- server/tests/bridge_ipc.rs | 1 + server/tests/bridge_tcp.rs | 1 + server/tests/gen_fixtures.rs | 2 + server/tests/golden/proof_room_tick_10.json | 5 +- server/tests/serialization.rs | 5 +- 30 files changed, 1646 insertions(+), 14 deletions(-) create mode 100644 server/src/bin/line_preview.rs create mode 100644 server/src/simulation/sound.rs 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/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25..3b499939158c6ec9f928f44be8d417950cca09fb 100644 GIT binary patch delta 64 zcmZo>n#aV|zq~B9s5mn}k82{=bKTX&$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q&!8!7+* delta 37 scmbQo)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01mJY-v9sr diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 8f350a9506d8f002f67a02d51b3d72ea3cebc25e..56c001dcf5d20729c9b60d4f2be711b6c2e8224e 100644 GIT binary patch delta 64 zcmZo>n#aV|zq~B9s5mn}k82{=bKTX&$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q&!8!7+* delta 37 scmbQo)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01mJY-v9sr diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 829fc4dabb266abdcd641c306f2cf934b256b992..6ae68786f089233a2719b365fbd67172e66f9d3b 100644 GIT binary patch delta 64 zcmZo?TExWFzq~B9s5mn}k82{=Yu(kw$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q==8#Vv{ delta 37 scmZ3))Xv1!ySyy5s5mn}k8>i|YoQHEIr+)isVVWPc_o=8nW@DS01zM!>i_@% diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 8723189c6b95e0586a8884f66f41a6948755c022..0822dc9c3abad26fc1570e3af0ec4037aea295d2 100644 GIT binary patch delta 64 zcmeBXTE@iHzq~B9s5mn}k82{=d)?K=$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q}18$tj8 delta 37 scmZ3+)Xl`zySyy5s5mn}k8>i|d!Y?UIr+)isVVWPc_o=8nW@DS01=Q5_W%F@ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 25a2615afb7c4683aba49befe4e3724fa2657cc3..69657bc2916e81914a14d15bdeffbeb83bb8e19b 100644 GIT binary patch delta 64 zcmZo=TEN8Bzq~B9s5mn}k82{=OWoDQ$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q+)8!!L> delta 37 scmZ3$)XK!wySyy5s5mn}k8>i|OQ8)(Ir+)isVVWPc_o=8nW@DS01s#mn#aV|zq~B9s5mn}k82{=bKTX&$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS N))eQL<^lN-c>q&!8!7+* delta 37 scmbQo)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01mJY-v9sr diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index c17c621184e0a2f3e4933e52e57dc044f3142c6c..673d30a77768530d7cc637cc893fff14d613f9fe 100644 GIT binary patch delta 65 zcmbQix|x-$e|cGIQE_H|9@j>$8YbP<#mR|z@u_90c_qaYHYDZbCugUo!~=PmC7G$k O6V?>xm*xTa5P1Mt@f#5U delta 38 tcmdnYI)jy~cX?TAQE_H|9_L1`8YZC)Njdq+*{LblJC!^4Yq@4WZ?9`O_)Vz|+lFZcN2>=xw4yOPB diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 29bb08964bfd1e9dd82705516b2d2c7cadb1173e..d22a725327f22d8f79751972039fc47a8be13e9a 100644 GIT binary patch delta 65 zcmaFC)XdD)zq~B9s5mn}k82~B52Nnt;^f4<_|&r0yprMx8e!;}mySyy5s5mn}k8>lJ52MhAq@4WZ?9`O_)Vz|+lFZcN2>=#34zK_K diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index dea8f77dd6130ff7aef5a80fd4eb44d9927a2192..2aa4f71c82617df3935fe6857b7d5e8135970a0f 100644 GIT binary patch delta 65 zcmeyxJe7s3e|cGIQE_H|9@j>$dyKlPi<1-c;#12~^Gb>*Y)Hz, + + /// 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..8bef80d07 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,7 +28,7 @@ pub const PROTOCOL_VERSION: u8 = 9; /// v7 adds: pending_recognitions (#423, D-060 cognitive delay). /// v8 adds: dialogue_response (#305, D-028 dialogue pipeline). /// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities). -/// Future fields: ambient sound events, HUD state (D-020 expansion). +/// v10 adds: sound_events (#124, D-038 server sound event pipeline). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { /// Protocol version for forward compatibility. Current: 9. @@ -79,6 +79,12 @@ pub struct ObserverSnapshot { /// Sorted ascending for deterministic output. Client can safely ignore. #[serde(default)] pub blocked_entities: Vec, + /// 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..9ffc52464 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -231,6 +231,68 @@ impl Default for KnowledgeGraph { } } +// --- Access control filter (#139, D-010 principle 2) --- + +/// Component-level access control filter. +/// +/// Called by the observer snapshot builder before including a component's +/// sensitive data in the snapshot. Returns `true` if `observer_id` is +/// permitted to read a component tagged with `rule` on entity `target_id`. +/// +/// Design: coarse-grained component-level check. A component either passes +/// or fails as a whole. See `ObserverAccess` for available rules. +/// +/// # Arguments +/// - `observer_id`: StableId of the entity requesting access. +/// - `target_id`: StableId of the entity that owns the component. +/// - `rule`: The access rule attached to the component via `AccessRule`. +/// - `kg`: The observer's `KnowledgeGraph` (used for relationship and knowledge checks). +pub fn filter_by_access( + observer_id: StableId, + target_id: StableId, + rule: &ObserverAccess, + kg: &KnowledgeGraph, +) -> bool { + match rule { + // Public data is always readable. + ObserverAccess::Public => true, + + // OwnerOnly: only the entity that owns the component can read it. + // Primary use case: player's own inventory (D-065). + ObserverAccess::OwnerOnly => observer_id == target_id, + + // FactionOnly: observer must have a recorded faction match with the target. + // Stored as a "faction_id" key in the target's known_attributes. + // Full faction system deferred; approximation via knowledge attributes. + ObserverAccess::FactionOnly(faction_id) => kg + .entities + .get(&target_id) + .and_then(|k| k.known_attributes.get("faction_id")) + .and_then(|v| v.parse::().ok()) + .is_some_and(|id| id == faction_id.0), + + // RelationshipGated: observer must have a relationship score >= threshold. + // Threshold is 0–100; maps to RelationshipState enum values. + ObserverAccess::RelationshipGated(threshold) => { + let score: i32 = match kg.relationship_with(&target_id) { + RelationshipState::Unknown => 0, + RelationshipState::Known => 25, + RelationshipState::PersonOfInterest => 40, + RelationshipState::Friendly => 75, + RelationshipState::Hostile => 5, + }; + score >= *threshold + } + + // KnowledgeGated: observer must have a specific fact in their knowledge graph. + // Used for "you only see this if you know about it" information walls. + ObserverAccess::KnowledgeGated(flag) => { + let fact_id = FactId(flag.clone()); + kg.knows_fact(&fact_id) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -468,4 +530,187 @@ mod tests { let ids: Vec = 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" + ); + } } 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..19cd733ad 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(); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index aa7887317..357becc4a 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -21,6 +21,7 @@ use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; @@ -56,6 +57,7 @@ pub fn compute_observer_snapshot( time: Res, geometry: Res, registry: Res, + sound_queue: Option>, mut observer_query: Query< ( Entity, @@ -176,6 +178,14 @@ pub fn compute_observer_snapshot( .map(|buf| buf.take()) .unwrap_or_default(); + // Collect sound events audible to the observer (D-038, #124). + // Filter by D-018 range: only events the player can hear based on distance. + let sound_events = if let Some(ref queue) = sound_queue { + queue.audible_at(_observer_pos).cloned().collect() + } else { + Vec::new() + }; + // Build pending recognitions from CognitiveDelay (#423, D-060) let pending_recognitions = cognitive_delay_opt .map(|delay| { @@ -215,6 +225,7 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, + sound_events, }); } 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/path_follow.rs b/server/src/simulation/path_follow.rs index 64a11c3fe..7aa260851 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 { diff --git a/server/src/simulation/sound.rs b/server/src/simulation/sound.rs new file mode 100644 index 000000000..c752550da --- /dev/null +++ b/server/src/simulation/sound.rs @@ -0,0 +1,281 @@ +// Sound event system — server side (#124) +// Implements D-038: SoundEventEmitter → SoundEventQueue → ObserverSnapshot. +// Event-driven: emitters post events each tick, queue fans out to subscribers. +// +// Range model per D-018: +// Close ≤ 3 tiles — always heard, spatial positioning +// Medium ≤ 8 tiles — heard if not obstructed +// Long ≤ 20 tiles — heard in quiet conditions + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::types::SoundRange; +use crate::simulation::movement::TilePosition; + +// --- Sound event type taxonomy --- + +/// Typed sound event categories. +/// Client maps each kind to its audio asset registry key (D-038). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SoundEventKind { + /// Footstep — emitted by moving entities. Intensity varies by stance. + Footstep, + /// Voice — dialogue, monologue, NPC speech. + Voice, + /// Machinery — terminals, doors, consoles, mechanical activity. + Machinery, + /// Alert — alarms, warnings, emergency signals. + Alert, + /// Ambient — location atmosphere, background environment. + Ambient, +} + +// --- Sound event --- + +/// A single sound event emitted this tick. +/// Produced by `SoundEventEmitter`, collected into `SoundEventQueue`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SoundEvent { + /// What kind of sound this is. + pub kind: SoundEventKind, + /// World position where the sound originates (tile centre). + pub x: f32, + pub y: f32, + pub z: i32, + /// Normalized intensity in [0.0, 1.0]. Drives volume at the client. + pub intensity: f32, + /// How far this sound propagates (D-018 three-range model). + pub range: SoundRange, + /// Stable entity ID of the source, if any. + /// None for procedural or world-generated events (e.g. Ambient). + pub source_entity_id: Option, +} + +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; occlusion is a future concern (D-018 note). + pub fn audible_at(&self, listener_pos: &TilePosition) -> bool { + let ceil = Self::max_range_tiles(self.range); + let dx = (self.x.floor() as i32).abs_diff(listener_pos.x); + let dy = (self.y.floor() as i32).abs_diff(listener_pos.y); + let dz = (self.z).abs_diff(listener_pos.z); + dz == 0 && dx + dy <= ceil + } +} + +// --- Emitter component --- + +/// Component: entity emits sound events this tick. +/// +/// Attached transiently — systems add this component to entities when they +/// produce sound (step taken, line spoken, door opened). The `collect_sound_events` +/// system harvests all emitters each tick, drains their pending events into +/// `SoundEventQueue`, and removes the component. +/// +/// Usage pattern (illustrative): +/// ```ignore +/// commands.entity(npc).insert(SoundEventEmitter::new( +/// SoundEvent::at(&pos, SoundEventKind::Footstep, 0.6, SoundRange::Close, Some(npc_id)) +/// )); +/// ``` +#[derive(Component, Debug, Clone, Default)] +pub struct SoundEventEmitter { + pub pending: Vec, +} + +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`. +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); + } +} diff --git a/server/src/simulation/tier.rs b/server/src/simulation/tier.rs index f002cf899..0a5c2ca77 100644 --- a/server/src/simulation/tier.rs +++ b/server/src/simulation/tier.rs @@ -2,9 +2,44 @@ // Implements D-026: Active/Background/State-saved/Ungenerated tiers // Timestamp-based LRU eviction for simulation space management +use bevy_app::prelude::*; use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +// --- Zero-sized marker components (D-026) --- +// Tag-based tier identification. Systems query With to scope work +// to nearby NPCs only, avoiding full-world iteration every tick. + +/// Marker: entity is in the Active simulation tier. +/// Full behavior systems (movement, perception, dialogue, monologue) run for +/// entities with this tag at 10–20 ticks/sec. +#[derive(Component, Debug, Clone, Copy, Default)] +pub struct ActiveSim; + +/// Marker: entity is in the Background simulation tier. +/// Lightweight schedule-keeping only — no full perception or dialogue. +#[derive(Component, Debug, Clone, Copy, Default)] +pub struct BackgroundSim; + +/// Marker: entity is in the State-saved tier. +/// ECS components preserved but no systems run. Re-promoted to Background +/// or Active when player approaches. +#[derive(Component, Debug, Clone, Copy, Default)] +pub struct StateSaved; + +/// Plugin registering the tier marker components and associated resources. +/// Systems that filter by tier (With, etc.) require these markers +/// to exist in the type registry. Future: tier transition systems live here. +pub struct TierPlugin; + +impl Plugin for TierPlugin { + fn build(&self, _app: &mut App) { + // Marker components are zero-sized — no resources to initialize. + // Tier transition systems will be added here in ticket #99. + tracing::debug!("TierPlugin initialized"); + } +} + #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SimulationTier { Active, @@ -34,10 +69,13 @@ pub enum ScopeKind { #[cfg(test)] mod tests { use super::*; + use bevy_ecs::world::World; + + // --- SimulationTier enum tests --- #[test] fn tier_can_be_added_and_queried() { - let mut world = bevy_ecs::world::World::new(); + let mut world = World::new(); let entity = world.spawn(SimulationTier::Active).id(); assert_eq!( *world.get::(entity).unwrap(), @@ -47,7 +85,7 @@ mod tests { #[test] fn tier_can_transition() { - let mut world = bevy_ecs::world::World::new(); + let mut world = World::new(); let entity = world.spawn(SimulationTier::Active).id(); world.entity_mut(entity).insert(SimulationTier::Background); assert_eq!( @@ -55,4 +93,217 @@ mod tests { SimulationTier::Background ); } + + #[test] + fn all_tier_variants_are_distinct() { + assert_ne!(SimulationTier::Active, SimulationTier::Background); + assert_ne!(SimulationTier::Background, SimulationTier::StateSaved); + assert_ne!(SimulationTier::StateSaved, SimulationTier::Ungenerated); + assert_ne!(SimulationTier::Active, SimulationTier::Ungenerated); + } + + // --- Marker component query correctness (D-026, #94) --- + // These tests verify that With / With / With + // filter correctly — the core guarantee that behavior systems only run for the + // intended tier. + + #[test] + fn with_active_sim_query_excludes_background_entities() { + let mut world = World::new(); + let active = world.spawn(ActiveSim).id(); + let _background = world.spawn(BackgroundSim).id(); + let _state_saved = world.spawn(StateSaved).id(); + + let mut query = world.query_filtered::>(); + 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!( + active_query.iter(&world).count(), + 0, + "demoted entity not in ActiveSim query" + ); + } + + #[test] + fn demote_active_to_state_saved() { + let mut world = World::new(); + let entity = world.spawn(ActiveSim).id(); + + world + .entity_mut(entity) + .remove::() + .insert(StateSaved); + + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_none()); + } + + // --- LastInteraction and ScopeTag --- + + #[test] + fn last_interaction_records_tick() { + let mut world = World::new(); + let entity = world.spawn(LastInteraction { tick: 42 }).id(); + + let interaction = world.get::(entity).unwrap(); + assert_eq!(interaction.tick, 42); + } + + #[test] + fn last_interaction_tick_can_be_updated() { + let mut world = World::new(); + let entity = world.spawn(LastInteraction { tick: 1 }).id(); + + world.entity_mut(entity).insert(LastInteraction { tick: 100 }); + + let interaction = world.get::(entity).unwrap(); + assert_eq!(interaction.tick, 100); + } + + #[test] + fn scope_tag_neighborhood_kind() { + let mut world = World::new(); + let entity = world + .spawn(ScopeTag { + tags: vec![ScopeKind::Neighborhood], + }) + .id(); + + let tag = world.get::(entity).unwrap(); + assert!(tag.tags.contains(&ScopeKind::Neighborhood)); + assert!(!tag.tags.contains(&ScopeKind::ActiveQuest)); + } + + #[test] + fn scope_tag_multiple_kinds() { + let entity = ScopeTag { + tags: vec![ + ScopeKind::Neighborhood, + ScopeKind::Colleague, + ScopeKind::KnownContact, + ], + }; + assert_eq!(entity.tags.len(), 3); + assert!(entity.tags.contains(&ScopeKind::Colleague)); + assert!(entity.tags.contains(&ScopeKind::KnownContact)); + assert!(!entity.tags.contains(&ScopeKind::ActiveQuest)); + } + + // --- TierPlugin smoke test --- + + #[test] + fn tier_plugin_builds_without_panic() { + let mut app = bevy_app::App::new(); + app.add_plugins(TierPlugin); + // Just verifying it doesn't panic on build + } } 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");