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>
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
// Simulation tier system
|
||
// 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,
|
||
Background,
|
||
StateSaved,
|
||
Ungenerated,
|
||
}
|
||
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct LastInteraction {
|
||
pub tick: u64,
|
||
}
|
||
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct ScopeTag {
|
||
pub tags: Vec<ScopeKind>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum ScopeKind {
|
||
Neighborhood,
|
||
ActiveQuest,
|
||
Colleague,
|
||
KnownContact,
|
||
}
|
||
|
||
#[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 = World::new();
|
||
let entity = world.spawn(SimulationTier::Active).id();
|
||
assert_eq!(
|
||
*world.get::<SimulationTier>(entity).unwrap(),
|
||
SimulationTier::Active
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tier_can_transition() {
|
||
let mut world = World::new();
|
||
let entity = world.spawn(SimulationTier::Active).id();
|
||
world.entity_mut(entity).insert(SimulationTier::Background);
|
||
assert_eq!(
|
||
*world.get::<SimulationTier>(entity).unwrap(),
|
||
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
|
||
}
|
||
}
|