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:
@@ -15,6 +15,7 @@ pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod rng;
|
||||
pub mod sound;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
@@ -25,11 +26,15 @@ pub struct SimulationPlugin;
|
||||
|
||||
impl Plugin for SimulationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Tier marker components (D-026) — must register before behavior systems
|
||||
app.add_plugins(tier::TierPlugin);
|
||||
|
||||
// Initialize core simulation resources
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.init_resource::<sound::SoundEventQueue>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -42,6 +47,9 @@ impl Plugin for SimulationPlugin {
|
||||
contraband::check_contraband_scan
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
sound::collect_sound_events
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ use bevy_ecs::prelude::*;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::MoveIntent;
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Movement speed component. Controls ticks between path steps.
|
||||
/// Default: 1 step per tick. Higher values = slower movement.
|
||||
@@ -48,9 +49,13 @@ impl MovementSpeed {
|
||||
|
||||
/// System: NPC entities with ComputedPath advance along their path.
|
||||
/// Creates MoveIntent for the next step. Removes ComputedPath when complete.
|
||||
///
|
||||
/// Scoped to `ActiveSim` NPCs — only entities in the Active tier execute
|
||||
/// path movement each tick (D-026, #94). Background/StateSaved NPCs do not
|
||||
/// process path steps.
|
||||
pub fn follow_paths(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With<Npc>>,
|
||||
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With<Npc>, With<ActiveSim>)>,
|
||||
) {
|
||||
for (entity, mut path, speed_opt) in query.iter_mut() {
|
||||
if let Some(mut speed) = speed_opt {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Sound event system — server side (#124)
|
||||
// Implements D-038: SoundEventEmitter → SoundEventQueue → ObserverSnapshot.
|
||||
// Event-driven: emitters post events each tick, queue fans out to subscribers.
|
||||
//
|
||||
// Range model per D-018:
|
||||
// Close ≤ 3 tiles — always heard, spatial positioning
|
||||
// Medium ≤ 8 tiles — heard if not obstructed
|
||||
// Long ≤ 20 tiles — heard in quiet conditions
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
// --- Sound event type taxonomy ---
|
||||
|
||||
/// Typed sound event categories.
|
||||
/// Client maps each kind to its audio asset registry key (D-038).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SoundEventKind {
|
||||
/// Footstep — emitted by moving entities. Intensity varies by stance.
|
||||
Footstep,
|
||||
/// Voice — dialogue, monologue, NPC speech.
|
||||
Voice,
|
||||
/// Machinery — terminals, doors, consoles, mechanical activity.
|
||||
Machinery,
|
||||
/// Alert — alarms, warnings, emergency signals.
|
||||
Alert,
|
||||
/// Ambient — location atmosphere, background environment.
|
||||
Ambient,
|
||||
}
|
||||
|
||||
// --- Sound event ---
|
||||
|
||||
/// A single sound event emitted this tick.
|
||||
/// Produced by `SoundEventEmitter`, collected into `SoundEventQueue`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SoundEvent {
|
||||
/// What kind of sound this is.
|
||||
pub kind: SoundEventKind,
|
||||
/// World position where the sound originates (tile centre).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: i32,
|
||||
/// Normalized intensity in [0.0, 1.0]. Drives volume at the client.
|
||||
pub intensity: f32,
|
||||
/// How far this sound propagates (D-018 three-range model).
|
||||
pub range: SoundRange,
|
||||
/// Stable entity ID of the source, if any.
|
||||
/// None for procedural or world-generated events (e.g. Ambient).
|
||||
pub source_entity_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl SoundEvent {
|
||||
/// Create an event at a tile position.
|
||||
pub fn at(
|
||||
pos: &TilePosition,
|
||||
kind: SoundEventKind,
|
||||
intensity: f32,
|
||||
range: SoundRange,
|
||||
source_entity_id: Option<u64>,
|
||||
) -> Self {
|
||||
let (x, y, z) = pos.to_render_coords();
|
||||
Self {
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
intensity,
|
||||
range,
|
||||
source_entity_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Manhattan-distance range ceiling in tiles for each category (D-018).
|
||||
pub fn max_range_tiles(range: SoundRange) -> u32 {
|
||||
match range {
|
||||
SoundRange::Close => 3,
|
||||
SoundRange::Medium => 8,
|
||||
SoundRange::Long => 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this sound is audible at `listener_pos`.
|
||||
/// Simple tile-distance check; occlusion is a future concern (D-018 note).
|
||||
pub fn audible_at(&self, listener_pos: &TilePosition) -> bool {
|
||||
let ceil = Self::max_range_tiles(self.range);
|
||||
let dx = (self.x.floor() as i32).abs_diff(listener_pos.x);
|
||||
let dy = (self.y.floor() as i32).abs_diff(listener_pos.y);
|
||||
let dz = (self.z).abs_diff(listener_pos.z);
|
||||
dz == 0 && dx + dy <= ceil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Emitter component ---
|
||||
|
||||
/// Component: entity emits sound events this tick.
|
||||
///
|
||||
/// Attached transiently — systems add this component to entities when they
|
||||
/// produce sound (step taken, line spoken, door opened). The `collect_sound_events`
|
||||
/// system harvests all emitters each tick, drains their pending events into
|
||||
/// `SoundEventQueue`, and removes the component.
|
||||
///
|
||||
/// Usage pattern (illustrative):
|
||||
/// ```ignore
|
||||
/// commands.entity(npc).insert(SoundEventEmitter::new(
|
||||
/// SoundEvent::at(&pos, SoundEventKind::Footstep, 0.6, SoundRange::Close, Some(npc_id))
|
||||
/// ));
|
||||
/// ```
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct SoundEventEmitter {
|
||||
pub pending: Vec<SoundEvent>,
|
||||
}
|
||||
|
||||
impl SoundEventEmitter {
|
||||
pub fn new(event: SoundEvent) -> Self {
|
||||
Self {
|
||||
pending: vec![event],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with(mut self, event: SoundEvent) -> Self {
|
||||
self.pending.push(event);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// --- Queue resource ---
|
||||
|
||||
/// Resource: sound events produced this tick.
|
||||
///
|
||||
/// `collect_sound_events` drains all `SoundEventEmitter` components into this
|
||||
/// resource each tick. Consumers (observer snapshot builder, NPC awareness
|
||||
/// system) read from the queue. Cleared at the top of each tick.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SoundEventQueue {
|
||||
pub events: Vec<SoundEvent>,
|
||||
}
|
||||
|
||||
impl SoundEventQueue {
|
||||
/// Drain all queued events, leaving the queue empty.
|
||||
pub fn drain(&mut self) -> Vec<SoundEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Return events audible at a listener position, without draining.
|
||||
pub fn audible_at<'a>(&'a self, pos: &'a TilePosition) -> impl Iterator<Item = &'a SoundEvent> {
|
||||
self.events.iter().filter(move |e| e.audible_at(pos))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collection system ---
|
||||
|
||||
/// System: harvest SoundEventEmitters → SoundEventQueue.
|
||||
///
|
||||
/// Runs each tick after movement/monologue/dialogue systems have fired.
|
||||
/// Removes the emitter component after draining. Ordering: after movement,
|
||||
/// before `compute_observer_snapshot`.
|
||||
pub fn collect_sound_events(
|
||||
mut commands: Commands,
|
||||
mut queue: ResMut<SoundEventQueue>,
|
||||
mut emitters: Query<(Entity, &mut SoundEventEmitter)>,
|
||||
) {
|
||||
queue.events.clear();
|
||||
for (entity, mut emitter) in emitters.iter_mut() {
|
||||
queue.events.extend(emitter.pending.drain(..));
|
||||
commands.entity(entity).remove::<SoundEventEmitter>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tile(x: i32, y: i32) -> TilePosition {
|
||||
TilePosition::new(x, y, 0)
|
||||
}
|
||||
|
||||
fn close_event(pos: &TilePosition) -> SoundEvent {
|
||||
SoundEvent::at(pos, SoundEventKind::Footstep, 0.5, SoundRange::Close, None)
|
||||
}
|
||||
|
||||
fn medium_event(pos: &TilePosition) -> SoundEvent {
|
||||
SoundEvent::at(pos, SoundEventKind::Voice, 0.7, SoundRange::Medium, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_range_audible_within_3_tiles() {
|
||||
let source = tile(5, 5);
|
||||
let event = close_event(&source);
|
||||
assert!(event.audible_at(&tile(5, 5)), "audible at origin");
|
||||
assert!(event.audible_at(&tile(5, 8)), "audible at distance 3");
|
||||
assert!(!event.audible_at(&tile(5, 9)), "not audible at distance 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_range_audible_within_8_tiles() {
|
||||
let source = tile(0, 0);
|
||||
let event = medium_event(&source);
|
||||
assert!(event.audible_at(&tile(4, 4)), "audible at manhattan 8");
|
||||
assert!(!event.audible_at(&tile(5, 4)), "not audible at manhattan 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_audible() {
|
||||
let source = tile(5, 5);
|
||||
let event = close_event(&source);
|
||||
let above = TilePosition::new(5, 5, 1);
|
||||
assert!(!event.audible_at(&above), "different z not audible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_system_drains_emitters_into_queue() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
|
||||
let pos = tile(5, 5);
|
||||
let _entity = world
|
||||
.spawn(SoundEventEmitter::new(close_event(&pos)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<SoundEventQueue>();
|
||||
assert_eq!(queue.events.len(), 1);
|
||||
assert_eq!(queue.events[0].kind, SoundEventKind::Footstep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_system_removes_emitter_component() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
|
||||
let entity = world
|
||||
.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<SoundEventEmitter>(entity).is_none(),
|
||||
"emitter component should be removed after collection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_audible_at_filters_by_range() {
|
||||
let mut queue = SoundEventQueue::default();
|
||||
let close_pos = tile(5, 5);
|
||||
let far_pos = tile(20, 20);
|
||||
queue.events.push(close_event(&close_pos));
|
||||
queue.events.push(close_event(&far_pos));
|
||||
|
||||
let listener = tile(5, 6);
|
||||
let heard: Vec<&SoundEvent> = queue.audible_at(&listener).collect();
|
||||
assert_eq!(heard.len(), 1, "only close sound is audible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_clears_each_tick() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SoundEventQueue::default());
|
||||
world.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(collect_sound_events);
|
||||
|
||||
// Tick 1: event collected
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 1);
|
||||
|
||||
// Tick 2: no new emitters → queue cleared
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -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