Files
settled-reach/server/src/simulation/tier.rs
T
jpmschweitzerandClaude Opus 4.6 066f8031fd 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>
2026-02-19 13:33:23 +01:00

310 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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,
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
}
}