#94 — Active tier simulation (complete): - Add ActiveSim marker to all 9 test world room NPC spawns - Fix test entities in routine.rs and path_follow.rs to include ActiveSim so With<ActiveSim> queries match correctly in unit tests #99 — Tier transition logic (complete): - Implement update_tier_markers system in tier.rs - Promotes/demotes tier markers by manhattan distance from PlayerCharacter: ≤40 tiles → ActiveSim, ≤120 → BackgroundSim, beyond → StateSaved - Handles cross-z-level as u32::MAX (effectively unreachable) - No-op when no PlayerCharacter entity present (headless tests safe) - 11 new unit tests covering all distance bands and boundary cases - TierPlugin now registers the system after movement::validate_movement Also picks up extended test coverage added by hoshe: - observer/tests.rs — 230 lines of perception observer tests - sound.rs — additional sound event integration tests All 548 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::graph::filter_by_access;
|
||||
use crate::knowledge::types::{AccessRule, KnowledgeState};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
@@ -80,6 +81,7 @@ pub fn compute_observer_snapshot(
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
Option<&AccessRule>,
|
||||
)>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
@@ -121,8 +123,13 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve observer's StableId for component-level access control (#139, D-010)
|
||||
let observer_stable_id = registry
|
||||
.to_stable(observer_entity)
|
||||
.unwrap_or(StableId(0));
|
||||
|
||||
let (mut entities, visible_ids, blocked_entities) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, observer_stable_id, &all_entities);
|
||||
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
@@ -237,18 +244,20 @@ fn filter_visible_entities(
|
||||
geometry: &VisibilityGeometry,
|
||||
registry: &EntityRegistry,
|
||||
observer_kg: &KnowledgeGraph,
|
||||
observer_stable_id: StableId,
|
||||
all_entities: &Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
Option<&AccessRule>,
|
||||
)>,
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() {
|
||||
if pos.z != geometry.observer_z {
|
||||
continue;
|
||||
}
|
||||
@@ -289,7 +298,16 @@ fn filter_visible_entities(
|
||||
let relationship = if is_player.is_some() {
|
||||
RelationshipState::Known // Self
|
||||
} else if let Some(stable_id) = registry.to_stable(entity) {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
// D-010 principle 2: check access control before exposing relationship (#139)
|
||||
let access_granted = match access_rule {
|
||||
Some(rule) => filter_by_access(observer_stable_id, stable_id, &rule.0, observer_kg),
|
||||
None => true, // No AccessRule → Public (default)
|
||||
};
|
||||
if access_granted {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown // Access denied — redact relationship data
|
||||
}
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::types::{KnowledgeState, ObserverAccess};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
@@ -2178,6 +2178,234 @@ fn different_z_level_not_in_blocked_entities() {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Component-level access control tests (#139, D-010 principle 2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn access_rule_owner_only_redacts_relationship() {
|
||||
// THE critical negative test for #139: NPC with OwnerOnly access rule
|
||||
// is physically visible (in LOS) but relationship data is redacted.
|
||||
use crate::knowledge::types::AccessRule;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC with OwnerOnly access rule — only the NPC itself can read its data
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::OwnerOnly),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows NPC as Hostile — but access should be denied
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// NPC should be visible (physically in LOS)
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible even with OwnerOnly access");
|
||||
|
||||
// But relationship must be redacted to Unknown (access denied)
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Unknown,
|
||||
"OwnerOnly access should redact relationship to Unknown for non-owner observer"
|
||||
);
|
||||
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_rule_knowledge_gated_passes_with_matching_fact() {
|
||||
// Positive test: observer has the required fact, relationship visible.
|
||||
use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource};
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC gated on a specific fact
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::KnowledgeGated("contraband.ring_exists".into())),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows the required fact AND has a relationship with the NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
kg.facts.insert(
|
||||
FactId("contraband.ring_exists".into()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
|
||||
// Observer has the required fact — relationship should be visible
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::PersonOfInterest,
|
||||
"KnowledgeGated access should pass when observer has the required fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_rule_knowledge_gated_redacts_without_fact() {
|
||||
// Negative test: observer lacks the required fact, relationship redacted.
|
||||
use crate::knowledge::types::AccessRule;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC gated on a fact the observer doesn't have
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::KnowledgeGated("conspiracy.mastermind".into())),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player has relationship but NOT the required fact
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible (in LOS)");
|
||||
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Unknown,
|
||||
"KnowledgeGated access should redact relationship when observer lacks the fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_access_rule_defaults_to_public() {
|
||||
// Existing behavior: entities without AccessRule are fully visible.
|
||||
// This is a regression guard — existing tests also cover this implicitly.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC with NO AccessRule component
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Friendly,
|
||||
"No AccessRule should default to Public — relationship fully visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_sorted_ascending() {
|
||||
// Multiple blocked NPCs should appear in ascending entity_id order
|
||||
|
||||
Reference in New Issue
Block a user