// 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 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, 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, } #[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::(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::(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 / With / With // 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::>(); let results: Vec = 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::>(); let results: Vec = 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::>(); let results: Vec = 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::>(); let mut results: Vec = 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::>(); let results: Vec = 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::() .insert(ActiveSim); assert!(world.get::(entity).is_some(), "ActiveSim added"); assert!( world.get::(entity).is_none(), "StateSaved removed" ); // Must appear in ActiveSim query after promotion let mut query = world.query_filtered::>(); let results: Vec = 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::() .insert(BackgroundSim); assert!( world.get::(entity).is_some(), "BackgroundSim added" ); assert!(world.get::(entity).is_none(), "ActiveSim removed"); // Must NOT appear in ActiveSim query after demotion let mut active_query = world.query_filtered::>(); 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::() .insert(StateSaved); assert!(world.get::(entity).is_some()); assert!(world.get::(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::(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::(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::(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 } }