diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index a02757c75..6dcb85c7f 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -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::().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::().unwrap(); + assert_eq!( + kg.confidence_of(&target_sid), + Some(KnowledgeConfidence::KnowsOf), + "decay should run on tick 10 and downgrade confidence" + ); + } } diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index d4f7fd096..b65a21f64 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -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 { + 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![ diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index d66871231..892a4df1a 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -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 { diff --git a/server/src/main.rs b/server/src/main.rs index bb94311b9..96918aaf8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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"); diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index a9eeed9d4..31e91ff1f 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -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, buffer: Res, - registry: Option>, + registry: Res, observer_query: Query<(Entity, &KnowledgeGraph), With>, - event_queue: Option>, + mut event_queue: ResMut, 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 = snapshot + // Collect visible entity IDs (Vec — linear search is faster at 5-20 entities) + let visible_entity_ids: Vec = snapshot .entities .iter() .filter(|e| !matches!(e.kind, EntityKind::Player)) diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 7927d1211..500ab4096 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -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();