#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:
@@ -94,6 +94,7 @@ mod tests {
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::ActiveSim, // system requires With<ActiveSim> (#94)
|
||||
TilePosition::new(5, 5, 0), // Not at afternoon location
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
@@ -216,6 +217,7 @@ mod tests {
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::ActiveSim, // system requires With<ActiveSim> (#94)
|
||||
TilePosition::new(20, 20, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
|
||||
@@ -10,7 +10,8 @@ use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::graph::filter_by_access;
|
||||
use crate::knowledge::types::{AccessRule, KnowledgeState};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
@@ -80,6 +81,7 @@ pub fn compute_observer_snapshot(
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
Option<&AccessRule>,
|
||||
)>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
@@ -121,8 +123,13 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve observer's StableId for component-level access control (#139, D-010)
|
||||
let observer_stable_id = registry
|
||||
.to_stable(observer_entity)
|
||||
.unwrap_or(StableId(0));
|
||||
|
||||
let (mut entities, visible_ids, blocked_entities) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, observer_stable_id, &all_entities);
|
||||
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
@@ -237,18 +244,20 @@ fn filter_visible_entities(
|
||||
geometry: &VisibilityGeometry,
|
||||
registry: &EntityRegistry,
|
||||
observer_kg: &KnowledgeGraph,
|
||||
observer_stable_id: StableId,
|
||||
all_entities: &Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
Option<&AccessRule>,
|
||||
)>,
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() {
|
||||
if pos.z != geometry.observer_z {
|
||||
continue;
|
||||
}
|
||||
@@ -289,7 +298,16 @@ fn filter_visible_entities(
|
||||
let relationship = if is_player.is_some() {
|
||||
RelationshipState::Known // Self
|
||||
} else if let Some(stable_id) = registry.to_stable(entity) {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
// D-010 principle 2: check access control before exposing relationship (#139)
|
||||
let access_granted = match access_rule {
|
||||
Some(rule) => filter_by_access(observer_stable_id, stable_id, &rule.0, observer_kg),
|
||||
None => true, // No AccessRule → Public (default)
|
||||
};
|
||||
if access_granted {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown // Access denied — redact relationship data
|
||||
}
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::types::{KnowledgeState, ObserverAccess};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
@@ -2178,6 +2178,234 @@ fn different_z_level_not_in_blocked_entities() {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Component-level access control tests (#139, D-010 principle 2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn access_rule_owner_only_redacts_relationship() {
|
||||
// THE critical negative test for #139: NPC with OwnerOnly access rule
|
||||
// is physically visible (in LOS) but relationship data is redacted.
|
||||
use crate::knowledge::types::AccessRule;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC with OwnerOnly access rule — only the NPC itself can read its data
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::OwnerOnly),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows NPC as Hostile — but access should be denied
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// NPC should be visible (physically in LOS)
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible even with OwnerOnly access");
|
||||
|
||||
// But relationship must be redacted to Unknown (access denied)
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Unknown,
|
||||
"OwnerOnly access should redact relationship to Unknown for non-owner observer"
|
||||
);
|
||||
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_rule_knowledge_gated_passes_with_matching_fact() {
|
||||
// Positive test: observer has the required fact, relationship visible.
|
||||
use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource};
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC gated on a specific fact
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::KnowledgeGated("contraband.ring_exists".into())),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows the required fact AND has a relationship with the NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
kg.facts.insert(
|
||||
FactId("contraband.ring_exists".into()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
|
||||
// Observer has the required fact — relationship should be visible
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::PersonOfInterest,
|
||||
"KnowledgeGated access should pass when observer has the required fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_rule_knowledge_gated_redacts_without_fact() {
|
||||
// Negative test: observer lacks the required fact, relationship redacted.
|
||||
use crate::knowledge::types::AccessRule;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC gated on a fact the observer doesn't have
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
AccessRule(ObserverAccess::KnowledgeGated("conspiracy.mastermind".into())),
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player has relationship but NOT the required fact
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible (in LOS)");
|
||||
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Unknown,
|
||||
"KnowledgeGated access should redact relationship when observer lacks the fact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_access_rule_defaults_to_public() {
|
||||
// Existing behavior: entities without AccessRule are fully visible.
|
||||
// This is a regression guard — existing tests also cover this implicitly.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC with NO AccessRule component
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
|
||||
assert_eq!(
|
||||
npc_entity.relationship,
|
||||
RelationshipState::Friendly,
|
||||
"No AccessRule should default to Public — relationship fully visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_sorted_ascending() {
|
||||
// Multiple blocked NPCs should appear in ascending entity_id order
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 84;
|
||||
@@ -62,6 +63,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 80;
|
||||
@@ -57,6 +58,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 36;
|
||||
@@ -90,6 +91,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 74;
|
||||
@@ -65,6 +66,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 28;
|
||||
@@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::inventory::ItemName;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 2;
|
||||
@@ -66,6 +67,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
npc_pos,
|
||||
Want {
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 74;
|
||||
@@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 42;
|
||||
@@ -29,6 +30,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
@@ -51,6 +52,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
npc_pos,
|
||||
Want {
|
||||
|
||||
Reference in New Issue
Block a user