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
+14
View File
@@ -19,10 +19,22 @@ use crate::simulation::time::SimulationTime;
/// Base cognitive delay: 0.6 seconds = 6 ticks at 10 tps (D-031, D-060).
/// Tunable: expect playtesting adjustments.
///
/// Playtesting expectation: 0.6s should feel like a brief "processing" beat —
/// noticeable enough that new entities register as grey blobs before resolving,
/// but short enough to not feel sluggish. If playtesters report recognition
/// feels instant (reduce to test), or laggy (current value may be too high for
/// fast-paced encounters), adjust in 2-tick increments. The 2:1 ratio with
/// URGENT_DELAY_TICKS should be preserved.
pub const NORMAL_DELAY_TICKS: u64 = 6;
/// Urgent cognitive delay: 0.3 seconds = 3 ticks at 10 tps (D-031, D-060).
/// Triggered when observe_anomaly context is active.
///
/// Playtesting expectation: 0.3s should feel nearly instant but still register
/// visually as a "snap to attention" moment. If playtesters don't notice the
/// delay at all, consider whether the grey blob phase is too brief to read.
/// Must remain strictly less than NORMAL_DELAY_TICKS.
pub const URGENT_DELAY_TICKS: u64 = 3;
/// How the recognition was triggered, determines delay duration.
@@ -76,6 +88,8 @@ impl CognitiveDelay {
}
/// Check if an entity is already pending recognition.
// Note: O(n) scan over pending vec. Fine for v0.1 (typically <10 pending).
// If NPC cognitive delay is added, consider HashSet<StableId> index.
pub fn is_pending(&self, stable_id: &StableId) -> bool {
self.pending.iter().any(|p| p.stable_id == *stable_id)
}
+4 -3
View File
@@ -78,7 +78,7 @@ pub fn emit_observation_events(
} else if let Some(ref mut delay) = cognitive_delay {
// New entity + cognitive delay available: buffer recognition
if !delay.is_pending(&stable_id) {
// TODO: wire RecognitionTrigger::Urgent for observe_anomaly triggers
// TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers
let trigger = RecognitionTrigger::Normal;
delay.push(PendingRecognition {
target: entity,
@@ -135,8 +135,9 @@ pub fn emit_observation_events(
delay.pending().iter().map(|p| p.stable_id).collect();
for sid in pending_ids {
if !visible_stable_ids.contains(&sid.0) {
delay.cancel(&sid);
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
if delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
}
}
}
+98
View File
@@ -1817,3 +1817,101 @@ fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() {
let queue = query.single(&world).unwrap();
assert!(queue.has_pending(), "one anomaly should be queued");
}
// -----------------------------------------------------------------------
// Pending recognitions in observer snapshot (#423, D-060)
// -----------------------------------------------------------------------
#[test]
fn pending_recognitions_appear_in_snapshot() {
// H11: When a player entity has a CognitiveDelay component with pending
// recognitions, compute_observer_snapshot should include them in
// pending_recognitions for the client to render as grey blobs.
use crate::perception::cognitive_delay::{
CognitiveDelay, PendingRecognition, RecognitionTrigger,
};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Target entity that is being "recognized"
let target = world.spawn_empty().id();
let target_sid = registry.register(target);
// Player with CognitiveDelay containing a pending recognition
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: target_sid,
position: TilePosition::new(16, 14, 0),
delay_until_tick: 110, // will complete at tick 110
trigger: RecognitionTrigger::Normal,
});
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
cd,
))
.id();
registry.register(player);
world.insert_resource(registry);
world.insert_resource({
let mut t = SimulationTime::default();
t.tick = 106; // 4 ticks remaining until recognition
t
});
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(
snapshot.pending_recognitions.len(),
1,
"should have one pending recognition in snapshot"
);
let pending = &snapshot.pending_recognitions[0];
assert_eq!(pending.entity_id, target_sid.0);
assert_eq!(pending.remaining_ticks, 4, "110 - 106 = 4 remaining");
assert_eq!(
pending.total_delay_ticks,
crate::perception::cognitive_delay::NORMAL_DELAY_TICKS,
"total delay should match Normal trigger"
);
// Position should be render coords of (16, 14, 0)
let (expected_x, expected_y, expected_z) = TilePosition::new(16, 14, 0).to_render_coords();
assert_eq!(pending.x, expected_x);
assert_eq!(pending.y, expected_y);
assert_eq!(pending.z, expected_z);
}
#[test]
fn no_cognitive_delay_component_means_empty_pending_recognitions() {
// H11 complement: player WITHOUT CognitiveDelay should produce
// an empty pending_recognitions vec (backward compatibility).
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.pending_recognitions.is_empty(),
"no CognitiveDelay component should produce empty pending_recognitions"
);
}