Files
settled-reach/server/src/voice/lookup.rs
T
jpmschweitzerandClaude Opus 4.8 7776d99c14 test(simulation): isolate voice::lookup cache dirs — fix flaky cache_miss (T-1035)
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>
2026-06-09 08:18:58 +02:00

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.");
}
}