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>
261 lines
8.5 KiB
Rust
261 lines
8.5 KiB
Rust
//! Content hot-reload via timestamp polling (dev-only).
|
|
//!
|
|
//! Periodically checks content YAML files for modifications and triggers
|
|
//! a full reload when changes are detected. Designed for the authoring
|
|
//! workflow — not enabled in production builds.
|
|
//!
|
|
//! Check interval: every 20 ticks (~2s at 10 tps per D-031).
|
|
//! Failures are non-critical: previous content is preserved on reload error.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::SystemTime;
|
|
|
|
use bevy_ecs::prelude::*;
|
|
|
|
use crate::content::line_pool::LinePoolIndex;
|
|
use crate::content::loader;
|
|
use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource};
|
|
|
|
/// How often to check for content changes (in system ticks).
|
|
/// 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
|
|
}
|
|
|
|
/// 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, 0);
|
|
tracing::debug!(
|
|
"ContentWatcher: tracking {} content files",
|
|
self.file_timestamps.len()
|
|
);
|
|
}
|
|
|
|
/// 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, 0);
|
|
let changed = new_timestamps != self.file_timestamps;
|
|
if changed {
|
|
self.file_timestamps = new_timestamps;
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Number of tracked files (for diagnostics).
|
|
pub fn tracked_file_count(&self) -> usize {
|
|
self.file_timestamps.len()
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// 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, 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() {
|
|
timestamps.insert(path, modified);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// System: periodically check for content file changes and reload.
|
|
///
|
|
/// Only runs when a ContentWatcher resource exists (hot-reload enabled).
|
|
/// Runs in PostUpdate to avoid interfering with the current tick.
|
|
pub fn hot_reload_content(
|
|
config: Res<ContentConfig>,
|
|
watcher: Option<ResMut<ContentWatcher>>,
|
|
store_res: Option<ResMut<ContentStoreResource>>,
|
|
index_res: Option<ResMut<LinePoolIndexResource>>,
|
|
) {
|
|
let Some(mut watcher) = watcher else {
|
|
return;
|
|
};
|
|
let Some(mut store_res) = store_res else {
|
|
return;
|
|
};
|
|
let Some(mut index_res) = index_res else {
|
|
return;
|
|
};
|
|
|
|
watcher.ticks_since_check += 1;
|
|
if watcher.ticks_since_check < CHECK_INTERVAL_TICKS {
|
|
return;
|
|
}
|
|
watcher.ticks_since_check = 0;
|
|
|
|
if !watcher.check_and_rescan(&config.content_root) {
|
|
return;
|
|
}
|
|
|
|
tracing::info!("Content files changed, reloading...");
|
|
|
|
match loader::load_content(&config.content_root) {
|
|
Ok(store) => {
|
|
let index = LinePoolIndex::build(&store);
|
|
let d_count = index.dialogue_line_count();
|
|
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,
|
|
m_count
|
|
);
|
|
}
|
|
Err(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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn watcher_tracks_yaml_files() {
|
|
let dir = std::env::temp_dir().join("sr_hotreload_test_track");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
fs::write(dir.join("test.yaml"), "key: value\n").unwrap();
|
|
fs::write(dir.join("other.txt"), "ignored\n").unwrap();
|
|
|
|
let watcher = ContentWatcher::new(&dir);
|
|
assert_eq!(watcher.tracked_file_count(), 1);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn watcher_detects_new_file() {
|
|
let dir = std::env::temp_dir().join("sr_hotreload_test_new");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&dir);
|
|
assert!(!watcher.check_and_rescan(&dir)); // no change yet
|
|
|
|
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
|
|
assert!(watcher.check_and_rescan(&dir)); // new file detected
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn watcher_detects_deleted_file() {
|
|
let dir = std::env::temp_dir().join("sr_hotreload_test_del");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
|
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&dir);
|
|
assert_eq!(watcher.tracked_file_count(), 2);
|
|
|
|
fs::remove_file(dir.join("b.yaml")).unwrap();
|
|
assert!(watcher.check_and_rescan(&dir));
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn watcher_detects_modification() {
|
|
let dir = std::env::temp_dir().join("sr_hotreload_test_mod");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&dir);
|
|
|
|
// Sleep briefly to ensure modification time differs
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
fs::write(dir.join("a.yaml"), "key: modified\n").unwrap();
|
|
|
|
assert!(watcher.check_and_rescan(&dir));
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn watcher_recurses_subdirectories() {
|
|
let dir = std::env::temp_dir().join("sr_hotreload_test_recurse");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
let sub = dir.join("sub/deep");
|
|
fs::create_dir_all(&sub).unwrap();
|
|
|
|
fs::write(dir.join("root.yaml"), "key: root\n").unwrap();
|
|
fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap();
|
|
|
|
let watcher = ContentWatcher::new(&dir);
|
|
assert_eq!(watcher.tracked_file_count(), 2);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
}
|