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:
@@ -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 10–20 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user