The four voice::lookup tests shared one on-disk cache directory (base_dir temp/sr-voice-lookup-test + world_seed 99). VoiceCacheStore persists on Drop (save_all) and reloads on first zone access (load_zone), and cache_hit stores 'Voiced line.' under the identical (zone_id=100, CacheKey) that cache_miss looks up — so under parallel execution the hit test's Drop/save could leak into the miss test's lookup, returning 'Voiced line.' instead of 'Base line.'. Give each test a directory keyed by test label + process id, so neither parallel tests nor concurrent cargo test runs collide. Verified clean across repeated runs. Closes T-1035. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
5.9 KiB
Rust
198 lines
5.9 KiB
Rust
//! 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<Mutex<VoiceCacheStore>>,
|
|
}
|
|
|
|
/// 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<Mutex<VoiceCacheStore>>,
|
|
zone_id: u32,
|
|
culture_id: &str,
|
|
npc_stable_id: u64,
|
|
content_type: ContentType,
|
|
content_index: u16,
|
|
tell_state: Option<TellCategory>,
|
|
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<Mutex<VoiceCacheStore>> {
|
|
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.");
|
|
}
|
|
}
|