//! Voice cache lookup for behavior text (D-138, Phase 3). //! //! Provides the integration point between the voice cache and any system //! that serves NPC text to the client. Wired into the observer pipeline //! via `voice::integration` enrichment systems. use bevy_ecs::prelude::*; use std::sync::{Arc, Mutex}; use crate::npc::tell_state::TellCategory; use crate::voice::cache::{CacheKey, VoiceCacheStore}; use crate::voice::prompt_builder::ContentType; /// ECS resource wrapping the voice cache store. /// /// Optional — systems that use it gracefully degrade to base text when absent. /// Populated at startup by the voice subsystem when the pipeline is enabled. #[derive(Resource)] pub struct VoiceCacheResource { pub cache: Arc>, } /// Look up a voiced behavior from cache, falling back to base text. /// /// Tell behaviors (from `NpcBlueprint.tell_behaviors`) are NEVER re-voiced — /// they are always base text passthrough. Only pass `is_tell_behavior: false` /// for `observable_behaviors` content. pub fn voiced_behavior( cache: &Arc>, zone_id: u32, culture_id: &str, npc_stable_id: u64, content_type: ContentType, content_index: u16, tell_state: Option, base_text: &str, is_tell_behavior: bool, ) -> String { // Tell behaviors are always passthrough — never re-voiced. if is_tell_behavior { return base_text.to_string(); } // Factual content is always base text — no LLM, no cache lookup. if content_type == ContentType::Factual { return base_text.to_string(); } let key = CacheKey { culture_id: culture_id.to_string(), npc_stable_id, content_type, content_index, tell_state, }; if let Ok(mut store) = cache.lock() { if let Some(voiced) = store.lookup(zone_id, &key) { return voiced; } } // Cache miss — serve base text (graceful degradation). base_text.to_string() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Build a store with a directory unique to this test (T-1035). /// /// `VoiceCacheStore` persists to disk on `Drop` (`save_all`) and reads it back /// on first zone access (`load_zone`). A shared directory therefore lets one /// test's stored entry leak into another's lookup under parallel execution — /// the `cache_hit`/`cache_miss` pair use an identical `(zone_id, CacheKey)`, /// which is exactly how this module flaked. Isolating by test label + process /// id keeps both parallel tests and concurrent `cargo test` runs from colliding. fn test_cache(label: &str) -> Arc> { let dir = std::env::temp_dir().join(format!( "sr-voice-lookup-test/{}-{}", std::process::id(), label )); let _ = std::fs::remove_dir_all(&dir); Arc::new(Mutex::new(VoiceCacheStore::new( dir, 99, "v1".into(), "i1".into(), ))) } #[test] fn cache_hit_returns_voiced_text() { let cache = test_cache("cache-hit"); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Behavior, content_index: 0, tell_state: None, }; cache.lock().unwrap().store(100, key, "Voiced line.".into()); let result = voiced_behavior( &cache, 100, "van-maanens-star", 42, ContentType::Behavior, 0, None, "Base line.", false, ); assert_eq!(result, "Voiced line."); } #[test] fn cache_miss_returns_base_text() { let cache = test_cache("cache-miss"); let result = voiced_behavior( &cache, 100, "van-maanens-star", 42, ContentType::Behavior, 0, None, "Base line.", false, ); assert_eq!(result, "Base line."); } #[test] fn factual_content_always_passthrough() { let cache = test_cache("factual"); // Store a voiced version under the Factual key — it must never be returned. let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Factual, content_index: 0, tell_state: None, }; cache .lock() .unwrap() .store(100, key, "Voiced factual (should be ignored).".into()); let result = voiced_behavior( &cache, 100, "van-maanens-star", 42, ContentType::Factual, 0, None, "14 crates in bay seven.", false, ); assert_eq!(result, "14 crates in bay seven."); } #[test] fn tell_behavior_always_passthrough() { let cache = test_cache("tell"); // Even if cache has a voiced version, tell behaviors return base text let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Behavior, content_index: 0, tell_state: Some(TellCategory::Angry), }; cache.lock().unwrap().store(100, key, "Voiced tell.".into()); let result = voiced_behavior( &cache, 100, "van-maanens-star", 42, ContentType::Behavior, 0, Some(TellCategory::Angry), "Base tell.", true, ); assert_eq!(result, "Base tell."); } }