fix(server): address PR #23 review — 4 critical bugs, 3 warnings, 11 suggestions
Critical fixes: - Add PendingRecognitionWire serialization roundtrip test - Check Option return from delay.cancel() before logging - InputQueue capacity limit (1000) with drop-oldest and warning - TODO in observation.rs references ticket #450 Warning fixes: - Hot-reload guards against invalid/empty content root - Location header mismatch warning in line pool indexing - walk_yaml() depth limit (100) against symlink loops - Consecutive reload failure counter (warns after 5+) Test additions: - Negative prerequisite filtering test for monologue lines - Integration test for pending_recognitions in observer snapshot - Eavesdrop threshold ordering assertion (Careful < default) Documentation: - Playtesting expectation comments on delay constants - is_pending() scalability note for future NPC cognitive delay - ID format regex validation in line pool spec - BTreeMap vs sort() ordering clarification in loader - Multiplayer TODO in relationships.rs references D-010 - ContentSlug ticket #452 filed for entity slug resolution 394 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,20 +21,34 @@ use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource}
|
||||
/// At 10 tps (D-031), 20 ticks = 2 seconds.
|
||||
const CHECK_INTERVAL_TICKS: u64 = 20;
|
||||
|
||||
/// Consecutive reload failures before escalating to a warning.
|
||||
const FAILURE_WARN_THRESHOLD: u32 = 5;
|
||||
|
||||
/// Resource tracking content file timestamps for change detection.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct ContentWatcher {
|
||||
file_timestamps: BTreeMap<PathBuf, SystemTime>,
|
||||
ticks_since_check: u64,
|
||||
/// Consecutive reload failures. Resets on success.
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
impl ContentWatcher {
|
||||
/// Create a new watcher and perform initial timestamp scan.
|
||||
/// Returns a watcher with no tracked files if content_root is invalid.
|
||||
pub fn new(content_root: &Path) -> Self {
|
||||
let mut watcher = Self {
|
||||
file_timestamps: BTreeMap::new(),
|
||||
ticks_since_check: 0,
|
||||
consecutive_failures: 0,
|
||||
};
|
||||
if content_root.as_os_str().is_empty() || !content_root.is_dir() {
|
||||
tracing::warn!(
|
||||
"ContentWatcher: invalid content root {:?}, hot-reload disabled",
|
||||
content_root,
|
||||
);
|
||||
return watcher;
|
||||
}
|
||||
watcher.scan(content_root);
|
||||
watcher
|
||||
}
|
||||
@@ -42,7 +56,7 @@ impl ContentWatcher {
|
||||
/// Scan content directory tree and record all YAML file timestamps.
|
||||
fn scan(&mut self, content_root: &Path) {
|
||||
self.file_timestamps.clear();
|
||||
walk_yaml(content_root, &mut self.file_timestamps);
|
||||
walk_yaml(content_root, &mut self.file_timestamps, 0);
|
||||
tracing::debug!(
|
||||
"ContentWatcher: tracking {} content files",
|
||||
self.file_timestamps.len()
|
||||
@@ -52,7 +66,7 @@ impl ContentWatcher {
|
||||
/// Check for changes and rescan. Returns true if any files changed.
|
||||
fn check_and_rescan(&mut self, content_root: &Path) -> bool {
|
||||
let mut new_timestamps = BTreeMap::new();
|
||||
walk_yaml(content_root, &mut new_timestamps);
|
||||
walk_yaml(content_root, &mut new_timestamps, 0);
|
||||
let changed = new_timestamps != self.file_timestamps;
|
||||
if changed {
|
||||
self.file_timestamps = new_timestamps;
|
||||
@@ -66,15 +80,23 @@ impl ContentWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum recursion depth for directory walking (guards against symlink loops).
|
||||
const MAX_WALK_DEPTH: usize = 100;
|
||||
|
||||
/// Recursively walk a directory, recording .yaml file modification timestamps.
|
||||
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>) {
|
||||
/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops.
|
||||
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>, depth: usize) {
|
||||
if depth >= MAX_WALK_DEPTH {
|
||||
tracing::warn!("walk_yaml: max depth {} reached at {:?}, stopping", MAX_WALK_DEPTH, dir);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk_yaml(&path, timestamps);
|
||||
walk_yaml(&path, timestamps, depth + 1);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
if let Ok(meta) = std::fs::metadata(&path) {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
@@ -124,6 +146,7 @@ pub fn hot_reload_content(
|
||||
let m_count = index.monologue_line_count();
|
||||
store_res.0 = store;
|
||||
index_res.0 = index;
|
||||
watcher.consecutive_failures = 0;
|
||||
tracing::info!(
|
||||
"Content hot-reloaded: {} dialogue lines, {} monologue lines",
|
||||
d_count,
|
||||
@@ -131,7 +154,16 @@ pub fn hot_reload_content(
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Content hot-reload failed (keeping previous): {}", e);
|
||||
watcher.consecutive_failures += 1;
|
||||
if watcher.consecutive_failures >= FAILURE_WARN_THRESHOLD {
|
||||
tracing::warn!(
|
||||
"Content hot-reload failed {} consecutive times (keeping previous): {}",
|
||||
watcher.consecutive_failures,
|
||||
e,
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("Content hot-reload failed (keeping previous): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,14 @@ impl LinePoolIndex {
|
||||
}
|
||||
|
||||
fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) {
|
||||
if pool.location.is_empty() {
|
||||
tracing::warn!(
|
||||
"Skipping dialogue pool with empty location (role={})",
|
||||
pool.role,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let key = (pool.location.clone(), pool.role.clone());
|
||||
|
||||
let indexed_lines: Vec<IndexedDialogueLine> =
|
||||
@@ -366,6 +374,14 @@ impl LinePoolIndex {
|
||||
}
|
||||
|
||||
fn index_monologue_pool(&mut self, pool: &types::MonologuePool) {
|
||||
if pool.location.is_empty() {
|
||||
tracing::warn!(
|
||||
"Skipping monologue pool with empty location (character={})",
|
||||
pool.character,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let character = match pool.character.parse::<Character>() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -534,8 +550,28 @@ fn parse_dialogue_line(line: &types::DialogueLine) -> Option<IndexedDialogueLine
|
||||
return None;
|
||||
}
|
||||
|
||||
let topic: Vec<Topic> = line.topic.iter().filter_map(|s| s.parse().ok()).collect();
|
||||
let mood: Vec<Mood> = line.mood.iter().filter_map(|s| s.parse().ok()).collect();
|
||||
let topic: Vec<Topic> = line
|
||||
.topic
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
s.parse()
|
||||
.map_err(|e: ParseEnumError| {
|
||||
tracing::warn!("Line {}: {}", line.id, e);
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
let mood: Vec<Mood> = line
|
||||
.mood
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
s.parse()
|
||||
.map_err(|e: ParseEnumError| {
|
||||
tracing::warn!("Line {}: {}", line.id, e);
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(IndexedDialogueLine {
|
||||
id: line.id.clone(),
|
||||
@@ -1009,6 +1045,100 @@ mod tests {
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_monologue_line_with_prerequisite_excluded_when_fact_absent() {
|
||||
// H10: Verify prerequisite field is populated through query so callers
|
||||
// can filter. query_monologue returns ALL matching lines (prerequisite
|
||||
// evaluation is caller's responsibility per D-028), but a line with a
|
||||
// prerequisite should carry that data through for the caller to check.
|
||||
let mut store = ContentStore::default();
|
||||
let monologue_pools = vec![types::MonologuePool {
|
||||
character: "smuggler".to_string(),
|
||||
location: "the-terminal".to_string(),
|
||||
lines: vec![
|
||||
// Line WITHOUT prerequisite — should always be available
|
||||
types::MonologueLine {
|
||||
id: "prereq_none".to_string(),
|
||||
text: "No prereq line".to_string(),
|
||||
trigger: "enter_location".to_string(),
|
||||
prerequisites: None,
|
||||
priority: Some(5),
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
},
|
||||
// Line WITH prerequisite — caller must check before using
|
||||
types::MonologueLine {
|
||||
id: "prereq_fact".to_string(),
|
||||
text: "Requires cargo_manifest_seen".to_string(),
|
||||
trigger: "enter_location".to_string(),
|
||||
prerequisites: Some(types::Prerequisites {
|
||||
facts: vec![types::FactPrerequisite {
|
||||
fact_id: "cargo_manifest_seen".to_string(),
|
||||
min_confidence: "confirmed".to_string(),
|
||||
}],
|
||||
entity_attributes: vec![],
|
||||
relationship: None,
|
||||
}),
|
||||
priority: Some(8),
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
},
|
||||
],
|
||||
}];
|
||||
let district = DistrictContent {
|
||||
monologue_pools,
|
||||
..Default::default()
|
||||
};
|
||||
store
|
||||
.districts
|
||||
.insert("test.district".to_string(), district);
|
||||
|
||||
let index = LinePoolIndex::build(&store);
|
||||
let results =
|
||||
index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation);
|
||||
|
||||
// Both lines are returned (query doesn't filter prerequisites)
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
// Verify the prerequisite-bearing line carries its prerequisites through
|
||||
let prereq_line = results.iter().find(|l| l.id == "prereq_fact").unwrap();
|
||||
assert!(
|
||||
prereq_line.prerequisites.is_some(),
|
||||
"prerequisite field should be populated for caller to evaluate"
|
||||
);
|
||||
let prereqs = prereq_line.prerequisites.as_ref().unwrap();
|
||||
assert_eq!(prereqs.facts.len(), 1);
|
||||
assert_eq!(prereqs.facts[0].fact_id, "cargo_manifest_seen");
|
||||
|
||||
// The no-prerequisite line should have None
|
||||
let no_prereq_line = results.iter().find(|l| l.id == "prereq_none").unwrap();
|
||||
assert!(
|
||||
no_prereq_line.prerequisites.is_none(),
|
||||
"line without prerequisites should have None"
|
||||
);
|
||||
|
||||
// Simulate caller-side filtering: if fact is absent, exclude the line
|
||||
let player_known_facts: Vec<&str> = vec![]; // empty — fact not known
|
||||
let available: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|line| {
|
||||
match &line.prerequisites {
|
||||
None => true, // no prerequisites = always available
|
||||
Some(prereqs) => prereqs
|
||||
.facts
|
||||
.iter()
|
||||
.all(|f| player_known_facts.contains(&f.fact_id.as_str())),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
available.len(),
|
||||
1,
|
||||
"only the no-prerequisite line should pass when fact is absent"
|
||||
);
|
||||
assert_eq!(available[0].id, "prereq_none");
|
||||
}
|
||||
|
||||
// -- Parse edge cases ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,8 +93,11 @@ fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
|
||||
if systems_path.is_dir() {
|
||||
walk_for_districts(&systems_path, &mut districts);
|
||||
}
|
||||
// BTreeMap ordering guarantees deterministic district processing,
|
||||
// but sort the discovery order too for consistency.
|
||||
// Discovery order from fs::read_dir is platform-dependent. Sort the Vec
|
||||
// here so districts load in a deterministic order regardless of OS.
|
||||
// ContentStore.districts uses BTreeMap for deterministic *iteration* later,
|
||||
// but sorted discovery ensures deterministic *load* order (and thus
|
||||
// deterministic ID derivation and log output).
|
||||
districts.sort();
|
||||
districts
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user