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
+253 -2
View File
@@ -2,9 +2,44 @@
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
// Timestamp-based LRU eviction for simulation space management
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
// --- Zero-sized marker components (D-026) ---
// Tag-based tier identification. Systems query With<ActiveSim> to scope work
// to nearby NPCs only, avoiding full-world iteration every tick.
/// Marker: entity is in the Active simulation tier.
/// Full behavior systems (movement, perception, dialogue, monologue) run for
/// entities with this tag at 1020 ticks/sec.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ActiveSim;
/// Marker: entity is in the Background simulation tier.
/// Lightweight schedule-keeping only — no full perception or dialogue.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct BackgroundSim;
/// Marker: entity is in the State-saved tier.
/// ECS components preserved but no systems run. Re-promoted to Background
/// or Active when player approaches.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct StateSaved;
/// Plugin registering the tier marker components and associated resources.
/// Systems that filter by tier (With<ActiveSim>, etc.) require these markers
/// to exist in the type registry. Future: tier transition systems live here.
pub struct TierPlugin;
impl Plugin for TierPlugin {
fn build(&self, _app: &mut App) {
// Marker components are zero-sized — no resources to initialize.
// Tier transition systems will be added here in ticket #99.
tracing::debug!("TierPlugin initialized");
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimulationTier {
Active,
@@ -34,10 +69,13 @@ pub enum ScopeKind {
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::world::World;
// --- SimulationTier enum tests ---
#[test]
fn tier_can_be_added_and_queried() {
let mut world = bevy_ecs::world::World::new();
let mut world = World::new();
let entity = world.spawn(SimulationTier::Active).id();
assert_eq!(
*world.get::<SimulationTier>(entity).unwrap(),
@@ -47,7 +85,7 @@ mod tests {
#[test]
fn tier_can_transition() {
let mut world = bevy_ecs::world::World::new();
let mut world = World::new();
let entity = world.spawn(SimulationTier::Active).id();
world.entity_mut(entity).insert(SimulationTier::Background);
assert_eq!(
@@ -55,4 +93,217 @@ mod tests {
SimulationTier::Background
);
}
#[test]
fn all_tier_variants_are_distinct() {
assert_ne!(SimulationTier::Active, SimulationTier::Background);
assert_ne!(SimulationTier::Background, SimulationTier::StateSaved);
assert_ne!(SimulationTier::StateSaved, SimulationTier::Ungenerated);
assert_ne!(SimulationTier::Active, SimulationTier::Ungenerated);
}
// --- Marker component query correctness (D-026, #94) ---
// These tests verify that With<ActiveSim> / With<BackgroundSim> / With<StateSaved>
// filter correctly — the core guarantee that behavior systems only run for the
// intended tier.
#[test]
fn with_active_sim_query_excludes_background_entities() {
let mut world = World::new();
let active = world.spawn(ActiveSim).id();
let _background = world.spawn(BackgroundSim).id();
let _state_saved = world.spawn(StateSaved).id();
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1, "only one ActiveSim entity expected");
assert_eq!(results[0], active);
}
#[test]
fn with_background_sim_query_excludes_active_entities() {
let mut world = World::new();
let _active = world.spawn(ActiveSim).id();
let background = world.spawn(BackgroundSim).id();
let _state_saved = world.spawn(StateSaved).id();
let mut query =
world.query_filtered::<bevy_ecs::entity::Entity, With<BackgroundSim>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1, "only one BackgroundSim entity expected");
assert_eq!(results[0], background);
}
#[test]
fn with_state_saved_query_excludes_active_and_background() {
let mut world = World::new();
let _active = world.spawn(ActiveSim).id();
let _background = world.spawn(BackgroundSim).id();
let state_saved = world.spawn(StateSaved).id();
let mut query =
world.query_filtered::<bevy_ecs::entity::Entity, With<StateSaved>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1, "only one StateSaved entity expected");
assert_eq!(results[0], state_saved);
}
#[test]
fn multiple_active_sim_entities_all_returned() {
let mut world = World::new();
let a = world.spawn(ActiveSim).id();
let b = world.spawn(ActiveSim).id();
let _c = world.spawn(BackgroundSim).id();
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
let mut results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
results.sort(); // deterministic comparison
assert_eq!(results.len(), 2);
assert!(results.contains(&a));
assert!(results.contains(&b));
}
#[test]
fn entity_without_tier_marker_not_returned_by_active_query() {
let mut world = World::new();
let _bare = world.spawn_empty().id();
let active = world.spawn(ActiveSim).id();
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1);
assert_eq!(results[0], active);
}
// --- Tier transition tests (#99) ---
#[test]
fn promote_state_saved_to_active() {
let mut world = World::new();
let entity = world.spawn(StateSaved).id();
// Transition: StateSaved → ActiveSim
world
.entity_mut(entity)
.remove::<StateSaved>()
.insert(ActiveSim);
assert!(world.get::<ActiveSim>(entity).is_some(), "ActiveSim added");
assert!(
world.get::<StateSaved>(entity).is_none(),
"StateSaved removed"
);
// Must appear in ActiveSim query after promotion
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1);
assert_eq!(results[0], entity);
}
#[test]
fn demote_active_to_background() {
let mut world = World::new();
let entity = world.spawn(ActiveSim).id();
// Transition: ActiveSim → BackgroundSim
world
.entity_mut(entity)
.remove::<ActiveSim>()
.insert(BackgroundSim);
assert!(
world.get::<BackgroundSim>(entity).is_some(),
"BackgroundSim added"
);
assert!(world.get::<ActiveSim>(entity).is_none(), "ActiveSim removed");
// Must NOT appear in ActiveSim query after demotion
let mut active_query =
world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
assert_eq!(
active_query.iter(&world).count(),
0,
"demoted entity not in ActiveSim query"
);
}
#[test]
fn demote_active_to_state_saved() {
let mut world = World::new();
let entity = world.spawn(ActiveSim).id();
world
.entity_mut(entity)
.remove::<ActiveSim>()
.insert(StateSaved);
assert!(world.get::<StateSaved>(entity).is_some());
assert!(world.get::<ActiveSim>(entity).is_none());
}
// --- LastInteraction and ScopeTag ---
#[test]
fn last_interaction_records_tick() {
let mut world = World::new();
let entity = world.spawn(LastInteraction { tick: 42 }).id();
let interaction = world.get::<LastInteraction>(entity).unwrap();
assert_eq!(interaction.tick, 42);
}
#[test]
fn last_interaction_tick_can_be_updated() {
let mut world = World::new();
let entity = world.spawn(LastInteraction { tick: 1 }).id();
world.entity_mut(entity).insert(LastInteraction { tick: 100 });
let interaction = world.get::<LastInteraction>(entity).unwrap();
assert_eq!(interaction.tick, 100);
}
#[test]
fn scope_tag_neighborhood_kind() {
let mut world = World::new();
let entity = world
.spawn(ScopeTag {
tags: vec![ScopeKind::Neighborhood],
})
.id();
let tag = world.get::<ScopeTag>(entity).unwrap();
assert!(tag.tags.contains(&ScopeKind::Neighborhood));
assert!(!tag.tags.contains(&ScopeKind::ActiveQuest));
}
#[test]
fn scope_tag_multiple_kinds() {
let entity = ScopeTag {
tags: vec![
ScopeKind::Neighborhood,
ScopeKind::Colleague,
ScopeKind::KnownContact,
],
};
assert_eq!(entity.tags.len(), 3);
assert!(entity.tags.contains(&ScopeKind::Colleague));
assert!(entity.tags.contains(&ScopeKind::KnownContact));
assert!(!entity.tags.contains(&ScopeKind::ActiveQuest));
}
// --- TierPlugin smoke test ---
#[test]
fn tier_plugin_builds_without_panic() {
let mut app = bevy_app::App::new();
app.add_plugins(TierPlugin);
// Just verifying it doesn't panic on build
}
}