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:
@@ -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 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::*;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0–100 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.
|
||||
|
||||
Reference in New Issue
Block a user