Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -7,6 +7,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Knowledge graph system (#361, #362, #363, #365) — per-entity KnowledgeGraph component (D-041), StableEntityId + EntityRegistry, KnowledgeEventQueue, decay system, 4-level confidence hierarchy
|
||||
- Direct observation knowledge flow (#364) — perception emits DirectObservation/LeftLOS events to knowledge graph, entities entering/leaving LOS tracked
|
||||
- Observer visibility query (#112) — replaces unfiltered generate_snapshot with LOS-filtered compute_observer_snapshot combining shadowcasting + vision cone
|
||||
- Vision cone system (#111) — forward/peripheral/blind sectors per D-015, Facing component updated on movement
|
||||
- Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035)
|
||||
|
||||
@@ -177,6 +177,8 @@ impl Plugin for BridgePlugin {
|
||||
crate::perception::observer::compute_observer_snapshot
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
send_bridge_snapshot
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Knowledge event queue and processing system (#363).
|
||||
//!
|
||||
//! Event-driven knowledge updates. Perception and other systems push
|
||||
//! KnowledgeEvents; the processing system drains them per tick.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
use super::graph::KnowledgeGraph;
|
||||
use super::registry::EntityRegistry;
|
||||
use super::types::*;
|
||||
|
||||
/// Events that modify knowledge graphs. Produced by perception and
|
||||
/// other systems. Consumed by the knowledge update system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KnowledgeEvent {
|
||||
pub observer: Entity,
|
||||
pub tick: u64,
|
||||
pub event_type: KnowledgeEventType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KnowledgeEventType {
|
||||
/// Observer saw entity at position (sets Direct confidence).
|
||||
DirectObservation {
|
||||
target: Entity,
|
||||
position: TilePosition,
|
||||
},
|
||||
/// Entity left observer's LOS (downgrades from Direct).
|
||||
LeftLOS { target: Entity },
|
||||
}
|
||||
|
||||
/// Resource: queue of pending knowledge events.
|
||||
/// Drained once per tick by the knowledge update system.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct KnowledgeEventQueue {
|
||||
pub(crate) events: Vec<KnowledgeEvent>,
|
||||
}
|
||||
|
||||
impl KnowledgeEventQueue {
|
||||
/// Push a knowledge event into the queue.
|
||||
pub fn push(&mut self, event: KnowledgeEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
/// Drain all pending events.
|
||||
pub fn drain(&mut self) -> Vec<KnowledgeEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Number of pending events.
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Whether the queue is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: process pending knowledge events.
|
||||
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
|
||||
/// to the relevant KnowledgeGraph components.
|
||||
pub fn process_knowledge_events(
|
||||
mut queue: ResMut<KnowledgeEventQueue>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
let events = queue.drain();
|
||||
for event in events {
|
||||
let Ok(mut observer_kg) = knowledge_query.get_mut(event.observer) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match event.event_type {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: run knowledge decay once per game-minute (every 10 ticks per D-031).
|
||||
pub fn decay_knowledge(
|
||||
time: Res<crate::simulation::time::SimulationTime>,
|
||||
thresholds: Res<DecayThresholds>,
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
// Decay runs every 10 ticks (1 game-minute per D-031)
|
||||
if time.tick % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
for mut kg in knowledge_query.iter_mut() {
|
||||
kg.decay(time.tick, &thresholds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
#[test]
|
||||
fn queue_push_and_drain() {
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
|
||||
queue.push(KnowledgeEvent {
|
||||
observer: e1,
|
||||
tick: 100,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: e2,
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
},
|
||||
});
|
||||
assert_eq!(queue.len(), 1);
|
||||
|
||||
let events = queue.drain();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_direct_observation_event() {
|
||||
let mut world = World::new();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let observer = world.spawn(KnowledgeGraph::new()).id();
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let observer_sid = registry.register(observer);
|
||||
let target_sid = registry.register(target);
|
||||
let _ = observer_sid; // registered for completeness
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
observer,
|
||||
tick: 50,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
},
|
||||
});
|
||||
world.insert_resource(queue);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_knowledge_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
||||
assert!(kg.knows_entity(&target_sid));
|
||||
assert_eq!(
|
||||
kg.confidence_of(&target_sid),
|
||||
Some(KnowledgeConfidence::Direct)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_left_los_event() {
|
||||
let mut world = World::new();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
let target_sid = registry.register(target);
|
||||
|
||||
// Pre-populate observer with Direct knowledge
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(target_sid, TilePosition::new(10, 10, 0), 50);
|
||||
|
||||
let observer = world.spawn(kg).id();
|
||||
registry.register(observer);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
observer,
|
||||
tick: 60,
|
||||
event_type: KnowledgeEventType::LeftLOS { target },
|
||||
});
|
||||
world.insert_resource(queue);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_knowledge_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
||||
assert_eq!(
|
||||
kg.confidence_of(&target_sid),
|
||||
Some(KnowledgeConfidence::KnowsDetails)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_event_for_missing_observer_is_no_op() {
|
||||
let mut world = World::new();
|
||||
let registry = EntityRegistry::new(0);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
|
||||
let fake_target = world.spawn_empty().id();
|
||||
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
observer: fake_observer,
|
||||
tick: 100,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: fake_target,
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
},
|
||||
});
|
||||
world.insert_resource(queue);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! KnowledgeGraph ECS component per D-041.
|
||||
//!
|
||||
//! Per-entity knowledge component. Attached to every entity that has knowledge
|
||||
//! (player character, Active-tier NPCs, Background-tier NPCs).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
/// Per-entity knowledge component. THE core data structure.
|
||||
/// BTreeMap for deterministic iteration (D-010 principle 4).
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeGraph {
|
||||
/// What this entity knows about other entities.
|
||||
/// Key: StableId of the known entity.
|
||||
pub entities: BTreeMap<StableId, EntityKnowledge>,
|
||||
|
||||
/// Non-entity facts this entity knows.
|
||||
/// Key: FactId in "category.topic" format.
|
||||
pub facts: BTreeMap<FactId, FactKnowledge>,
|
||||
}
|
||||
|
||||
impl KnowledgeGraph {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entities: BTreeMap::new(),
|
||||
facts: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct with starting facts (character background per D-013).
|
||||
pub fn with_background(facts: Vec<(FactId, FactKnowledge)>) -> Self {
|
||||
Self {
|
||||
entities: BTreeMap::new(),
|
||||
facts: facts.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Read Queries ---
|
||||
|
||||
/// Does this entity know about another entity at all?
|
||||
pub fn knows_entity(&self, id: &StableId) -> bool {
|
||||
self.entities.contains_key(id)
|
||||
}
|
||||
|
||||
/// What confidence level for a known entity?
|
||||
pub fn confidence_of(&self, id: &StableId) -> Option<KnowledgeConfidence> {
|
||||
self.entities.get(id).map(|k| k.confidence)
|
||||
}
|
||||
|
||||
/// What is the relationship state with a known entity?
|
||||
/// Returns Unknown for entities not in the graph.
|
||||
pub fn relationship_with(&self, id: &StableId) -> RelationshipState {
|
||||
self.entities
|
||||
.get(id)
|
||||
.map(|k| k.relationship)
|
||||
.unwrap_or(RelationshipState::Unknown)
|
||||
}
|
||||
|
||||
/// Does this entity know a specific fact?
|
||||
pub fn knows_fact(&self, id: &FactId) -> bool {
|
||||
self.facts.contains_key(id)
|
||||
}
|
||||
|
||||
/// Is fact confidence at or above a threshold?
|
||||
/// This is the monologue prerequisite check (D-035 `prerequisite` tag).
|
||||
pub fn fact_at_least(&self, id: &FactId, min: KnowledgeConfidence) -> bool {
|
||||
self.facts
|
||||
.get(id)
|
||||
.map(|f| f.confidence >= min)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get entity knowledge entry (read-only).
|
||||
pub fn entity_knowledge(&self, id: &StableId) -> Option<&EntityKnowledge> {
|
||||
self.entities.get(id)
|
||||
}
|
||||
|
||||
/// Iterate all known entities (deterministic order via BTreeMap).
|
||||
pub fn known_entities_iter(&self) -> impl Iterator<Item = (&StableId, &EntityKnowledge)> {
|
||||
self.entities.iter()
|
||||
}
|
||||
|
||||
/// Number of known entities.
|
||||
pub fn entity_count(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
/// Number of known facts.
|
||||
pub fn fact_count(&self) -> usize {
|
||||
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).
|
||||
pub fn observe_entity(
|
||||
&mut self,
|
||||
target: StableId,
|
||||
position: TilePosition,
|
||||
tick: u64,
|
||||
) {
|
||||
let entry = self.entities.entry(target).or_insert_with(|| EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 0,
|
||||
confidence: KnowledgeConfidence::Direct,
|
||||
source: KnowledgeSource::DirectObservation { tick },
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: BTreeMap::new(),
|
||||
});
|
||||
entry.last_known_position = Some(position);
|
||||
entry.last_observed_tick = tick;
|
||||
entry.last_updated_tick = tick;
|
||||
entry.confidence = KnowledgeConfidence::Direct;
|
||||
entry.source = KnowledgeSource::DirectObservation { tick };
|
||||
// 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.
|
||||
pub fn observe_entity_leaving_los(&mut self, target: &StableId, tick: u64) {
|
||||
if let Some(entry) = self.entities.get_mut(target) {
|
||||
if entry.confidence == KnowledgeConfidence::Direct {
|
||||
entry.confidence = KnowledgeConfidence::KnowsDetails;
|
||||
entry.last_updated_tick = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set relationship state for an entity.
|
||||
pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) {
|
||||
if let Some(entry) = self.entities.get_mut(target) {
|
||||
entry.relationship = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run knowledge decay pass. Called periodically (once per game-minute).
|
||||
pub fn decay(&mut self, current_tick: u64, thresholds: &DecayThresholds) {
|
||||
for (_id, knowledge) in self.entities.iter_mut() {
|
||||
// Direct confidence is managed by perception system, not decay.
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
let age = current_tick.saturating_sub(knowledge.last_observed_tick);
|
||||
if age > thresholds.stale_after {
|
||||
knowledge.state = KnowledgeState::Stale;
|
||||
} else if age > thresholds.decay_after {
|
||||
knowledge.confidence = knowledge.confidence.decayed();
|
||||
knowledge.last_updated_tick = current_tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KnowledgeGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_position(x: i32, y: i32) -> TilePosition {
|
||||
TilePosition::new(x, y, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_graph_is_empty() {
|
||||
let g = KnowledgeGraph::new();
|
||||
assert_eq!(g.entity_count(), 0);
|
||||
assert_eq!(g.fact_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_entity_creates_direct_entry() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
|
||||
assert!(g.knows_entity(&target));
|
||||
assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct));
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.last_known_position, Some(make_position(5, 10)));
|
||||
assert_eq!(entry.last_observed_tick, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_los_downgrades_to_knows_details() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
g.observe_entity_leaving_los(&target, 110);
|
||||
|
||||
assert_eq!(
|
||||
g.confidence_of(&target),
|
||||
Some(KnowledgeConfidence::KnowsDetails)
|
||||
);
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.last_updated_tick, 110);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_los_no_op_if_not_direct() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
g.observe_entity_leaving_los(&target, 110);
|
||||
// Now at KnowsDetails — leaving LOS again should not downgrade further
|
||||
g.observe_entity_leaving_los(&target, 120);
|
||||
assert_eq!(
|
||||
g.confidence_of(&target),
|
||||
Some(KnowledgeConfidence::KnowsDetails)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_default_is_unknown() {
|
||||
let g = KnowledgeGraph::new();
|
||||
assert_eq!(
|
||||
g.relationship_with(&StableId(999)),
|
||||
RelationshipState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_relationship_updates_entry() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
g.set_relationship(&target, RelationshipState::Hostile);
|
||||
|
||||
assert_eq!(
|
||||
g.relationship_with(&target),
|
||||
RelationshipState::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_operations() {
|
||||
let fact_id = FactId("contraband.ring_exists".to_string());
|
||||
let fact = FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
};
|
||||
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
|
||||
|
||||
assert!(g.knows_fact(&fact_id));
|
||||
assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::Suspects));
|
||||
assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsOf));
|
||||
assert!(!g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_ordering() {
|
||||
assert!(KnowledgeConfidence::Suspects < KnowledgeConfidence::KnowsOf);
|
||||
assert!(KnowledgeConfidence::KnowsOf < KnowledgeConfidence::KnowsDetails);
|
||||
assert!(KnowledgeConfidence::KnowsDetails < KnowledgeConfidence::Direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_decay_chain() {
|
||||
assert_eq!(
|
||||
KnowledgeConfidence::Direct.decayed(),
|
||||
KnowledgeConfidence::KnowsDetails
|
||||
);
|
||||
assert_eq!(
|
||||
KnowledgeConfidence::KnowsDetails.decayed(),
|
||||
KnowledgeConfidence::KnowsOf
|
||||
);
|
||||
assert_eq!(
|
||||
KnowledgeConfidence::KnowsOf.decayed(),
|
||||
KnowledgeConfidence::Suspects
|
||||
);
|
||||
assert_eq!(
|
||||
KnowledgeConfidence::Suspects.decayed(),
|
||||
KnowledgeConfidence::Suspects
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_skips_direct_confidence() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 100,
|
||||
};
|
||||
g.decay(200, &thresholds);
|
||||
|
||||
// Direct entries are managed by perception, not decay
|
||||
assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_downgrades_non_direct() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
g.observe_entity_leaving_los(&target, 110); // Now KnowsDetails
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 1000,
|
||||
};
|
||||
// Age = 200 - 100 = 100, which is > decay_after (10)
|
||||
g.decay(200, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&target),
|
||||
Some(KnowledgeConfidence::KnowsOf)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_marks_stale_when_very_old() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
g.observe_entity_leaving_los(&target, 110);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 50,
|
||||
};
|
||||
// Age = 200 - 100 = 100, which is > stale_after (50)
|
||||
g.decay(200, &thresholds);
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Stale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_preserves_contradicted_state() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
g.observe_entity(target, make_position(5, 10), 100);
|
||||
// Manually set contradicted (would be done by contradiction detector in Sprint 3)
|
||||
g.entities.get_mut(&target).unwrap().state = KnowledgeState::Contradicted;
|
||||
|
||||
// Re-observe — state should remain Contradicted
|
||||
g.observe_entity(target, make_position(6, 10), 200);
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Contradicted);
|
||||
assert_eq!(entry.confidence, KnowledgeConfidence::Direct);
|
||||
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![
|
||||
(
|
||||
FactId("location.restricted".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
FactId("contraband.exists".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::Suspects,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
),
|
||||
];
|
||||
let g = KnowledgeGraph::with_background(facts);
|
||||
assert_eq!(g.fact_count(), 2);
|
||||
assert_eq!(g.entity_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_entities_iter_deterministic() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
// Insert in reverse order — BTreeMap should iterate in sorted order
|
||||
for id in (0..5).rev() {
|
||||
g.observe_entity(StableId(id), make_position(id as i32, 0), 100);
|
||||
}
|
||||
let ids: Vec<u64> = g.known_entities_iter().map(|(id, _)| id.0).collect();
|
||||
assert_eq!(ids, vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Knowledge graph module (D-041).
|
||||
//!
|
||||
//! Implements information boundaries (D-010 principle 2): every piece of
|
||||
//! state is tagged with who knows it. Per-entity KnowledgeGraph component
|
||||
//! tracks what each entity knows about others and the world.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
pub mod events;
|
||||
pub mod graph;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use events::{
|
||||
KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType,
|
||||
};
|
||||
pub use graph::KnowledgeGraph;
|
||||
pub use registry::{EntityRegistry, StableEntityId};
|
||||
pub use types::*;
|
||||
|
||||
/// Knowledge system plugin.
|
||||
/// Registers resources and systems for knowledge graph processing.
|
||||
pub struct KnowledgePlugin;
|
||||
|
||||
impl Plugin for KnowledgePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<KnowledgeEventQueue>()
|
||||
.init_resource::<EntityRegistry>()
|
||||
.init_resource::<DecayThresholds>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
events::process_knowledge_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
events::decay_knowledge
|
||||
.after(events::process_knowledge_events),
|
||||
),
|
||||
);
|
||||
tracing::debug!("KnowledgePlugin initialized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Entity ID infrastructure (Q-019, D-041).
|
||||
//!
|
||||
//! StableEntityId component and EntityRegistry resource for
|
||||
//! bidirectional StableId <-> Entity mapping.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::types::StableId;
|
||||
|
||||
/// Stable entity ID component. Assigned once at spawn, never changes.
|
||||
/// Serialized with entity for save/load.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct StableEntityId(pub StableId);
|
||||
|
||||
/// Bidirectional mapping between StableId and bevy Entity.
|
||||
/// Resource, updated on spawn/despawn.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct EntityRegistry {
|
||||
by_stable_id: BTreeMap<StableId, Entity>,
|
||||
by_entity: BTreeMap<Entity, StableId>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Default for EntityRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityRegistry {
|
||||
pub fn new(seed_offset: u64) -> Self {
|
||||
Self {
|
||||
by_stable_id: BTreeMap::new(),
|
||||
by_entity: BTreeMap::new(),
|
||||
next_id: seed_offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new entity and assign a StableId.
|
||||
/// Returns the existing StableId if already registered.
|
||||
pub fn register(&mut self, entity: Entity) -> StableId {
|
||||
if let Some(id) = self.by_entity.get(&entity) {
|
||||
return *id;
|
||||
}
|
||||
let id = StableId(self.next_id);
|
||||
self.next_id += 1;
|
||||
self.by_stable_id.insert(id, entity);
|
||||
self.by_entity.insert(entity, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Lookup: StableId -> Entity (for ECS queries).
|
||||
pub fn to_entity(&self, id: &StableId) -> Option<Entity> {
|
||||
self.by_stable_id.get(id).copied()
|
||||
}
|
||||
|
||||
/// Lookup: Entity -> StableId (for knowledge graph keys).
|
||||
pub fn to_stable(&self, entity: Entity) -> Option<StableId> {
|
||||
self.by_entity.get(&entity).copied()
|
||||
}
|
||||
|
||||
/// Remove a despawned entity from the registry.
|
||||
/// Called AFTER knowledge cleanup (tombstone pattern).
|
||||
pub fn unregister(&mut self, entity: Entity) {
|
||||
if let Some(id) = self.by_entity.remove(&entity) {
|
||||
self.by_stable_id.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of registered entities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_entity.len()
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_entity.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
#[test]
|
||||
fn register_assigns_sequential_ids() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id1 = registry.register(e1);
|
||||
let id2 = registry.register(e2);
|
||||
|
||||
assert_eq!(id1, StableId(0));
|
||||
assert_eq!(id2, StableId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_idempotent() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id_first = registry.register(e1);
|
||||
let id_second = registry.register(e1);
|
||||
|
||||
assert_eq!(id_first, id_second);
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional_lookup() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id = registry.register(e1);
|
||||
|
||||
assert_eq!(registry.to_entity(&id), Some(e1));
|
||||
assert_eq!(registry.to_stable(e1), Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_both_directions() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id = registry.register(e1);
|
||||
registry.unregister(e1);
|
||||
|
||||
assert_eq!(registry.to_entity(&id), None);
|
||||
assert_eq!(registry.to_stable(e1), None);
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_offset_starts_ids_higher() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(1000);
|
||||
let id = registry.register(e1);
|
||||
|
||||
assert_eq!(id, StableId(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_unknown_entity_returns_none() {
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let registry = EntityRegistry::new(0);
|
||||
assert_eq!(registry.to_entity(&StableId(999)), None);
|
||||
assert_eq!(registry.to_stable(e1), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Knowledge graph types per D-041 canonical reference.
|
||||
//!
|
||||
//! All types conform to the structs defined in:
|
||||
//! docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
// --- Identity Types ---
|
||||
|
||||
/// Stable entity identifier that survives save/load cycles.
|
||||
/// NOT a bevy_ecs Entity (which is a generational index).
|
||||
/// Assigned once at entity creation, never changes.
|
||||
/// Resolves Q-019 for knowledge graph and snapshot purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct StableId(pub u64);
|
||||
|
||||
/// Typed fact identifier for non-entity knowledge.
|
||||
/// Format: "category.topic" (e.g., "contraband.ring_exists").
|
||||
/// Lexicographic ordering in BTreeMap provides deterministic iteration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct FactId(pub String);
|
||||
|
||||
// --- Knowledge Confidence (Q-016 Resolution) ---
|
||||
|
||||
/// Knowledge confidence hierarchy. Discrete enum, NOT a continuous float.
|
||||
/// Derives Ord: Suspects < KnowsOf < KnowsDetails < Direct.
|
||||
/// This ordering is load-bearing -- do not reorder variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum KnowledgeConfidence {
|
||||
/// "Something seems off about X" / "I've heard the name"
|
||||
/// Gates: initial investigation, vague monologue, open-ended questions.
|
||||
Suspects = 0,
|
||||
/// "X is involved in Y" / "X has a connection to Z"
|
||||
/// Gates: topic-specific dialogue, specific monologue, peer-tier access.
|
||||
KnowsOf = 1,
|
||||
/// "X did Y at Z because W" -- actionable detail.
|
||||
/// Gates: confrontation, secret-tier dialogue, detailed monologue.
|
||||
KnowsDetails = 2,
|
||||
/// "I'm looking at X right now" -- currently in observer's LOS.
|
||||
/// Gates: live position data, current activity visible, maximum rendering fidelity.
|
||||
/// Automatically set by perception system, downgraded when entity leaves LOS.
|
||||
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 {
|
||||
match self {
|
||||
Self::Direct => Self::KnowsDetails,
|
||||
Self::KnowsDetails => Self::KnowsOf,
|
||||
Self::KnowsOf => Self::Suspects,
|
||||
Self::Suspects => Self::Suspects, // Floor -- does not decay below Suspects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Knowledge State ---
|
||||
|
||||
/// Temporal/logical state of a knowledge entry.
|
||||
/// Orthogonal to confidence: a KnowsDetails entry can be Active or Contradicted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum KnowledgeState {
|
||||
/// Currently believed true. Default state.
|
||||
Active,
|
||||
/// Conflicting information exists. Both conflicting entries receive this state.
|
||||
/// Triggers monologue event when set. THE FRIEND arc detector.
|
||||
Contradicted,
|
||||
/// Decay system has aged this entry beyond stale threshold.
|
||||
/// Entry remains in graph but is treated as unreliable.
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl Default for KnowledgeState {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
// --- Knowledge Source ---
|
||||
|
||||
/// How knowledge was acquired. Tracked per-entry for provenance.
|
||||
/// CauseChain (D-030) can reference this for monologue trigger explanations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum KnowledgeSource {
|
||||
/// Directly seen by this entity's LOS (perception system).
|
||||
DirectObservation { tick: u64 },
|
||||
/// Heard via D-018 sound model (medium/long range).
|
||||
Heard { tick: u64, range: SoundRange },
|
||||
/// Told by another entity during conversation (dialogue, gossip).
|
||||
ToldBy { source_id: StableId, tick: u64 },
|
||||
/// Inferred from combining other knowledge entries.
|
||||
Inferred { basis: Vec<FactId> },
|
||||
/// Starting knowledge from character background (D-013 insert data).
|
||||
Background,
|
||||
}
|
||||
|
||||
/// Sound range classification from D-018.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SoundRange {
|
||||
Close,
|
||||
Medium,
|
||||
Long,
|
||||
}
|
||||
|
||||
// --- Relationship State (D-033) ---
|
||||
|
||||
/// Relationship state drives D-033 entity color rendering.
|
||||
/// Derived from knowledge + NPC relationship axes (D-024).
|
||||
/// Client maps this to color palette defined in D-033.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RelationshipState {
|
||||
/// No prior knowledge. Teal #4a9ebb.
|
||||
Unknown,
|
||||
/// Recognized, neutral-to-positive. Soft green #6bc9a6.
|
||||
Known,
|
||||
/// Positive relationship, trusted. Soft green #6bc9a6.
|
||||
Friendly,
|
||||
/// Flagged by monologue/investigation. Amber #e8c547.
|
||||
PersonOfInterest,
|
||||
/// Character KNOWS there is danger. Red #d45d5d.
|
||||
Hostile,
|
||||
}
|
||||
|
||||
impl Default for RelationshipState {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entity Knowledge ---
|
||||
|
||||
/// What entity A knows about entity B.
|
||||
/// One entry per known entity in the BTreeMap.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntityKnowledge {
|
||||
/// Last position this entity was observed at. None if never directly seen.
|
||||
pub last_known_position: Option<TilePosition>,
|
||||
/// Tick when this entity was last directly observed by LOS.
|
||||
pub last_observed_tick: u64,
|
||||
/// Tick when this knowledge entry was last updated (by any source).
|
||||
pub last_updated_tick: u64,
|
||||
/// How confident is this knowledge? (Q-016 hierarchy)
|
||||
pub confidence: KnowledgeConfidence,
|
||||
/// How was this knowledge acquired?
|
||||
pub source: KnowledgeSource,
|
||||
/// Logical state (active, contradicted, stale).
|
||||
pub state: KnowledgeState,
|
||||
/// Relationship assessment (drives D-033 entity color).
|
||||
pub relationship: RelationshipState,
|
||||
/// Known attributes of the target entity.
|
||||
/// Keys are structured (name, role, faction, etc.)
|
||||
pub known_attributes: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Non-entity fact knowledge (locations, events, abstract knowledge).
|
||||
/// Used for monologue prerequisites and dialogue gating.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FactKnowledge {
|
||||
/// Confidence level for this fact.
|
||||
pub confidence: KnowledgeConfidence,
|
||||
/// How was this fact acquired?
|
||||
pub source: KnowledgeSource,
|
||||
/// Logical state.
|
||||
pub state: KnowledgeState,
|
||||
/// Tick when this fact was learned.
|
||||
pub acquired_tick: u64,
|
||||
}
|
||||
|
||||
// --- Decay Configuration ---
|
||||
|
||||
/// Configuration resource for knowledge decay rates.
|
||||
/// D-011: "fog returns when you leave."
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DecayThresholds {
|
||||
/// Ticks before knowledge begins decaying.
|
||||
/// Default: 600 ticks = 1 game-hour (at 10 tps per D-031).
|
||||
pub decay_after: u64,
|
||||
/// Ticks before knowledge becomes Stale.
|
||||
/// Default: 3600 ticks = 6 game-hours.
|
||||
pub stale_after: u64,
|
||||
}
|
||||
|
||||
impl Default for DecayThresholds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
decay_after: 600, // 1 game-hour
|
||||
stale_after: 3600, // 6 game-hours
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Observer Snapshot Integration ---
|
||||
|
||||
/// How an entity appears in the observer snapshot.
|
||||
/// Extends VisibleEntity for knowledge-based rendering.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EntityVisibility {
|
||||
/// Currently in line of sight.
|
||||
Visible,
|
||||
/// Not in LOS but remembered from knowledge graph.
|
||||
Remembered {
|
||||
confidence: KnowledgeConfidence,
|
||||
age_ticks: u64,
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod simulation;
|
||||
|
||||
+8
-2
@@ -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,6 +4,7 @@
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
pub mod observation;
|
||||
pub mod observer;
|
||||
pub mod shadowcast;
|
||||
pub mod vision_cone;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Direct observation knowledge flow (#364).
|
||||
//!
|
||||
//! Emits knowledge events when entities enter or leave the observer's LOS.
|
||||
//! Runs after compute_observer_snapshot, before process_knowledge_events.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::{
|
||||
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
|
||||
};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Emit knowledge events for entities entering/leaving the observer's LOS.
|
||||
///
|
||||
/// Compares the current snapshot's visible entities against the observer's
|
||||
/// knowledge graph. Emits DirectObservation for newly visible entities and
|
||||
/// LeftLOS for entities that were Direct but are no longer in the snapshot.
|
||||
pub fn emit_observation_events(
|
||||
time: Res<SimulationTime>,
|
||||
buffer: Res<SnapshotBuffer>,
|
||||
registry: Res<EntityRegistry>,
|
||||
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 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))
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
// Emit DirectObservation for each visible non-player entity
|
||||
for visible in &snapshot.entities {
|
||||
if matches!(visible.kind, EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up the bevy Entity from the entity_id (which is Entity::to_bits())
|
||||
let entity = Entity::from_bits(visible.entity_id);
|
||||
|
||||
// Get tile position for knowledge tracking
|
||||
if let Ok(pos) = entity_positions.get(entity) {
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: observer_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: entity,
|
||||
position: *pos,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit LeftLOS for entities that were Direct but are no longer visible
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
if knowledge.confidence != crate::knowledge::KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entity is still visible in the current snapshot
|
||||
if let Some(entity) = registry.to_entity(stable_id) {
|
||||
let entity_bits = entity.to_bits();
|
||||
if !visible_entity_ids.contains(&entity_bits) {
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: observer_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::LeftLOS { target: entity },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::Npc;
|
||||
use crate::perception::observer::compute_observer_snapshot;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::movement::WalkabilityMap;
|
||||
|
||||
fn setup_world(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_npc_emits_direct_observation() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// First: compute snapshot so NPC is visible
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(
|
||||
!queue.is_empty(),
|
||||
"should emit DirectObservation for visible NPC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_leaving_los_emits_left_los() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player with pre-existing Direct knowledge of NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// Now block the NPC behind a wall so it's NOT visible
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&TilePosition::new(16, 15, 0), false);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
let events = &queue.events;
|
||||
let has_left_los = events.iter().any(|e| {
|
||||
matches!(
|
||||
e.event_type,
|
||||
KnowledgeEventType::LeftLOS { target } if target == npc
|
||||
)
|
||||
});
|
||||
assert!(has_left_los, "should emit LeftLOS when NPC is no longer visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_events_for_player_entity() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain());
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(
|
||||
queue.is_empty(),
|
||||
"should not emit observation events for the player entity itself"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user