Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
//! 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<TellCategory>,
|
||
}
|
||
|
||
/// 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<CacheKey, String>,
|
||
}
|
||
|
||
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<u32, ZoneVoiceCache>,
|
||
}
|
||
|
||
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<String> {
|
||
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<ZoneVoiceCache> {
|
||
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<Option<TellCategory>> {
|
||
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||
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);
|
||
}
|
||
}
|