Files
settled-reach/server/src/voice/integration.rs
T
jpmschweitzerandClaude Opus 4.6 51414b0b63 fix(simulation): address PR #89 review — version comment, settings contract, docs, dead process recovery
Must-fix: protocol version comment now references PROTOCOL_VERSION
(no hardcoded number), settings delete is idempotent no-op.

Suggestions addressed: BehaviorModifier dedup claim dropped, hash
collision safety documented, FK pragma in test store, columns_to_value
consolidated, modifier_hint mismatch logging, OffDuty test coverage,
batching tradeoff documented, unknown value_type warning, occluded
text risk documented, dead child respawn in voice worker, INJECT
block ambiguity documented.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:34:33 +01:00

237 lines
8.4 KiB
Rust

//! 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<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;
}
/// 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<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 conversation_buffer: Query<&mut ConversationEventBuffer, With<PlayerCharacter>>,
) {
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("");
}
}