//! Observer integration for the voice pipeline (D-138, Phase 3). //! //! Two enrichment systems run before `compute_observer_snapshot` and rewrite //! NPC text in the dialogue and conversation buffers with voiced variants //! looked up from the cache. Cache miss → base text (never blocks). //! //! ## System ordering //! //! ```text //! process_talk_interaction ─┐ //! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐ //! └─► voice_enrich_conversation_events ─► 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::conversation::ConversationEventBuffer; 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>, 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>, registry: Res, zone_map: Option>, player_query: Query<&TilePosition, With>, npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With>, mut dialogue_buffer: Query<&mut DialogueResponseBuffer, With>, ) { 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; } /// Enrich the conversation event buffer with voiced lines from the cache. /// /// Must run after `run_npc_conversations` and before /// `compute_observer_snapshot`. No-op when the voice cache resource is absent. /// /// Each event in the buffer is processed: the pre-occlusion base text is /// not available at this point (occlusion has already been applied), so the /// `occluded_line` is treated as the base text for the voice lookup. /// This means the voice register wraps the already-occluded line. /// /// ## Accepted risk: re-voicing of heavily occluded text /// /// When many words are dropped by D-078 occlusion, the remaining text may /// be fragmentary ("... the ... came in ..."). Re-voicing such fragments can /// produce incoherent output. This is acceptable for two reasons: /// 1. Cache misses are common for conversation text (no pre-baking pipeline), /// so the base text fallback in `voiced_behavior()` fires most of the time. /// 2. Even incoherent voiced output is no worse than the already-degraded /// overheard line — the occlusion itself has already broken coherence. /// The player's inability to fully parse overheard speech is the mechanic. #[allow(clippy::type_complexity)] pub fn voice_enrich_conversation_events( voice_cache: Option>, registry: Res, zone_map: Option>, player_query: Query<&TilePosition, With>, npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With>, mut conversation_buffer: Query<&mut ConversationEventBuffer, With>, ) { let Some(voice_cache) = voice_cache else { return; }; let Ok(mut buffer) = conversation_buffer.single_mut() else { return; }; if buffer.events.is_empty() { return; } let zone_id = player_zone_id(&player_query, zone_map.as_deref()); for event in &mut buffer.events { let speaker_entity = registry.to_entity(&StableId(event.speaker_id)); let Some(entity) = speaker_entity else { continue; }; let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else { continue; }; let tell_state = tell_state_opt.and_then(|t| t.category); // Conversation lines don't have a stable line_id; derive content_index // from the occluded line text. The full cache key is // (culture_id, npc_stable_id, content_type, content_index, tell_state), // so a u16 hash collision requires two different lines from the same // speaker with the same tell state to hash identically — at ~65K // possible values this is extremely rare, and the worst outcome is // a stale voiced line being served instead of base text. Acceptable. let content_index = content_index_from_line_id(&event.occluded_line); let voiced = voiced_behavior( &voice_cache.cache, zone_id, &voice_profile.culture_id, event.speaker_id, ContentType::Dialogue, content_index, tell_state, &event.occluded_line, false, ); event.occluded_line = 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(""); } }