feat(simulation): #94 #99 active tier filtering and transition system

#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:
2026-02-19 13:54:37 +01:00
co-authored by Claude Sonnet 4.6
parent 066f8031fd
commit 8e00db2b2d
15 changed files with 733 additions and 12 deletions
+22 -4
View File
@@ -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, &registry, observer_kg, &all_entities);
filter_visible_entities(&geometry, &registry, 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
};
+229 -1
View File
@@ -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