Files
settled-reach/server/src/voice/integration.rs
T
jpmschweitzerandClaude Sonnet 4.6 e86e53ec06 feat(engine): retire D-078 overheard conversation system (#848)
Per R-012: delete conversation.rs, both overheard content files, and
remove all 6 wire-up points (social_plugin, bridge/types, monologue,
voice/integration). Protocol version 22 → 23. Scope confirmed by
#842 audit — npc/ and content/global/ untouched. Surviving NPC
components (NpcName, NpcColorIndex, NpcConversation) migrated to
simulation/npc_components.rs for use by D-080 knowledge propagation.
Also applies pre-existing cargo fmt debt (names.rs and 4 others).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:08:26 +02:00

155 lines
5.0 KiB
Rust

//! Observer integration for the voice pipeline (D-138, Phase 3).
//!
//! One enrichment system runs before `compute_observer_snapshot` and rewrites
//! NPC dialogue text with voiced variants looked up from the cache.
//! Cache miss → base text (never blocks).
//!
//! ## System ordering
//!
//! ```text
//! process_talk_interaction ─► voice_enrich_dialogue_response ─► compute_observer_snapshot
//! ```
//!
//! ## Content index derivation
//!
//! The voice cache key uses a `content_index: u16` to distinguish individual
//! lines for the same NPC. For dialogue lines the index is derived via
//! FNV-1a hash of `line_id`, matching the derivation used when requests
//! are submitted to the worker queue for pre-voicing.
use bevy_ecs::prelude::*;
use crate::knowledge::{EntityRegistry, StableId};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Npc, NpcVoiceProfile};
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::zone::ZoneMap;
use crate::voice::lookup::{voiced_behavior, VoiceCacheResource};
use crate::voice::prompt_builder::ContentType;
// ---------------------------------------------------------------------------
// Content index derivation
// ---------------------------------------------------------------------------
/// Derive a deterministic content_index (u16) from a string line identifier.
///
/// Uses FNV-1a (64-bit) truncated to u16 — same algorithm used by the worker
/// queue when submitting pre-voicing requests, ensuring cache key consistency.
pub fn content_index_from_line_id(line_id: &str) -> u16 {
let mut hash: u64 = 0xcbf29ce484222325;
for b in line_id.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash as u16
}
// ---------------------------------------------------------------------------
// Player zone resolution
// ---------------------------------------------------------------------------
fn player_zone_id(
player_query: &Query<&TilePosition, With<PlayerCharacter>>,
zone_map: Option<&ZoneMap>,
) -> u32 {
let Ok(pos) = player_query.single() else {
return 0;
};
zone_map
.and_then(|zm| zm.zone_at(pos.x, pos.y, pos.z))
.map(|id| id as u32)
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Enrich the dialogue response buffer with a voiced line from the cache.
///
/// Must run after `process_talk_interaction` and before
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
///
/// The system looks up the NPC's voice culture and current tell state, then
/// calls `voiced_behavior()` to retrieve a cached voiced line. On cache miss
/// the buffer text is unchanged (base text serves as fallback).
#[allow(clippy::type_complexity)]
pub fn voice_enrich_dialogue_response(
voice_cache: Option<Res<VoiceCacheResource>>,
registry: Res<EntityRegistry>,
zone_map: Option<Res<ZoneMap>>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
mut dialogue_buffer: Query<&mut DialogueResponseBuffer, With<PlayerCharacter>>,
) {
let Some(voice_cache) = voice_cache else {
return;
};
let Ok(mut buffer) = dialogue_buffer.single_mut() else {
return;
};
let Some(ref mut response) = buffer.response else {
return;
};
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
let speaker_entity = registry.to_entity(&StableId(response.speaker_entity_id));
let Some(entity) = speaker_entity else {
return;
};
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
return;
};
let tell_state = tell_state_opt.and_then(|t| t.category);
let content_index = content_index_from_line_id(&response.line_id);
let voiced = voiced_behavior(
&voice_cache.cache,
zone_id,
&voice_profile.culture_id,
response.speaker_entity_id,
ContentType::Dialogue,
content_index,
tell_state,
&response.text,
false,
);
response.text = voiced;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_index_is_deterministic() {
let a = content_index_from_line_id("dock_d_001");
let b = content_index_from_line_id("dock_d_001");
assert_eq!(a, b);
}
#[test]
fn content_index_differs_for_different_ids() {
let a = content_index_from_line_id("dock_d_001");
let b = content_index_from_line_id("dock_d_002");
assert_ne!(a, b);
}
#[test]
fn content_index_empty_string() {
// Should not panic
let _ = content_index_from_line_id("");
}
}