//! MessagePack voice cache (D-138, Spike 2). //! //! Stores re-voiced text keyed by (npc, content, tell state, culture). //! Length-gated variant count: short lines cache neutral only, medium lines //! cache 3 variants, long lines cache all applicable tells. //! //! Baked content is just pre-populated cache — `make voice-bake` writes to //! the same directory. No separate baked path. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::io; use std::path::PathBuf; use crate::npc::tell_state::TellCategory; use crate::voice::prompt_builder::ContentType; /// Cache key for a single voiced line. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct CacheKey { pub culture_id: String, pub npc_stable_id: u64, pub content_type: ContentType, pub content_index: u16, /// Tell state variant. `None` = neutral (used for short content). pub tell_state: Option, } /// A single zone's voice cache — maps cache keys to voiced text. #[derive(Debug, Default, Serialize, Deserialize)] pub struct ZoneVoiceCache { /// Model version hash — cache miss if this doesn't match. pub model_version: String, /// Injector version hash — cache miss if this doesn't match. pub injector_version: String, /// Cached voiced lines. pub entries: BTreeMap, } impl ZoneVoiceCache { pub fn new(model_version: String, injector_version: String) -> Self { Self { model_version, injector_version, entries: BTreeMap::new(), } } /// Look up a cached voiced line. Returns `None` on miss. pub fn lookup(&self, key: &CacheKey) -> Option<&str> { self.entries.get(key).map(|s| s.as_str()) } /// Store a voiced line in the cache. pub fn store(&mut self, key: CacheKey, text: String) { self.entries.insert(key, text); } /// Number of cached entries. pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } /// Manages voice caches across zones with disk persistence. #[derive(Debug)] pub struct VoiceCacheStore { /// Base directory for cache files. base_dir: PathBuf, /// World seed — part of the directory path. world_seed: u64, /// Current model version hash. model_version: String, /// Current injector version hash. injector_version: String, /// Loaded zone caches. zones: BTreeMap, } impl VoiceCacheStore { /// Create a new cache store. Does not load any zones yet. pub fn new( base_dir: PathBuf, world_seed: u64, model_version: String, injector_version: String, ) -> Self { Self { base_dir, world_seed, model_version, injector_version, zones: BTreeMap::new(), } } /// Get or load the cache for a zone. pub fn zone_cache(&mut self, zone_id: u32) -> &mut ZoneVoiceCache { if !self.zones.contains_key(&zone_id) { let cache = self.load_zone(zone_id).unwrap_or_else(|| { ZoneVoiceCache::new(self.model_version.clone(), self.injector_version.clone()) }); self.zones.insert(zone_id, cache); } self.zones.get_mut(&zone_id).unwrap() } /// Look up a voiced line across the right zone cache. pub fn lookup(&mut self, zone_id: u32, key: &CacheKey) -> Option { let cache = self.zone_cache(zone_id); cache.lookup(key).map(|s| s.to_string()) } /// Store a voiced line and return the stored text. pub fn store(&mut self, zone_id: u32, key: CacheKey, text: String) { let cache = self.zone_cache(zone_id); cache.store(key, text); } /// Persist a zone's cache to disk as MessagePack. /// /// Not atomic (no rename-into-place) — a crash mid-write can produce a /// partial file. This is acceptable: worst case is a cache miss on next /// load, triggering re-inference or base text fallback. pub fn save_zone(&self, zone_id: u32) -> io::Result<()> { let Some(cache) = self.zones.get(&zone_id) else { return Ok(()); }; let dir = self.zone_dir(); fs::create_dir_all(&dir)?; let path = dir.join(format!("{}.msgpack", zone_id)); let data = rmp_serde::to_vec(cache).map_err(io::Error::other)?; fs::write(path, data) } /// Save all loaded zone caches to disk. pub fn save_all(&self) -> io::Result<()> { for &zone_id in self.zones.keys() { self.save_zone(zone_id)?; } Ok(()) } /// Load a zone cache from disk. Returns `None` if file doesn't exist /// or version mismatch (cache invalidation). fn load_zone(&self, zone_id: u32) -> Option { let path = self.zone_dir().join(format!("{}.msgpack", zone_id)); let data = fs::read(&path).ok()?; let cache: ZoneVoiceCache = rmp_serde::from_slice(&data).ok()?; // Version check — invalidate on mismatch if cache.model_version != self.model_version || cache.injector_version != self.injector_version { tracing::info!(zone_id, "voice cache version mismatch — invalidating"); return None; } tracing::debug!(zone_id, entries = cache.entries.len(), "loaded voice cache"); Some(cache) } fn zone_dir(&self) -> PathBuf { self.base_dir.join(format!("{}", self.world_seed)) } } impl Drop for VoiceCacheStore { fn drop(&mut self) { if let Err(e) = self.save_all() { tracing::warn!(error = %e, "failed to save voice cache on shutdown"); } } } /// Determine which tell states should be cached for a given base text. /// /// Length-gated variant count (Spike 1 finding): /// - Short (≤7 words): neutral only — 2B model can't differentiate /// - Medium (8–15 words): neutral + Angry + Guarded (3 variants) /// - Long (16+ words): all 5 tells + neutral (6 variants) pub fn cacheable_tells(base_text: &str) -> Vec> { let words = base_text.split_whitespace().count(); if words <= 7 { vec![None] // neutral only } else if words <= 15 { vec![None, Some(TellCategory::Angry), Some(TellCategory::Guarded)] } else { vec![ None, Some(TellCategory::Nervous), Some(TellCategory::Angry), Some(TellCategory::Friendly), Some(TellCategory::Guarded), Some(TellCategory::RoutineDeviation), ] } } // --------------------------------------------------------------------------- // Make ContentType serializable for cache keys // --------------------------------------------------------------------------- impl Serialize for ContentType { fn serialize(&self, serializer: S) -> Result { match self { ContentType::Dialogue => serializer.serialize_u8(0), ContentType::Behavior => serializer.serialize_u8(1), ContentType::Factual => serializer.serialize_u8(2), } } } impl<'de> Deserialize<'de> for ContentType { fn deserialize>(deserializer: D) -> Result { let v = u8::deserialize(deserializer)?; match v { 0 => Ok(ContentType::Dialogue), 1 => Ok(ContentType::Behavior), 2 => Ok(ContentType::Factual), _ => Err(serde::de::Error::custom("invalid ContentType")), } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn zone_cache_store_and_lookup() { let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into()); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Dialogue, content_index: 0, tell_state: None, }; cache.store(key.clone(), "Look, that's not mine to say.".into()); assert_eq!(cache.lookup(&key), Some("Look, that's not mine to say.")); } #[test] fn zone_cache_miss_returns_none() { let cache = ZoneVoiceCache::new("v1".into(), "i1".into()); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Dialogue, content_index: 0, tell_state: None, }; assert_eq!(cache.lookup(&key), None); } #[test] fn zone_cache_round_trips_through_msgpack() { let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into()); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 42, content_type: ContentType::Behavior, content_index: 3, tell_state: Some(TellCategory::Nervous), }; cache.store(key.clone(), "Hands are steady. Eyes aren't.".into()); let data = rmp_serde::to_vec(&cache).unwrap(); let restored: ZoneVoiceCache = rmp_serde::from_slice(&data).unwrap(); assert_eq!( restored.lookup(&key), Some("Hands are steady. Eyes aren't.") ); assert_eq!(restored.model_version, "v1"); } #[test] fn cache_store_persists_and_loads() { let dir = std::env::temp_dir().join("sr-voice-cache-test"); let _ = fs::remove_dir_all(&dir); let mut store = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into()); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 1, content_type: ContentType::Dialogue, content_index: 0, tell_state: None, }; store.store(100, key.clone(), "Hey.".into()); store.save_zone(100).unwrap(); // New store instance — loads from disk let mut store2 = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into()); assert_eq!(store2.lookup(100, &key), Some("Hey.".into())); let _ = fs::remove_dir_all(&dir); } #[test] fn cache_invalidation_on_version_mismatch() { let dir = std::env::temp_dir().join("sr-voice-cache-invalidation-test"); let _ = fs::remove_dir_all(&dir); let mut store = VoiceCacheStore::new(dir.clone(), 42, "v1".into(), "i1".into()); let key = CacheKey { culture_id: "van-maanens-star".into(), npc_stable_id: 1, content_type: ContentType::Dialogue, content_index: 0, tell_state: None, }; store.store(1, key.clone(), "Old text.".into()); store.save_zone(1).unwrap(); // Different model version — should invalidate let mut store2 = VoiceCacheStore::new(dir.clone(), 42, "v2".into(), "i1".into()); assert_eq!(store2.lookup(1, &key), None); let _ = fs::remove_dir_all(&dir); } #[test] fn cacheable_tells_short() { let tells = cacheable_tells("Hello there."); assert_eq!(tells.len(), 1); assert_eq!(tells[0], None); } #[test] fn cacheable_tells_medium() { let tells = cacheable_tells("The overnight delivery came in clean and it was logged"); assert_eq!(tells.len(), 3); } #[test] fn cacheable_tells_long() { let tells = cacheable_tells( "I heard the night crew had to stop the line twice because the coupling was faulty and nobody had flagged it" ); assert_eq!(tells.len(), 6); } }