feat(simulation): voice pipeline integration — Factual bypass, observer wiring, tell iteration (#650 #652 #651)
ContentType::Factual bypasses LLM for fact-bearing lines (numbers, denials). Observer enrichment systems rewrite dialogue/conversation text with voiced versions before snapshot assembly. Friendly and RoutineDeviation tells get concrete surface-pattern examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
//! 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.
|
||||
#[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);
|
||||
// Use speaker_id as the content derivation input — conversation lines
|
||||
// don't have a stable line_id, so use a hash of the line text itself.
|
||||
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("");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user