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>
237 lines
8.4 KiB
Rust
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("");
|
|
}
|
|
}
|