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:
2026-02-16 01:03:51 +01:00
co-authored by Claude Opus 4.6
parent 8a334b47f3
commit d4fdbf426e
11 changed files with 379 additions and 17 deletions
+37 -5
View File
@@ -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);
}
}
}
}