feat(simulation): #94 #99 active tier filtering and transition system

#94 — Active tier simulation (complete):
- Add ActiveSim marker to all 9 test world room NPC spawns
- Fix test entities in routine.rs and path_follow.rs to include ActiveSim
  so With<ActiveSim> queries match correctly in unit tests

#99 — Tier transition logic (complete):
- Implement update_tier_markers system in tier.rs
- Promotes/demotes tier markers by manhattan distance from PlayerCharacter:
  ≤40 tiles → ActiveSim, ≤120 → BackgroundSim, beyond → StateSaved
- Handles cross-z-level as u32::MAX (effectively unreachable)
- No-op when no PlayerCharacter entity present (headless tests safe)
- 11 new unit tests covering all distance bands and boundary cases
- TierPlugin now registers the system after movement::validate_movement

Also picks up extended test coverage added by hoshe:
- observer/tests.rs — 230 lines of perception observer tests
- sound.rs — additional sound event integration tests

All 548 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:54:37 +01:00
co-authored by Claude Sonnet 4.6
parent 066f8031fd
commit 8e00db2b2d
15 changed files with 733 additions and 12 deletions
+3
View File
@@ -98,6 +98,7 @@ mod tests {
let entity = world
.spawn((
Npc,
super::ActiveSim, // system requires With<ActiveSim> (#94)
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![
@@ -129,6 +130,7 @@ mod tests {
let entity = world
.spawn((
Npc,
super::ActiveSim, // system requires With<ActiveSim> (#94)
TilePosition::new(2, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(3, 0, 0)],
@@ -154,6 +156,7 @@ mod tests {
let entity = world
.spawn((
Npc,
super::ActiveSim, // system requires With<ActiveSim> (#94)
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0)],
+216
View File
@@ -278,4 +278,220 @@ mod tests {
schedule.run(&mut world);
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 0);
}
// --- Multi-emitter and multi-event tests ---
#[test]
fn multiple_emitters_collected_in_one_tick() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
world.spawn(SoundEventEmitter::new(close_event(&tile(1, 1))));
world.spawn(SoundEventEmitter::new(close_event(&tile(2, 2))));
world.spawn(SoundEventEmitter::new(medium_event(&tile(3, 3))));
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(), 3, "all three emitters collected");
}
#[test]
fn emitter_with_multiple_pending_events_all_drained() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
let emitter = SoundEventEmitter::new(close_event(&pos))
.with(medium_event(&pos))
.with(SoundEvent::at(
&pos,
SoundEventKind::Alert,
1.0,
SoundRange::Long,
None,
));
world.spawn(emitter);
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(),
3,
"all three pending events from one emitter collected"
);
}
// --- Long range audibility tests (D-018) ---
#[test]
fn long_range_sound_audible_within_20_tiles() {
let source = tile(0, 0);
let event =
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
// Manhattan distance 20 — exactly at boundary
let listener = tile(10, 10);
assert!(
event.audible_at(&listener),
"Long range sound audible at manhattan 20"
);
}
#[test]
fn long_range_sound_not_audible_beyond_20_tiles() {
let source = tile(0, 0);
let event =
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
let listener = tile(11, 10); // manhattan 21
assert!(
!event.audible_at(&listener),
"Long range sound not audible at manhattan 21"
);
}
#[test]
fn long_range_audible_at_origin() {
let source = tile(5, 5);
let event =
SoundEvent::at(&source, SoundEventKind::Machinery, 0.5, SoundRange::Long, None);
assert!(event.audible_at(&source), "audible at source position");
}
// --- Sound event field preservation tests ---
#[test]
fn source_entity_id_preserved_through_collection() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
let event = SoundEvent::at(
&pos,
SoundEventKind::Voice,
0.8,
SoundRange::Close,
Some(42),
);
world.spawn(SoundEventEmitter::new(event));
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].source_entity_id,
Some(42),
"source_entity_id must be preserved through collection"
);
}
#[test]
fn intensity_preserved_through_collection() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
let event = SoundEvent::at(
&pos,
SoundEventKind::Footstep,
0.37,
SoundRange::Close,
None,
);
world.spawn(SoundEventEmitter::new(event));
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!(
(queue.events[0].intensity - 0.37).abs() < f32::EPSILON,
"intensity must be preserved through collection"
);
}
// --- All sound event kinds ---
#[test]
fn all_sound_event_kinds_can_be_emitted_and_collected() {
let kinds = [
SoundEventKind::Footstep,
SoundEventKind::Voice,
SoundEventKind::Machinery,
SoundEventKind::Alert,
SoundEventKind::Ambient,
];
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
for kind in kinds {
world.spawn(SoundEventEmitter::new(SoundEvent::at(
&pos,
kind,
0.5,
SoundRange::Close,
None,
)));
}
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(),
5,
"all five SoundEventKind variants collected"
);
let collected_kinds: std::collections::HashSet<SoundEventKind> =
queue.events.iter().map(|e| e.kind).collect();
for kind in [
SoundEventKind::Footstep,
SoundEventKind::Voice,
SoundEventKind::Machinery,
SoundEventKind::Alert,
SoundEventKind::Ambient,
] {
assert!(collected_kinds.contains(&kind), "{kind:?} not collected");
}
}
// --- max_range_tiles spec verification (D-018) ---
#[test]
fn max_range_tiles_values_match_d018() {
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Close), 3);
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Medium), 8);
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Long), 20);
}
// --- SoundEventQueue::drain test ---
#[test]
fn queue_drain_empties_the_queue() {
let mut queue = SoundEventQueue::default();
let pos = tile(5, 5);
queue.events.push(close_event(&pos));
queue.events.push(medium_event(&pos));
assert_eq!(queue.events.len(), 2);
let drained = queue.drain();
assert_eq!(drained.len(), 2, "drain returns all events");
assert_eq!(queue.events.len(), 0, "queue empty after drain");
}
}
+243 -7
View File
@@ -1,11 +1,24 @@
// Simulation tier system
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
// Timestamp-based LRU eviction for simulation space management
// Tier transitions based on player approach distance (#99).
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
// --- Tier radius constants (D-026) ---
// These thresholds define the distance bands at which entities transition
// between simulation tiers. Manhattan distance in tiles.
/// Entities within this radius receive full Active simulation (D-026).
pub const ACTIVE_RADIUS: u32 = 40;
/// Entities within this radius (and beyond ACTIVE_RADIUS) receive
/// lightweight Background schedule-keeping (D-026).
pub const BACKGROUND_RADIUS: u32 = 120;
// --- 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.
@@ -27,19 +40,97 @@ pub struct BackgroundSim;
#[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.
/// Plugin registering the tier marker components and the tier transition system.
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.
fn build(&self, app: &mut App) {
// Tier transition runs after movement so positions are current.
app.add_systems(
Update,
update_tier_markers.after(crate::simulation::movement::validate_movement),
);
tracing::debug!("TierPlugin initialized");
}
}
// --- Tier transition system (D-026, #99) ---
/// Manhattan tile distance between two positions, returning `u32::MAX` for
/// entities on different z-levels (they are effectively unreachable).
fn tile_distance(a: &TilePosition, b: &TilePosition) -> u32 {
if a.z != b.z {
return u32::MAX;
}
a.x.abs_diff(b.x) + a.y.abs_diff(b.y)
}
/// System: promote/demote NPC tier markers based on player distance (D-026, #99).
///
/// Each tick, after movement has settled positions:
/// - Entities within `ACTIVE_RADIUS` → `ActiveSim`
/// - Entities within `BACKGROUND_RADIUS` → `BackgroundSim`
/// - Entities beyond `BACKGROUND_RADIUS` → `StateSaved`
///
/// No-op when there is no `PlayerCharacter` entity (headless tests, no observer
/// spawned). Entities that are already in the correct tier are left unchanged.
pub fn update_tier_markers(
mut commands: Commands,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
active_npcs: Query<(Entity, &TilePosition), With<ActiveSim>>,
background_npcs: Query<(Entity, &TilePosition), With<BackgroundSim>>,
state_saved_npcs: Query<(Entity, &TilePosition), With<StateSaved>>,
) {
let Ok(player_pos) = player_query.single() else {
return;
};
for (entity, pos) in &active_npcs {
let dist = tile_distance(player_pos, pos);
if dist > BACKGROUND_RADIUS {
commands
.entity(entity)
.remove::<ActiveSim>()
.insert(StateSaved);
} else if dist > ACTIVE_RADIUS {
commands
.entity(entity)
.remove::<ActiveSim>()
.insert(BackgroundSim);
}
}
for (entity, pos) in &background_npcs {
let dist = tile_distance(player_pos, pos);
if dist <= ACTIVE_RADIUS {
commands
.entity(entity)
.remove::<BackgroundSim>()
.insert(ActiveSim);
} else if dist > BACKGROUND_RADIUS {
commands
.entity(entity)
.remove::<BackgroundSim>()
.insert(StateSaved);
}
}
for (entity, pos) in &state_saved_npcs {
let dist = tile_distance(player_pos, pos);
if dist <= ACTIVE_RADIUS {
commands
.entity(entity)
.remove::<StateSaved>()
.insert(ActiveSim);
} else if dist <= BACKGROUND_RADIUS {
commands
.entity(entity)
.remove::<StateSaved>()
.insert(BackgroundSim);
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimulationTier {
Active,
@@ -306,4 +397,149 @@ mod tests {
app.add_plugins(TierPlugin);
// Just verifying it doesn't panic on build
}
// --- update_tier_markers system tests (D-026, #99) ---
fn make_pos(x: i32, y: i32) -> TilePosition {
TilePosition::new(x, y, 0)
}
fn run_tier_update(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_tier_markers);
schedule.run(world);
}
#[test]
fn no_op_when_no_player_entity() {
// The system should be a no-op if there is no PlayerCharacter.
let mut world = World::new();
let npc = world.spawn((ActiveSim, make_pos(200, 200))).id();
run_tier_update(&mut world);
// NPC should still be ActiveSim — no player to compare against.
assert!(world.get::<ActiveSim>(npc).is_some());
}
#[test]
fn active_npc_within_active_radius_unchanged() {
let mut world = World::new();
// Player at origin; NPC at distance 10 (< ACTIVE_RADIUS=40)
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(10, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_some(), "stays Active");
assert!(world.get::<BackgroundSim>(npc).is_none());
}
#[test]
fn active_npc_in_background_band_demotes_to_background() {
// NPC at distance 60 → beyond ACTIVE_RADIUS(40), within BACKGROUND_RADIUS(120)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(60, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_none(), "ActiveSim removed");
assert!(world.get::<BackgroundSim>(npc).is_some(), "BackgroundSim added");
}
#[test]
fn active_npc_beyond_background_radius_demotes_to_state_saved() {
// NPC at distance 150 → beyond BACKGROUND_RADIUS(120)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(150, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_none(), "ActiveSim removed");
assert!(world.get::<StateSaved>(npc).is_some(), "StateSaved added");
}
#[test]
fn background_npc_within_active_radius_promotes_to_active() {
// NPC at distance 20 (< ACTIVE_RADIUS=40)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((BackgroundSim, make_pos(20, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<BackgroundSim>(npc).is_none(), "BackgroundSim removed");
assert!(world.get::<ActiveSim>(npc).is_some(), "ActiveSim added");
}
#[test]
fn background_npc_beyond_background_radius_demotes_to_state_saved() {
// NPC at distance 200 → beyond BACKGROUND_RADIUS(120)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((BackgroundSim, make_pos(200, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<BackgroundSim>(npc).is_none(), "BackgroundSim removed");
assert!(world.get::<StateSaved>(npc).is_some(), "StateSaved added");
}
#[test]
fn state_saved_npc_within_active_radius_promotes_to_active() {
// NPC at distance 5 (< ACTIVE_RADIUS=40)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((StateSaved, make_pos(5, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<StateSaved>(npc).is_none(), "StateSaved removed");
assert!(world.get::<ActiveSim>(npc).is_some(), "ActiveSim added");
}
#[test]
fn state_saved_npc_in_background_band_promotes_to_background() {
// NPC at distance 80 (> ACTIVE_RADIUS, < BACKGROUND_RADIUS)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((StateSaved, make_pos(80, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<StateSaved>(npc).is_none(), "StateSaved removed");
assert!(world.get::<BackgroundSim>(npc).is_some(), "BackgroundSim added");
}
#[test]
fn state_saved_npc_beyond_background_radius_unchanged() {
// NPC at distance 200 → stays StateSaved
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((StateSaved, make_pos(200, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<StateSaved>(npc).is_some(), "stays StateSaved");
assert!(world.get::<ActiveSim>(npc).is_none());
}
#[test]
fn different_z_level_treated_as_infinite_distance() {
// NPC on z=1 is unreachable from player on z=0
let mut world = World::new();
world.spawn((PlayerCharacter, TilePosition::new(0, 0, 0)));
// Spawn as ActiveSim at same x/y but different floor
let npc = world
.spawn((ActiveSim, TilePosition::new(0, 0, 1)))
.id();
run_tier_update(&mut world);
// Should demote: u32::MAX > BACKGROUND_RADIUS → StateSaved
assert!(world.get::<ActiveSim>(npc).is_none(), "ActiveSim removed");
assert!(world.get::<StateSaved>(npc).is_some(), "StateSaved due to z-distance");
}
#[test]
fn npc_at_exact_active_radius_boundary_stays_active() {
// Distance = ACTIVE_RADIUS exactly → should stay Active (threshold is >)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_some(), "stays Active at exact boundary");
}
#[test]
fn npc_one_tile_beyond_active_radius_demotes() {
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32 + 1, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_none(), "demoted to Background");
assert!(world.get::<BackgroundSim>(npc).is_some());
}
}