feat(simulation): sprint 12 server — tier system, sound events, KG access, line previewer

Implements 4 completed tickets + partial progress on 2 more:

- #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin)
- #138 Information tag schema (ObserverAccess enum in knowledge/types.rs)
- #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring)
- #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes)
- #94 Active tier simulation (in progress — With<ActiveSim> filters)
- #139 Component-level access control (in progress — filter_by_access)

Updates snapshot fixtures and test golden files for new sound_events field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:33:23 +01:00
co-authored by Claude Opus 4.6
parent f7e5b7eb63
commit 066f8031fd
30 changed files with 1646 additions and 14 deletions
+245
View File
@@ -231,6 +231,68 @@ impl Default for KnowledgeGraph {
}
}
// --- 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| v.parse::<u64>().ok())
.is_some_and(|id| id == faction_id.0),
// RelationshipGated: observer must have a relationship score >= threshold.
// Threshold is 0100; 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::*;
@@ -468,4 +530,187 @@ mod tests {
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"
);
}
}
+39
View File
@@ -208,6 +208,45 @@ impl Default for DecayThresholds {
}
}
// --- Information Access Control (D-010 principle 2, #138) ---
/// Access rule governing who can read a component's sensitive data.
///
/// The observer snapshot builder checks `AccessRule` before including data
/// in a snapshot. This is the schema; enforcement is in #139 (access control).
///
/// Design: coarse-grained component-level tags rather than per-field.
/// A component either passes or fails its access check as a whole.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ObserverAccess {
/// Anyone can observe this data. Default for non-sensitive components.
Public,
/// Only the entity that owns this component (e.g. player's own inventory).
OwnerOnly,
/// Members of a specific faction can observe this data.
FactionOnly(StableId),
/// Observers with a relationship score at or above the threshold can read.
/// Threshold is on a 0100 scale matching the NPC relationship axes (D-024).
RelationshipGated(i32),
/// Only observers who have a specific knowledge flag (FactId) can read.
/// Used for "you only see this if you know about it" information walls.
KnowledgeGated(String),
}
impl Default for ObserverAccess {
fn default() -> Self {
Self::Public
}
}
/// Component that attaches an access rule to an entity's sensitive data.
///
/// When an observer snapshot is built, `filter_by_access` (implemented in
/// #139) checks this rule before including component data in the snapshot.
/// Components without `AccessRule` are treated as `ObserverAccess::Public`.
#[derive(Component, Debug, Clone, Default)]
pub struct AccessRule(pub ObserverAccess);
// --- Observer Snapshot Integration ---
/// How an entity appears in the observer snapshot.