Files
settled-reach/server/src/simulation/sound.rs
T
jpmschweitzerandClaude Opus 4.6 066f8031fd feat(simulation): sprint 12 server — tier system, sound events, KG access, line previewer
Implements 4 completed tickets + partial progress on 2 more:

- #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin)
- #138 Information tag schema (ObserverAccess enum in knowledge/types.rs)
- #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring)
- #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes)
- #94 Active tier simulation (in progress — With<ActiveSim> filters)
- #139 Component-level access control (in progress — filter_by_access)

Updates snapshot fixtures and test golden files for new sound_events field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:33:23 +01:00

282 lines
9.1 KiB
Rust

// Sound event system — server side (#124)
// Implements D-038: SoundEventEmitter → SoundEventQueue → ObserverSnapshot.
// Event-driven: emitters post events each tick, queue fans out to subscribers.
//
// Range model per D-018:
// Close ≤ 3 tiles — always heard, spatial positioning
// Medium ≤ 8 tiles — heard if not obstructed
// Long ≤ 20 tiles — heard in quiet conditions
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::knowledge::types::SoundRange;
use crate::simulation::movement::TilePosition;
// --- Sound event type taxonomy ---
/// Typed sound event categories.
/// Client maps each kind to its audio asset registry key (D-038).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SoundEventKind {
/// Footstep — emitted by moving entities. Intensity varies by stance.
Footstep,
/// Voice — dialogue, monologue, NPC speech.
Voice,
/// Machinery — terminals, doors, consoles, mechanical activity.
Machinery,
/// Alert — alarms, warnings, emergency signals.
Alert,
/// Ambient — location atmosphere, background environment.
Ambient,
}
// --- Sound event ---
/// A single sound event emitted this tick.
/// Produced by `SoundEventEmitter`, collected into `SoundEventQueue`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoundEvent {
/// What kind of sound this is.
pub kind: SoundEventKind,
/// World position where the sound originates (tile centre).
pub x: f32,
pub y: f32,
pub z: i32,
/// Normalized intensity in [0.0, 1.0]. Drives volume at the client.
pub intensity: f32,
/// How far this sound propagates (D-018 three-range model).
pub range: SoundRange,
/// Stable entity ID of the source, if any.
/// None for procedural or world-generated events (e.g. Ambient).
pub source_entity_id: Option<u64>,
}
impl SoundEvent {
/// Create an event at a tile position.
pub fn at(
pos: &TilePosition,
kind: SoundEventKind,
intensity: f32,
range: SoundRange,
source_entity_id: Option<u64>,
) -> Self {
let (x, y, z) = pos.to_render_coords();
Self {
kind,
x,
y,
z,
intensity,
range,
source_entity_id,
}
}
/// Manhattan-distance range ceiling in tiles for each category (D-018).
pub fn max_range_tiles(range: SoundRange) -> u32 {
match range {
SoundRange::Close => 3,
SoundRange::Medium => 8,
SoundRange::Long => 20,
}
}
/// Whether this sound is audible at `listener_pos`.
/// Simple tile-distance check; occlusion is a future concern (D-018 note).
pub fn audible_at(&self, listener_pos: &TilePosition) -> bool {
let ceil = Self::max_range_tiles(self.range);
let dx = (self.x.floor() as i32).abs_diff(listener_pos.x);
let dy = (self.y.floor() as i32).abs_diff(listener_pos.y);
let dz = (self.z).abs_diff(listener_pos.z);
dz == 0 && dx + dy <= ceil
}
}
// --- Emitter component ---
/// Component: entity emits sound events this tick.
///
/// Attached transiently — systems add this component to entities when they
/// produce sound (step taken, line spoken, door opened). The `collect_sound_events`
/// system harvests all emitters each tick, drains their pending events into
/// `SoundEventQueue`, and removes the component.
///
/// Usage pattern (illustrative):
/// ```ignore
/// commands.entity(npc).insert(SoundEventEmitter::new(
/// SoundEvent::at(&pos, SoundEventKind::Footstep, 0.6, SoundRange::Close, Some(npc_id))
/// ));
/// ```
#[derive(Component, Debug, Clone, Default)]
pub struct SoundEventEmitter {
pub pending: Vec<SoundEvent>,
}
impl SoundEventEmitter {
pub fn new(event: SoundEvent) -> Self {
Self {
pending: vec![event],
}
}
pub fn with(mut self, event: SoundEvent) -> Self {
self.pending.push(event);
self
}
}
// --- Queue resource ---
/// Resource: sound events produced this tick.
///
/// `collect_sound_events` drains all `SoundEventEmitter` components into this
/// resource each tick. Consumers (observer snapshot builder, NPC awareness
/// system) read from the queue. Cleared at the top of each tick.
#[derive(Resource, Debug, Default)]
pub struct SoundEventQueue {
pub events: Vec<SoundEvent>,
}
impl SoundEventQueue {
/// Drain all queued events, leaving the queue empty.
pub fn drain(&mut self) -> Vec<SoundEvent> {
std::mem::take(&mut self.events)
}
/// Return events audible at a listener position, without draining.
pub fn audible_at<'a>(&'a self, pos: &'a TilePosition) -> impl Iterator<Item = &'a SoundEvent> {
self.events.iter().filter(move |e| e.audible_at(pos))
}
}
// --- Collection system ---
/// System: harvest SoundEventEmitters → SoundEventQueue.
///
/// Runs each tick after movement/monologue/dialogue systems have fired.
/// Removes the emitter component after draining. Ordering: after movement,
/// before `compute_observer_snapshot`.
pub fn collect_sound_events(
mut commands: Commands,
mut queue: ResMut<SoundEventQueue>,
mut emitters: Query<(Entity, &mut SoundEventEmitter)>,
) {
queue.events.clear();
for (entity, mut emitter) in emitters.iter_mut() {
queue.events.extend(emitter.pending.drain(..));
commands.entity(entity).remove::<SoundEventEmitter>();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tile(x: i32, y: i32) -> TilePosition {
TilePosition::new(x, y, 0)
}
fn close_event(pos: &TilePosition) -> SoundEvent {
SoundEvent::at(pos, SoundEventKind::Footstep, 0.5, SoundRange::Close, None)
}
fn medium_event(pos: &TilePosition) -> SoundEvent {
SoundEvent::at(pos, SoundEventKind::Voice, 0.7, SoundRange::Medium, None)
}
#[test]
fn close_range_audible_within_3_tiles() {
let source = tile(5, 5);
let event = close_event(&source);
assert!(event.audible_at(&tile(5, 5)), "audible at origin");
assert!(event.audible_at(&tile(5, 8)), "audible at distance 3");
assert!(!event.audible_at(&tile(5, 9)), "not audible at distance 4");
}
#[test]
fn medium_range_audible_within_8_tiles() {
let source = tile(0, 0);
let event = medium_event(&source);
assert!(event.audible_at(&tile(4, 4)), "audible at manhattan 8");
assert!(!event.audible_at(&tile(5, 4)), "not audible at manhattan 9");
}
#[test]
fn different_z_level_not_audible() {
let source = tile(5, 5);
let event = close_event(&source);
let above = TilePosition::new(5, 5, 1);
assert!(!event.audible_at(&above), "different z not audible");
}
#[test]
fn collect_system_drains_emitters_into_queue() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
let _entity = world
.spawn(SoundEventEmitter::new(close_event(&pos)))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(collect_sound_events);
schedule.run(&mut world);
let queue = world.resource::<SoundEventQueue>();
assert_eq!(queue.events.len(), 1);
assert_eq!(queue.events[0].kind, SoundEventKind::Footstep);
}
#[test]
fn collect_system_removes_emitter_component() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let entity = world
.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(collect_sound_events);
schedule.run(&mut world);
assert!(
world.get::<SoundEventEmitter>(entity).is_none(),
"emitter component should be removed after collection"
);
}
#[test]
fn queue_audible_at_filters_by_range() {
let mut queue = SoundEventQueue::default();
let close_pos = tile(5, 5);
let far_pos = tile(20, 20);
queue.events.push(close_event(&close_pos));
queue.events.push(close_event(&far_pos));
let listener = tile(5, 6);
let heard: Vec<&SoundEvent> = queue.audible_at(&listener).collect();
assert_eq!(heard.len(), 1, "only close sound is audible");
}
#[test]
fn queue_clears_each_tick() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
world.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(collect_sound_events);
// Tick 1: event collected
schedule.run(&mut world);
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 1);
// Tick 2: no new emitters → queue cleared
schedule.run(&mut world);
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 0);
}
}