1. Sound producers: document empty v0.1 pipeline explicitly (critical) 2. Routine tests: add ActiveSim to 3 tests that passed trivially 3. Rename _observer_pos → observer_pos (used at line 191) 4. Add FactionOnly positive test case (matching faction_id) 5. Fix stale doc comment "Current: 9" → 10 in ObserverSnapshot 6. Remove orphaned SimulationTier/LastInteraction/ScopeTag types 7. Add tracing::warn on FactionOnly non-numeric parse failure 8. Document Medium-range occlusion gap as TODO in audible_at 9. Insert SoundEventQueue in observer test setup_world Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
750 lines
26 KiB
Rust
750 lines
26 KiB
Rust
//! 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Record an incomplete interaction with an entity (D-064 walk-away).
|
||
///
|
||
/// Appends to known_attributes["incomplete_interactions"] as a
|
||
/// comma-separated list of "tick:type" entries. Creates the entity
|
||
/// entry if it doesn't exist (at Suspects confidence).
|
||
pub fn record_incomplete_interaction(
|
||
&mut self,
|
||
target: &StableId,
|
||
interaction_type: super::events::InteractionType,
|
||
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::Suspects,
|
||
source: KnowledgeSource::DirectObservation { tick },
|
||
state: KnowledgeState::Active,
|
||
relationship: RelationshipState::Unknown,
|
||
known_attributes: BTreeMap::new(),
|
||
});
|
||
|
||
let type_str = match interaction_type {
|
||
super::events::InteractionType::Talk => "talk",
|
||
super::events::InteractionType::Confront => "confront",
|
||
};
|
||
let record = format!("{}:{}", tick, type_str);
|
||
|
||
entry
|
||
.known_attributes
|
||
.entry("incomplete_interactions".to_string())
|
||
.and_modify(|v| {
|
||
v.push(',');
|
||
v.push_str(&record);
|
||
})
|
||
.or_insert(record);
|
||
|
||
entry.last_updated_tick = tick;
|
||
}
|
||
|
||
/// Check if the observer has any incomplete interactions with an entity.
|
||
///
|
||
/// Returns true if known_attributes["incomplete_interactions"] exists
|
||
/// and is non-empty. Used by dialogue/monologue systems to gate
|
||
/// post-conversation reactions (D-064 phase 3).
|
||
pub fn has_incomplete_interaction(&self, target: &StableId) -> bool {
|
||
self.entities
|
||
.get(target)
|
||
.and_then(|e| e.known_attributes.get("incomplete_interactions"))
|
||
.is_some_and(|v| !v.is_empty())
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|
||
|
||
// --- Access control filter (#139, D-010 principle 2) ---
|
||
|
||
/// Component-level access control filter.
|
||
///
|
||
/// Called by the observer snapshot builder before including a component's
|
||
/// sensitive data in the snapshot. Returns `true` if `observer_id` is
|
||
/// permitted to read a component tagged with `rule` on entity `target_id`.
|
||
///
|
||
/// Design: coarse-grained component-level check. A component either passes
|
||
/// or fails as a whole. See `ObserverAccess` for available rules.
|
||
///
|
||
/// # Arguments
|
||
/// - `observer_id`: StableId of the entity requesting access.
|
||
/// - `target_id`: StableId of the entity that owns the component.
|
||
/// - `rule`: The access rule attached to the component via `AccessRule`.
|
||
/// - `kg`: The observer's `KnowledgeGraph` (used for relationship and knowledge checks).
|
||
pub fn filter_by_access(
|
||
observer_id: StableId,
|
||
target_id: StableId,
|
||
rule: &ObserverAccess,
|
||
kg: &KnowledgeGraph,
|
||
) -> bool {
|
||
match rule {
|
||
// Public data is always readable.
|
||
ObserverAccess::Public => true,
|
||
|
||
// OwnerOnly: only the entity that owns the component can read it.
|
||
// Primary use case: player's own inventory (D-065).
|
||
ObserverAccess::OwnerOnly => observer_id == target_id,
|
||
|
||
// FactionOnly: observer must have a recorded faction match with the target.
|
||
// Stored as a "faction_id" key in the target's known_attributes.
|
||
// Full faction system deferred; approximation via knowledge attributes.
|
||
ObserverAccess::FactionOnly(faction_id) => kg
|
||
.entities
|
||
.get(&target_id)
|
||
.and_then(|k| k.known_attributes.get("faction_id"))
|
||
.and_then(|v| match v.parse::<u64>() {
|
||
Ok(id) => Some(id),
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
target_id = target_id.0,
|
||
value = %v,
|
||
"FactionOnly: non-numeric faction_id attribute, denying access"
|
||
);
|
||
None
|
||
}
|
||
})
|
||
.is_some_and(|id| id == faction_id.0),
|
||
|
||
// RelationshipGated: observer must have a relationship score >= threshold.
|
||
// Threshold is 0–100; maps to RelationshipState enum values.
|
||
ObserverAccess::RelationshipGated(threshold) => {
|
||
let score: i32 = match kg.relationship_with(&target_id) {
|
||
RelationshipState::Unknown => 0,
|
||
RelationshipState::Known => 25,
|
||
RelationshipState::PersonOfInterest => 40,
|
||
RelationshipState::Friendly => 75,
|
||
RelationshipState::Hostile => 5,
|
||
};
|
||
score >= *threshold
|
||
}
|
||
|
||
// KnowledgeGated: observer must have a specific fact in their knowledge graph.
|
||
// Used for "you only see this if you know about it" information walls.
|
||
ObserverAccess::KnowledgeGated(flag) => {
|
||
let fact_id = FactId(flag.clone());
|
||
kg.knows_fact(&fact_id)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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]);
|
||
}
|
||
|
||
// --- filter_by_access tests (#139, D-010 principle 2) ---
|
||
//
|
||
// Sprint 12 test focus: "negative tests — blocked component not returned
|
||
// for non-owner observer". These tests verify each ObserverAccess variant
|
||
// and confirm the critical negative case: OwnerOnly blocks non-owner.
|
||
|
||
#[test]
|
||
fn filter_by_access_public_always_passes() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let kg = KnowledgeGraph::new();
|
||
|
||
assert!(
|
||
filter_by_access(observer, target, &ObserverAccess::Public, &kg),
|
||
"Public access rule must always return true"
|
||
);
|
||
|
||
// Public is symmetric — even self-observation passes
|
||
assert!(filter_by_access(observer, observer, &ObserverAccess::Public, &kg));
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_owner_only_blocks_non_owner() {
|
||
// THE critical negative test (Sprint 12 joint briefing).
|
||
// A non-owner observer must NOT get access to OwnerOnly data.
|
||
let observer = StableId(1); // some other entity
|
||
let target = StableId(2); // owns the component
|
||
let kg = KnowledgeGraph::new();
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &ObserverAccess::OwnerOnly, &kg),
|
||
"OwnerOnly must block a non-owner observer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_owner_only_allows_owner() {
|
||
// The entity observing its own component must be allowed.
|
||
let owner = StableId(5);
|
||
let kg = KnowledgeGraph::new();
|
||
|
||
assert!(
|
||
filter_by_access(owner, owner, &ObserverAccess::OwnerOnly, &kg),
|
||
"OwnerOnly must allow the owner to read their own component"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_owner_only_distinct_ids_always_block() {
|
||
// Additional negative: even adjacent IDs are different owners.
|
||
let kg = KnowledgeGraph::new();
|
||
for id in 1u64..=10 {
|
||
assert!(
|
||
!filter_by_access(StableId(id), StableId(id + 1), &ObserverAccess::OwnerOnly, &kg),
|
||
"StableId({id}) should not match StableId({})", id + 1
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_knowledge_gated_blocks_without_knowledge() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let kg = KnowledgeGraph::new(); // empty — no facts known
|
||
|
||
let rule = ObserverAccess::KnowledgeGated("contraband.ring_exists".to_string());
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &rule, &kg),
|
||
"KnowledgeGated must block when observer lacks the required fact"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_knowledge_gated_passes_with_knowledge() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let flag = "contraband.ring_exists";
|
||
let kg = KnowledgeGraph::with_background(vec![(
|
||
FactId(flag.to_string()),
|
||
FactKnowledge {
|
||
confidence: KnowledgeConfidence::KnowsOf,
|
||
source: KnowledgeSource::Background,
|
||
state: KnowledgeState::Active,
|
||
acquired_tick: 0,
|
||
},
|
||
)]);
|
||
|
||
let rule = ObserverAccess::KnowledgeGated(flag.to_string());
|
||
|
||
assert!(
|
||
filter_by_access(observer, target, &rule, &kg),
|
||
"KnowledgeGated must pass when observer has the required fact"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_knowledge_gated_wrong_flag_blocks() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let kg = KnowledgeGraph::with_background(vec![(
|
||
FactId("contraband.ring_exists".to_string()),
|
||
FactKnowledge {
|
||
confidence: KnowledgeConfidence::KnowsOf,
|
||
source: KnowledgeSource::Background,
|
||
state: KnowledgeState::Active,
|
||
acquired_tick: 0,
|
||
},
|
||
)]);
|
||
|
||
// Gated on a DIFFERENT flag — observer doesn't have this one
|
||
let rule = ObserverAccess::KnowledgeGated("conspiracy.mastermind".to_string());
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &rule, &kg),
|
||
"KnowledgeGated must block when observer has a different fact, not this one"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_relationship_gated_blocks_unknown() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let kg = KnowledgeGraph::new(); // observer has no knowledge of target
|
||
|
||
// Threshold 25 = Known level — Unknown (score=0) should fail
|
||
let rule = ObserverAccess::RelationshipGated(25);
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &rule, &kg),
|
||
"RelationshipGated must block when observer's relationship is Unknown (score 0)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_relationship_gated_passes_for_friendly() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let mut kg = KnowledgeGraph::new();
|
||
kg.observe_entity(target, make_position(5, 5), 100);
|
||
kg.set_relationship(&target, RelationshipState::Friendly);
|
||
|
||
// Threshold 50 — Friendly (score=75) should pass
|
||
let rule = ObserverAccess::RelationshipGated(50);
|
||
|
||
assert!(
|
||
filter_by_access(observer, target, &rule, &kg),
|
||
"RelationshipGated must pass when observer has Friendly relationship (score 75 >= 50)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_relationship_gated_blocks_hostile() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let mut kg = KnowledgeGraph::new();
|
||
kg.observe_entity(target, make_position(5, 5), 100);
|
||
kg.set_relationship(&target, RelationshipState::Hostile);
|
||
|
||
// Threshold 25 — Hostile (score=5) should fail
|
||
let rule = ObserverAccess::RelationshipGated(25);
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &rule, &kg),
|
||
"RelationshipGated must block Hostile relationship (score 5 < threshold 25)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_faction_only_blocks_without_faction_attribute() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let kg = KnowledgeGraph::new(); // no knowledge of target
|
||
|
||
let faction = StableId(99);
|
||
let rule = ObserverAccess::FactionOnly(faction);
|
||
|
||
assert!(
|
||
!filter_by_access(observer, target, &rule, &kg),
|
||
"FactionOnly must block when faction attribute is not known"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_by_access_faction_only_passes_with_matching_faction() {
|
||
let observer = StableId(1);
|
||
let target = StableId(2);
|
||
let mut kg = KnowledgeGraph::new();
|
||
|
||
// Observer knows target's faction via known_attributes
|
||
kg.observe_entity(target, make_position(5, 5), 10);
|
||
kg.entities
|
||
.get_mut(&target)
|
||
.unwrap()
|
||
.known_attributes
|
||
.insert("faction_id".into(), "99".into());
|
||
|
||
let faction = StableId(99);
|
||
let rule = ObserverAccess::FactionOnly(faction);
|
||
|
||
assert!(
|
||
filter_by_access(observer, target, &rule, &kg),
|
||
"FactionOnly must pass when observer knows the matching faction_id"
|
||
);
|
||
}
|
||
}
|