fix(simulation): address PR #10 review — plugin wiring, stale reset, warnings

- Register KnowledgePlugin in main.rs and game_loop test (Tyre critical)
- Add KnowledgeGraph component to player spawn (Tyre critical)
- Reset Stale -> Active on fresh direct observation (Hoshe warning)
- Make registry/queue non-optional in emit_observation_events (Tyre/Hoshe)
- Add tracing::warn for missing EntityRegistry entries (Hoshe warning)
- Replace HashSet with Vec for small entity ID lookups (Hoshe suggestion)
- Add const static assertion for KnowledgeConfidence ordering (Hoshe)
- Add is_empty() and known_facts_iter() to KnowledgeGraph (Tyre)
- Add decay_skips_non_minute_ticks and observe_resets_stale tests (Hoshe)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 01:06:33 +01:00
co-authored by Claude Opus 4.6
parent a68a3ef429
commit 3115c7a84c
6 changed files with 113 additions and 15 deletions
+55
View File
@@ -78,11 +78,15 @@ pub fn process_knowledge_events(
KnowledgeEventType::DirectObservation { target, position } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity(stable_id, position, event.tick);
} else {
tracing::warn!("DirectObservation target {:?} not in EntityRegistry", target);
}
}
KnowledgeEventType::LeftLOS { target } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity_leaving_los(&stable_id, event.tick);
} else {
tracing::warn!("LeftLOS target {:?} not in EntityRegistry", target);
}
}
}
@@ -230,4 +234,55 @@ mod tests {
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world); // Should not panic
}
#[test]
fn decay_skips_non_minute_ticks() {
use crate::simulation::time::SimulationTime;
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let target_entity = world.spawn_empty().id();
let target_sid = registry.register(target_entity);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(target_sid, TilePosition::new(5, 5, 0), 0);
kg.observe_entity_leaving_los(&target_sid, 1); // KnowsDetails
let observer = world.spawn(kg).id();
registry.register(observer);
world.insert_resource(registry);
let thresholds = DecayThresholds {
decay_after: 5,
stale_after: 100,
};
world.insert_resource(thresholds);
// Tick 7: not a multiple of 10, decay should NOT run
world.insert_resource(SimulationTime { tick: 7, paused: false });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(decay_knowledge);
schedule.run(&mut world);
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
assert_eq!(
kg.confidence_of(&target_sid),
Some(KnowledgeConfidence::KnowsDetails),
"decay should not run on tick 7 (not a game-minute boundary)"
);
// Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5)
world.insert_resource(SimulationTime { tick: 10, paused: false });
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
schedule2.add_systems(decay_knowledge);
schedule2.run(&mut world);
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
assert_eq!(
kg.confidence_of(&target_sid),
Some(KnowledgeConfidence::KnowsOf),
"decay should run on tick 10 and downgrade confidence"
);
}
}
+31 -2
View File
@@ -95,6 +95,16 @@ impl KnowledgeGraph {
self.facts.len()
}
/// Whether the knowledge graph has no entries at all.
pub fn is_empty(&self) -> bool {
self.entities.is_empty() && self.facts.is_empty()
}
/// Iterate all known facts (deterministic order via BTreeMap).
pub fn known_facts_iter(&self) -> impl Iterator<Item = (&FactId, &FactKnowledge)> {
self.facts.iter()
}
// --- Write Operations ---
/// Record a direct observation of another entity (entity is in LOS).
@@ -119,8 +129,12 @@ impl KnowledgeGraph {
entry.last_updated_tick = tick;
entry.confidence = KnowledgeConfidence::Direct;
entry.source = KnowledgeSource::DirectObservation { tick };
// Do NOT reset state here -- a Contradicted entry stays Contradicted
// even if you're looking at the entity right now.
// Stale entries become Active again on fresh observation.
// Contradicted entries stay Contradicted even if you're looking
// at the entity right now — the contradiction is still unresolved.
if entry.state == KnowledgeState::Stale {
entry.state = KnowledgeState::Active;
}
}
/// Entity has left the observer's LOS. Downgrade from Direct.
@@ -355,6 +369,21 @@ mod tests {
assert_eq!(entry.last_known_position, Some(make_position(6, 10)));
}
#[test]
fn observe_resets_stale_to_active() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
g.observe_entity(target, make_position(5, 10), 100);
// Mark as stale (would be done by decay system)
g.entities.get_mut(&target).unwrap().state = KnowledgeState::Stale;
// Re-observe — stale should reset to Active
g.observe_entity(target, make_position(6, 10), 200);
let entry = g.entity_knowledge(&target).unwrap();
assert_eq!(entry.state, KnowledgeState::Active);
assert_eq!(entry.confidence, KnowledgeConfidence::Direct);
}
#[test]
fn with_background_creates_facts() {
let facts = vec![
+8
View File
@@ -46,6 +46,14 @@ pub enum KnowledgeConfidence {
Direct = 3,
}
// Load-bearing ordering assertion — do not reorder variants.
const _: () = {
assert!(KnowledgeConfidence::Suspects as u8 == 0);
assert!(KnowledgeConfidence::KnowsOf as u8 == 1);
assert!(KnowledgeConfidence::KnowsDetails as u8 == 2);
assert!(KnowledgeConfidence::Direct as u8 == 3);
};
impl KnowledgeConfidence {
/// Step down one confidence level (used by decay system).
pub fn decayed(self) -> Self {
+8 -2
View File
@@ -6,6 +6,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::SimulationPlugin;
@@ -38,10 +39,15 @@ fn main() {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
app.world_mut()
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default()));
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
tracing::info!("Simulation initialized, entering game loop");
+4 -9
View File
@@ -4,7 +4,6 @@
//! Runs after compute_observer_snapshot, before process_knowledge_events.
use bevy_ecs::prelude::*;
use std::collections::HashSet;
use crate::bridge::types::*;
use crate::knowledge::{
@@ -21,15 +20,11 @@ use crate::simulation::time::SimulationTime;
pub fn emit_observation_events(
time: Res<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Option<Res<EntityRegistry>>,
registry: Res<EntityRegistry>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
event_queue: Option<ResMut<KnowledgeEventQueue>>,
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
) {
let (Some(registry), Some(ref mut event_queue)) = (registry, event_queue) else {
return; // Knowledge system not wired up
};
let Some(snapshot) = &buffer.snapshot else {
return;
};
@@ -38,8 +33,8 @@ pub fn emit_observation_events(
return;
};
// Build set of currently visible entity IDs from snapshot (excluding player)
let visible_entity_ids: HashSet<u64> = snapshot
// Collect visible entity IDs (Vec — linear search is faster at 5-20 entities)
let visible_entity_ids: Vec<u64> = snapshot
.entities
.iter()
.filter(|e| !matches!(e.kind, EntityKind::Player))
+7 -2
View File
@@ -6,6 +6,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::SimulationPlugin;
use std::io::{BufReader, BufWriter};
@@ -26,10 +27,14 @@ fn player_moves_north_through_full_pipeline() {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
app.world_mut()
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
KnowledgeGraph::new(),
));
// Run one tick: receive input, process, validate movement, generate snapshot, send
app.update();