Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1409 lines
50 KiB
Rust
1409 lines
50 KiB
Rust
// Simulation tier system
|
||
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
|
||
// Tier transitions based on player approach distance (#99).
|
||
// Scope tag system: NPCs with active scope tags stay pinned to ActiveSim (#98).
|
||
// Timestamp-based eviction: LRU eviction when ActiveSim exceeds capacity (#97).
|
||
|
||
use std::cmp::Reverse;
|
||
use std::collections::{BTreeSet, BinaryHeap};
|
||
|
||
use bevy_app::prelude::*;
|
||
use bevy_ecs::prelude::*;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::knowledge::graph::KnowledgeGraph;
|
||
use crate::knowledge::registry::StableEntityId;
|
||
use crate::knowledge::types::KnowledgeConfidence;
|
||
use crate::npc::relationships::RelationshipGraph;
|
||
use crate::npc::{Npc, RelationshipKind};
|
||
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.
|
||
|
||
/// 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;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Eviction system (D-026, #97)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Maximum number of entities in `ActiveSim` before LRU eviction kicks in (D-026).
|
||
pub const ACTIVE_SIM_CAPACITY: usize = 80;
|
||
|
||
/// Tracks the tick at which the player last interacted with or observed an NPC (#97).
|
||
/// Updated by `update_last_interaction_tick` when an NPC is in the player's LOS.
|
||
/// Used by `evict_excess_active` as the LRU sort key.
|
||
#[derive(Component, Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||
pub struct LastInteractionTick(pub u64);
|
||
|
||
/// Tracks current `ActiveSim` entity count vs. capacity (#97, D-026).
|
||
/// Updated each tick by `evict_excess_active`.
|
||
#[derive(Resource, Debug, Clone)]
|
||
pub struct SimSpacePressure {
|
||
/// Number of entities in `ActiveSim` at the start of the current tick's eviction pass.
|
||
///
|
||
/// Set by `evict_excess_active` *before* any evictions run. Eviction commands are
|
||
/// deferred (applied after the system), so `active_count` reflects the pre-eviction
|
||
/// count, not the post-eviction count. Consumers (e.g., HUD pressure display) should
|
||
/// treat this as the high-water mark for the tick.
|
||
pub active_count: usize,
|
||
/// Capacity ceiling.
|
||
pub capacity: usize,
|
||
}
|
||
|
||
impl Default for SimSpacePressure {
|
||
fn default() -> Self {
|
||
Self {
|
||
active_count: 0,
|
||
capacity: ACTIVE_SIM_CAPACITY,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scope tag system (D-026, #98)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Scope tag kinds: reasons why an NPC stays pinned to `ActiveSim` (D-026).
|
||
///
|
||
/// Four variants track distinct reasons for pinning. An NPC may have multiple
|
||
/// reasons simultaneously — all are tracked in `ScopeTag`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
pub enum ScopeTagKind {
|
||
/// NPC is in the player's immediate neighborhood.
|
||
/// Set at session start for NPCs within `ACTIVE_RADIUS`. Managed by
|
||
/// `assign_neighborhood_tags_on_start` (deferred: future sprint).
|
||
Neighborhood,
|
||
/// NPC is involved in an active quest.
|
||
/// Reserved for the quest system (deferred: future sprint).
|
||
ActiveQuest,
|
||
/// NPC has a `Friend` or `Colleague` relationship with the player character.
|
||
/// Assigned by `assign_scope_tags` each tick from `RelationshipGraph`.
|
||
Colleague,
|
||
/// NPC is known to the player with confidence >= `KnowsOf`.
|
||
/// Assigned by `assign_scope_tags` each tick from player `KnowledgeGraph`.
|
||
KnownContact,
|
||
}
|
||
|
||
/// Scope tag component: which scope tags currently apply to this NPC (D-026).
|
||
///
|
||
/// NPCs carrying at least one scope tag are kept in `ActiveSim` regardless of
|
||
/// distance or LRU eviction pressure. `ScopePinned` is the eviction guard;
|
||
/// this component is the source of truth.
|
||
///
|
||
/// Assignment:
|
||
/// - `KnownContact` and `Colleague`: recomputed by `assign_scope_tags` each tick.
|
||
/// - `Neighborhood`: set at session start (see `ScopeTagKind::Neighborhood`).
|
||
/// - `ActiveQuest`: reserved for future quest system.
|
||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct ScopeTag {
|
||
pub tags: BTreeSet<ScopeTagKind>,
|
||
}
|
||
|
||
impl ScopeTag {
|
||
/// Create a `ScopeTag` with a single initial kind.
|
||
pub fn with(kind: ScopeTagKind) -> Self {
|
||
let mut tags = BTreeSet::new();
|
||
tags.insert(kind);
|
||
Self { tags }
|
||
}
|
||
|
||
/// Add a scope tag kind.
|
||
pub fn add(&mut self, kind: ScopeTagKind) {
|
||
self.tags.insert(kind);
|
||
}
|
||
|
||
/// Remove a scope tag kind.
|
||
pub fn remove(&mut self, kind: ScopeTagKind) {
|
||
self.tags.remove(&kind);
|
||
}
|
||
|
||
/// True if this NPC carries at least one scope tag.
|
||
pub fn is_pinned(&self) -> bool {
|
||
!self.tags.is_empty()
|
||
}
|
||
|
||
/// True if this specific kind is present.
|
||
pub fn contains(&self, kind: ScopeTagKind) -> bool {
|
||
self.tags.contains(&kind)
|
||
}
|
||
}
|
||
|
||
/// Marker component: this NPC is scope-pinned — the eviction system must skip it.
|
||
///
|
||
/// Kept in sync with `ScopeTag` by `sync_scope_pins`. Always use `ScopeTag`
|
||
/// as the source of truth; treat `ScopePinned` as a query-optimisation cache.
|
||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||
pub struct ScopePinned;
|
||
|
||
/// Plugin registering the tier marker components and the tier transition system.
|
||
pub struct TierPlugin;
|
||
|
||
impl Plugin for TierPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<SimSpacePressure>();
|
||
|
||
// Tier transition runs after movement so positions are current.
|
||
app.add_systems(
|
||
Update,
|
||
update_tier_markers.after(crate::simulation::movement::validate_movement),
|
||
);
|
||
// Scope tag assignment runs each tick to keep KnownContact / Colleague current.
|
||
// Must run before sync_scope_pins so pins are correct before eviction checks.
|
||
// Eviction runs after scope pins are synced (respects ScopePinned).
|
||
// LastInteractionTick update runs after visibility geometry.
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
assign_scope_tags,
|
||
sync_scope_pins.after(assign_scope_tags),
|
||
update_last_interaction_tick
|
||
.after(crate::perception::observer::compute_visibility_geometry),
|
||
evict_excess_active
|
||
.after(sync_scope_pins)
|
||
.after(update_tier_markers),
|
||
),
|
||
);
|
||
tracing::debug!("TierPlugin initialized");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scope tag systems (D-026, #98)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// System: assign `KnownContact` and `Colleague` scope tags from player epistemics.
|
||
///
|
||
/// Runs each tick. Clears and recomputes `KnownContact` and `Colleague` tags for all
|
||
/// NPCs based on:
|
||
/// - `KnownContact`: player `KnowledgeGraph` has an entry for this NPC with
|
||
/// confidence >= `KnowsOf`.
|
||
/// - `Colleague`: global `RelationshipGraph` has an edge from the player to this NPC
|
||
/// with kind `Friend` or `Colleague`.
|
||
///
|
||
/// `Neighborhood` and `ActiveQuest` tags are NOT touched by this system:
|
||
/// - `Neighborhood` is set at session start and persists (future sprint).
|
||
/// - `ActiveQuest` is reserved for the quest system (future sprint).
|
||
///
|
||
/// No-op when there is no `PlayerCharacter` entity.
|
||
pub fn assign_scope_tags(
|
||
player_query: Query<(&KnowledgeGraph, &StableEntityId), With<PlayerCharacter>>,
|
||
rel_graph: Res<RelationshipGraph>,
|
||
mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With<Npc>>,
|
||
mut commands: Commands,
|
||
) {
|
||
let Ok((player_kg, player_stable)) = player_query.single() else {
|
||
return;
|
||
};
|
||
let player_id = player_stable.0;
|
||
|
||
// Collect KnownContact set: entities in player KG with confidence >= KnowsOf.
|
||
// BTreeSet for deterministic iteration (D-010).
|
||
let known_contacts: BTreeSet<_> = player_kg
|
||
.entities
|
||
.iter()
|
||
.filter(|(_, ek)| ek.confidence >= KnowledgeConfidence::KnowsOf)
|
||
.map(|(id, _)| *id)
|
||
.collect();
|
||
|
||
// Collect Colleague set: player → NPC relationship edges with Friend/Colleague kind.
|
||
let colleagues: BTreeSet<_> = rel_graph
|
||
.relationships_of(&player_id)
|
||
.into_iter()
|
||
.filter(|(_, edge)| {
|
||
matches!(
|
||
edge.kind,
|
||
RelationshipKind::Friend | RelationshipKind::Colleague
|
||
)
|
||
})
|
||
.map(|(target_id, _)| *target_id)
|
||
.collect();
|
||
|
||
for (entity, npc_stable, maybe_scope_tag) in &mut npcs {
|
||
let npc_id = npc_stable.0;
|
||
let is_known = known_contacts.contains(&npc_id);
|
||
let is_colleague = colleagues.contains(&npc_id);
|
||
|
||
match maybe_scope_tag {
|
||
Some(mut scope_tag) => {
|
||
// Remove computed tags, then re-add if still applicable.
|
||
scope_tag.remove(ScopeTagKind::KnownContact);
|
||
scope_tag.remove(ScopeTagKind::Colleague);
|
||
if is_known {
|
||
scope_tag.add(ScopeTagKind::KnownContact);
|
||
}
|
||
if is_colleague {
|
||
scope_tag.add(ScopeTagKind::Colleague);
|
||
}
|
||
}
|
||
None if is_known || is_colleague => {
|
||
// Create a new ScopeTag component for this NPC.
|
||
let mut scope_tag = ScopeTag::default();
|
||
if is_known {
|
||
scope_tag.add(ScopeTagKind::KnownContact);
|
||
}
|
||
if is_colleague {
|
||
scope_tag.add(ScopeTagKind::Colleague);
|
||
}
|
||
commands.entity(entity).insert(scope_tag);
|
||
}
|
||
None => {} // NPC not known or related — no scope tag needed.
|
||
}
|
||
}
|
||
}
|
||
|
||
/// System: keep `ScopePinned` markers in sync with `ScopeTag` components.
|
||
///
|
||
/// Runs after `assign_scope_tags`. For each NPC:
|
||
/// - `ScopeTag` present and non-empty → add `ScopePinned` (if not already present).
|
||
/// - `ScopeTag` absent or empty → remove `ScopePinned` (if present).
|
||
///
|
||
/// The eviction system (#97) queries `Without<ScopePinned>` to skip pinned NPCs.
|
||
pub fn sync_scope_pins(
|
||
mut commands: Commands,
|
||
needs_pin: Query<(Entity, &ScopeTag), Without<ScopePinned>>,
|
||
may_need_unpin: Query<(Entity, Option<&ScopeTag>), With<ScopePinned>>,
|
||
) {
|
||
// Add ScopePinned to NPCs that have a non-empty ScopeTag.
|
||
for (entity, scope_tag) in &needs_pin {
|
||
if scope_tag.is_pinned() {
|
||
commands.entity(entity).insert(ScopePinned);
|
||
}
|
||
}
|
||
|
||
// Remove ScopePinned from NPCs whose ScopeTag is absent or empty.
|
||
for (entity, maybe_scope_tag) in &may_need_unpin {
|
||
let still_pinned = maybe_scope_tag.map(|s| s.is_pinned()).unwrap_or(false);
|
||
if !still_pinned {
|
||
commands.entity(entity).remove::<ScopePinned>();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Eviction systems (D-026, #97)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// System: update `LastInteractionTick` for NPCs visible to the player (#97).
|
||
///
|
||
/// Runs after visibility geometry is computed. Any NPC at a visible position
|
||
/// (in the player's LOS) gets its `LastInteractionTick` set to the current tick.
|
||
/// NPCs without this component get it inserted on first observation.
|
||
pub fn update_last_interaction_tick(
|
||
time: Res<crate::simulation::time::SimulationTime>,
|
||
vis_geo: Res<crate::perception::query::VisibilityGeometry>,
|
||
mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With<Npc>>,
|
||
npcs_without_tick: Query<(Entity, &TilePosition), (With<Npc>, Without<LastInteractionTick>)>,
|
||
mut commands: Commands,
|
||
) {
|
||
let current_tick = time.tick;
|
||
|
||
// Update existing LastInteractionTick for visible NPCs.
|
||
for (pos, mut last_tick) in &mut npcs_with_tick {
|
||
if pos.z == vis_geo.observer_z && vis_geo.visible_positions.contains(&(pos.x, pos.y)) {
|
||
last_tick.0 = current_tick;
|
||
}
|
||
}
|
||
|
||
// Insert LastInteractionTick for NPCs that don't have it yet but are visible.
|
||
for (entity, pos) in &npcs_without_tick {
|
||
if pos.z == vis_geo.observer_z && vis_geo.visible_positions.contains(&(pos.x, pos.y)) {
|
||
commands
|
||
.entity(entity)
|
||
.insert(LastInteractionTick(current_tick));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// System: evict excess `ActiveSim` entities when count exceeds capacity (#97).
|
||
///
|
||
/// When more than `ACTIVE_SIM_CAPACITY` entities are in `ActiveSim`:
|
||
/// 1. Skip all `ScopePinned` entities (they stay Active regardless).
|
||
/// 2. Sort remaining by `LastInteractionTick` (oldest first) via min-heap.
|
||
/// 3. Demote the oldest N entities to `BackgroundSim` (or `StateSaved` if beyond
|
||
/// background radius).
|
||
///
|
||
/// Updates `SimSpacePressure` resource with current counts.
|
||
pub fn evict_excess_active(
|
||
mut commands: Commands,
|
||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||
active_npcs: Query<
|
||
(Entity, &TilePosition, Option<&LastInteractionTick>),
|
||
(With<ActiveSim>, With<Npc>, Without<ScopePinned>),
|
||
>,
|
||
active_count_query: Query<(), With<ActiveSim>>,
|
||
mut pressure: ResMut<SimSpacePressure>,
|
||
) {
|
||
let total_active = active_count_query.iter().count();
|
||
pressure.active_count = total_active;
|
||
|
||
if total_active <= pressure.capacity {
|
||
return;
|
||
}
|
||
|
||
let excess = total_active - pressure.capacity;
|
||
let Ok(player_pos) = player_query.single() else {
|
||
return;
|
||
};
|
||
|
||
// Min-heap keyed by LastInteractionTick (oldest = smallest = evicted first).
|
||
// Entities without LastInteractionTick get tick 0 (most stale).
|
||
// NOTE: Ties in tick value are broken by Entity index, which is non-deterministic
|
||
// across runs (bevy Entity allocation order). For v0.1 this is acceptable —
|
||
// deterministic replay (D-010 principle 4) replays inputs, not eviction order.
|
||
// If eviction order must be deterministic, key by (tick, StableId) instead.
|
||
let mut heap: BinaryHeap<Reverse<(u64, Entity, TilePosition)>> = BinaryHeap::new();
|
||
for (entity, pos, maybe_tick) in &active_npcs {
|
||
let tick = maybe_tick.map(|t| t.0).unwrap_or(0);
|
||
heap.push(Reverse((tick, entity, *pos)));
|
||
}
|
||
|
||
let mut evicted = 0;
|
||
while evicted < excess {
|
||
let Some(Reverse((_, entity, pos))) = heap.pop() else {
|
||
break;
|
||
};
|
||
|
||
let dist = tile_distance(player_pos, &pos);
|
||
if dist > BACKGROUND_RADIUS {
|
||
commands
|
||
.entity(entity)
|
||
.remove::<ActiveSim>()
|
||
.insert(StateSaved);
|
||
} else {
|
||
commands
|
||
.entity(entity)
|
||
.remove::<ActiveSim>()
|
||
.insert(BackgroundSim);
|
||
}
|
||
evicted += 1;
|
||
}
|
||
|
||
if evicted > 0 {
|
||
tracing::debug!(
|
||
"evicted {} excess ActiveSim entities (was {}, cap {})",
|
||
evicted,
|
||
total_active,
|
||
pressure.capacity,
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy_ecs::world::World;
|
||
|
||
// --- 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());
|
||
}
|
||
|
||
// --- 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
|
||
}
|
||
|
||
// --- 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());
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ScopeTag component tests (#98, D-026)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn scope_tag_with_creates_single_kind() {
|
||
let tag = ScopeTag::with(ScopeTagKind::KnownContact);
|
||
assert!(tag.contains(ScopeTagKind::KnownContact));
|
||
assert!(!tag.contains(ScopeTagKind::Colleague));
|
||
assert!(tag.is_pinned());
|
||
}
|
||
|
||
#[test]
|
||
fn scope_tag_add_and_remove() {
|
||
let mut tag = ScopeTag::default();
|
||
assert!(!tag.is_pinned(), "new ScopeTag is empty");
|
||
|
||
tag.add(ScopeTagKind::Colleague);
|
||
assert!(tag.is_pinned());
|
||
assert!(tag.contains(ScopeTagKind::Colleague));
|
||
|
||
tag.add(ScopeTagKind::KnownContact);
|
||
assert!(tag.contains(ScopeTagKind::KnownContact));
|
||
|
||
tag.remove(ScopeTagKind::Colleague);
|
||
assert!(!tag.contains(ScopeTagKind::Colleague));
|
||
assert!(tag.is_pinned(), "still pinned by KnownContact");
|
||
|
||
tag.remove(ScopeTagKind::KnownContact);
|
||
assert!(!tag.is_pinned(), "unpinned when all tags removed");
|
||
}
|
||
|
||
#[test]
|
||
fn scope_tag_multiple_kinds_coexist() {
|
||
let mut tag = ScopeTag::default();
|
||
tag.add(ScopeTagKind::Neighborhood);
|
||
tag.add(ScopeTagKind::ActiveQuest);
|
||
tag.add(ScopeTagKind::Colleague);
|
||
tag.add(ScopeTagKind::KnownContact);
|
||
|
||
assert_eq!(tag.tags.len(), 4, "all four kinds present");
|
||
assert!(tag.is_pinned());
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// sync_scope_pins system tests (#98)
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn run_sync_scope_pins(world: &mut World) {
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(sync_scope_pins);
|
||
schedule.run(world);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_scope_pins_adds_scope_pinned_for_non_empty_tag() {
|
||
let mut world = World::new();
|
||
let npc = world
|
||
.spawn((Npc, ScopeTag::with(ScopeTagKind::KnownContact)))
|
||
.id();
|
||
|
||
run_sync_scope_pins(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ScopePinned>(npc).is_some(),
|
||
"ScopePinned added for non-empty ScopeTag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_scope_pins_does_not_add_for_empty_tag() {
|
||
let mut world = World::new();
|
||
let npc = world.spawn((Npc, ScopeTag::default())).id();
|
||
|
||
run_sync_scope_pins(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ScopePinned>(npc).is_none(),
|
||
"ScopePinned must NOT be added for empty ScopeTag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_scope_pins_removes_scope_pinned_when_tag_emptied() {
|
||
let mut world = World::new();
|
||
// Start with ScopePinned already set but ScopeTag now empty.
|
||
let npc = world.spawn((Npc, ScopePinned, ScopeTag::default())).id();
|
||
|
||
run_sync_scope_pins(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ScopePinned>(npc).is_none(),
|
||
"ScopePinned removed when ScopeTag is empty"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_scope_pins_removes_scope_pinned_when_tag_absent() {
|
||
let mut world = World::new();
|
||
// NPC has ScopePinned but no ScopeTag component at all.
|
||
let npc = world.spawn((Npc, ScopePinned)).id();
|
||
|
||
run_sync_scope_pins(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ScopePinned>(npc).is_none(),
|
||
"ScopePinned removed when ScopeTag absent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_scope_pins_keeps_existing_scope_pinned() {
|
||
// An NPC that already has ScopePinned AND a non-empty ScopeTag should remain pinned.
|
||
let mut world = World::new();
|
||
let npc = world
|
||
.spawn((Npc, ScopePinned, ScopeTag::with(ScopeTagKind::Colleague)))
|
||
.id();
|
||
|
||
run_sync_scope_pins(&mut world);
|
||
|
||
// After sync, the NPC should still have ScopePinned (it was already there
|
||
// AND the scope tag is non-empty — so no change needed).
|
||
assert!(
|
||
world.get::<ScopePinned>(npc).is_some(),
|
||
"ScopePinned preserved for non-empty ScopeTag"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// assign_scope_tags system tests (#98)
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn run_assign_scope_tags(world: &mut World) {
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(assign_scope_tags);
|
||
schedule.run(world);
|
||
}
|
||
|
||
#[test]
|
||
fn assign_scope_tags_no_op_without_player() {
|
||
let mut world = World::new();
|
||
world.init_resource::<RelationshipGraph>();
|
||
|
||
// NPC exists but no PlayerCharacter
|
||
let npc = world
|
||
.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1))))
|
||
.id();
|
||
|
||
run_assign_scope_tags(&mut world);
|
||
|
||
// No ScopeTag should be assigned — no player
|
||
assert!(world.get::<ScopeTag>(npc).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn assign_scope_tags_known_contact_from_player_kg() {
|
||
use crate::knowledge::types::StableId;
|
||
|
||
let mut world = World::new();
|
||
world.init_resource::<RelationshipGraph>();
|
||
|
||
let npc_stable = StableId(10);
|
||
let player_stable = StableId(1);
|
||
|
||
// Set up player with a KnowledgeGraph that knows the NPC at KnowsOf level.
|
||
let mut player_kg = KnowledgeGraph::new();
|
||
player_kg.observe_entity(npc_stable, make_pos(5, 5), 0);
|
||
|
||
world.spawn((
|
||
PlayerCharacter,
|
||
make_pos(0, 0),
|
||
player_kg,
|
||
StableEntityId(player_stable),
|
||
));
|
||
|
||
// Spawn the NPC
|
||
let npc = world
|
||
.spawn((Npc, make_pos(10, 0), StableEntityId(npc_stable)))
|
||
.id();
|
||
|
||
run_assign_scope_tags(&mut world);
|
||
|
||
let scope_tag = world
|
||
.get::<ScopeTag>(npc)
|
||
.expect("ScopeTag should be assigned");
|
||
assert!(
|
||
scope_tag.contains(ScopeTagKind::KnownContact),
|
||
"NPC known at KnowsOf level should get KnownContact tag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn assign_scope_tags_colleague_from_relationship_graph() {
|
||
use crate::knowledge::types::StableId;
|
||
use crate::npc::relationships::RelationshipEdge;
|
||
|
||
let mut world = World::new();
|
||
|
||
let npc_stable = StableId(20);
|
||
let player_stable = StableId(1);
|
||
|
||
// Player KG is empty — no KnownContact.
|
||
let player_kg = KnowledgeGraph::new();
|
||
|
||
world.spawn((
|
||
PlayerCharacter,
|
||
make_pos(0, 0),
|
||
player_kg,
|
||
StableEntityId(player_stable),
|
||
));
|
||
|
||
// Set up RelationshipGraph with player → NPC as Friend.
|
||
let mut rel_graph = RelationshipGraph::new();
|
||
rel_graph.set_relationship(
|
||
player_stable,
|
||
npc_stable,
|
||
RelationshipEdge {
|
||
kind: RelationshipKind::Friend,
|
||
trust: 5,
|
||
history: vec![],
|
||
last_interaction_tick: 0,
|
||
},
|
||
);
|
||
world.insert_resource(rel_graph);
|
||
|
||
let npc = world
|
||
.spawn((Npc, make_pos(0, 5), StableEntityId(npc_stable)))
|
||
.id();
|
||
|
||
run_assign_scope_tags(&mut world);
|
||
|
||
let scope_tag = world
|
||
.get::<ScopeTag>(npc)
|
||
.expect("ScopeTag assigned for colleague");
|
||
assert!(
|
||
scope_tag.contains(ScopeTagKind::Colleague),
|
||
"Friend relationship should grant Colleague scope tag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn assign_scope_tags_does_not_affect_unknown_npcs() {
|
||
use crate::knowledge::types::StableId;
|
||
|
||
let mut world = World::new();
|
||
world.init_resource::<RelationshipGraph>();
|
||
|
||
let player_stable = StableId(1);
|
||
let player_kg = KnowledgeGraph::new(); // empty — knows nobody
|
||
|
||
world.spawn((
|
||
PlayerCharacter,
|
||
make_pos(0, 0),
|
||
player_kg,
|
||
StableEntityId(player_stable),
|
||
));
|
||
|
||
// NPC that the player doesn't know
|
||
let npc = world
|
||
.spawn((Npc, make_pos(10, 0), StableEntityId(StableId(99))))
|
||
.id();
|
||
|
||
run_assign_scope_tags(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ScopeTag>(npc).is_none(),
|
||
"unknown NPC should not receive ScopeTag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scope_pinned_npc_in_query_without_scope_pinned_marker() {
|
||
// Verify that ScopePinned is a separate marker and Without<ScopePinned>
|
||
// correctly excludes pinned NPCs from eviction queries.
|
||
let mut world = World::new();
|
||
let pinned = world.spawn((Npc, ScopePinned)).id();
|
||
let unpinned = world.spawn(Npc).id();
|
||
|
||
let mut query = world.query_filtered::<Entity, (With<Npc>, Without<ScopePinned>)>();
|
||
let unpinned_results: Vec<Entity> = query.iter(&world).collect();
|
||
|
||
assert_eq!(unpinned_results.len(), 1, "only one unpinned NPC");
|
||
assert_eq!(unpinned_results[0], unpinned);
|
||
assert!(
|
||
!unpinned_results.contains(&pinned),
|
||
"pinned NPC excluded from eviction query"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Eviction system tests (#97, D-026)
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn run_evict_excess_active(world: &mut World) {
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(evict_excess_active);
|
||
schedule.run(world);
|
||
}
|
||
|
||
#[test]
|
||
fn no_eviction_when_under_capacity() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 5,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// Spawn 3 active NPCs (under cap of 5)
|
||
let npc1 = world
|
||
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)))
|
||
.id();
|
||
let npc2 = world
|
||
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
|
||
.id();
|
||
let npc3 = world
|
||
.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)))
|
||
.id();
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
// All should remain Active
|
||
assert!(world.get::<ActiveSim>(npc1).is_some());
|
||
assert!(world.get::<ActiveSim>(npc2).is_some());
|
||
assert!(world.get::<ActiveSim>(npc3).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn evicts_oldest_when_over_capacity() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 2,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// 3 NPCs, cap=2 → must evict 1 (the oldest: tick 10)
|
||
let oldest = world
|
||
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)))
|
||
.id();
|
||
let mid = world
|
||
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
|
||
.id();
|
||
let newest = world
|
||
.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)))
|
||
.id();
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
assert!(world.get::<ActiveSim>(oldest).is_none(), "oldest evicted");
|
||
assert!(
|
||
world.get::<BackgroundSim>(oldest).is_some(),
|
||
"oldest → Background"
|
||
);
|
||
assert!(world.get::<ActiveSim>(mid).is_some(), "mid stays Active");
|
||
assert!(
|
||
world.get::<ActiveSim>(newest).is_some(),
|
||
"newest stays Active"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_skips_scope_pinned() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 1,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// 2 NPCs, cap=1. The oldest is ScopePinned → skip it, evict the other.
|
||
let pinned = world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
ScopePinned,
|
||
ScopeTag::with(ScopeTagKind::KnownContact),
|
||
make_pos(5, 0),
|
||
LastInteractionTick(5),
|
||
))
|
||
.id();
|
||
let unpinned = world
|
||
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
|
||
.id();
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ActiveSim>(pinned).is_some(),
|
||
"pinned NPC stays Active"
|
||
);
|
||
assert!(
|
||
world.get::<ActiveSim>(unpinned).is_none(),
|
||
"unpinned NPC evicted"
|
||
);
|
||
assert!(world.get::<BackgroundSim>(unpinned).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_demotes_to_state_saved_if_beyond_background_radius() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 1,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// NPC at distance 200 (beyond BACKGROUND_RADIUS=120) → StateSaved
|
||
let far = world
|
||
.spawn((Npc, ActiveSim, make_pos(200, 0), LastInteractionTick(5)))
|
||
.id();
|
||
// NPC at distance 5 (within ACTIVE_RADIUS) → stays
|
||
let near = world
|
||
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(50)))
|
||
.id();
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
assert!(world.get::<ActiveSim>(far).is_none(), "far NPC evicted");
|
||
assert!(
|
||
world.get::<StateSaved>(far).is_some(),
|
||
"far NPC → StateSaved"
|
||
);
|
||
assert!(
|
||
world.get::<ActiveSim>(near).is_some(),
|
||
"near NPC stays Active"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_handles_npcs_without_last_interaction_tick() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 1,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// NPC without LastInteractionTick defaults to tick 0 (most stale)
|
||
let no_tick = world.spawn((Npc, ActiveSim, make_pos(5, 0))).id();
|
||
let with_tick = world
|
||
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(100)))
|
||
.id();
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
assert!(
|
||
world.get::<ActiveSim>(no_tick).is_none(),
|
||
"no-tick NPC evicted first"
|
||
);
|
||
assert!(world.get::<BackgroundSim>(no_tick).is_some());
|
||
assert!(
|
||
world.get::<ActiveSim>(with_tick).is_some(),
|
||
"with-tick NPC stays"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sim_space_pressure_updated_after_eviction() {
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 2,
|
||
});
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)));
|
||
world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)));
|
||
world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)));
|
||
|
||
run_evict_excess_active(&mut world);
|
||
|
||
let pressure = world.resource::<SimSpacePressure>();
|
||
// active_count is set BEFORE eviction runs (it reads the pre-eviction count).
|
||
// The actual count changes via deferred commands, which apply after the system.
|
||
assert_eq!(
|
||
pressure.active_count, 3,
|
||
"pressure tracks pre-eviction count"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scope_pinned_npcs_survive_eviction_at_scale() {
|
||
// Regression: evict_excess_active must never demote a ScopePinned NPC,
|
||
// even when many NPCs are over capacity (D-026, #97, #98).
|
||
//
|
||
// Setup: 85 Active NPCs (capacity = 80 → 5 must be evicted).
|
||
// - 10 are ScopePinned (must ALL remain ActiveSim after eviction).
|
||
// - 75 are unpinned (5 oldest are eviction targets; 70 survive).
|
||
//
|
||
// The Without<ScopePinned> query filter in evict_excess_active is the
|
||
// core invariant under test. This test fails immediately if that filter
|
||
// is removed or mis-applied.
|
||
let mut world = World::new();
|
||
world.insert_resource(SimSpacePressure {
|
||
active_count: 0,
|
||
capacity: 80,
|
||
});
|
||
|
||
// Player at origin — all NPCs are within BACKGROUND_RADIUS.
|
||
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||
|
||
// Spawn 10 ScopePinned NPCs. Give them the oldest ticks so they would
|
||
// be prime eviction candidates if Without<ScopePinned> were absent.
|
||
let pinned: Vec<Entity> = (0..10)
|
||
.map(|i| {
|
||
world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
ScopePinned,
|
||
make_pos(5 + i, 0),
|
||
LastInteractionTick(i as u64),
|
||
))
|
||
.id()
|
||
})
|
||
.collect();
|
||
|
||
// Spawn 5 unpinned NPCs with old ticks — these are the actual eviction targets.
|
||
let unpinned_oldest: Vec<Entity> = (0..5)
|
||
.map(|i| {
|
||
world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
make_pos(20 + i, 0),
|
||
LastInteractionTick(i as u64),
|
||
))
|
||
.id()
|
||
})
|
||
.collect();
|
||
|
||
// Spawn 70 unpinned NPCs with newer ticks — these survive.
|
||
for i in 0..70i32 {
|
||
world.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
make_pos(30 + i, 0),
|
||
LastInteractionTick(100 + i as u64),
|
||
));
|
||
}
|
||
|
||
// Total: 10 pinned + 5 oldest-unpinned + 70 newer-unpinned = 85 active.
|
||
// cap = 80 → exactly 5 must be evicted.
|
||
run_evict_excess_active(&mut world);
|
||
|
||
// Core invariant: ALL pinned entities remain ActiveSim.
|
||
for (i, &entity) in pinned.iter().enumerate() {
|
||
assert!(
|
||
world.get::<ActiveSim>(entity).is_some(),
|
||
"ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)",
|
||
i
|
||
);
|
||
assert!(
|
||
world.get::<BackgroundSim>(entity).is_none(),
|
||
"ScopePinned NPC {} must NOT be demoted to BackgroundSim",
|
||
i
|
||
);
|
||
assert!(
|
||
world.get::<StateSaved>(entity).is_none(),
|
||
"ScopePinned NPC {} must NOT be demoted to StateSaved",
|
||
i
|
||
);
|
||
}
|
||
|
||
// Sanity: the 5 oldest unpinned were the ones evicted.
|
||
let evicted_count = unpinned_oldest
|
||
.iter()
|
||
.filter(|&&e| world.get::<ActiveSim>(e).is_none())
|
||
.count();
|
||
assert_eq!(
|
||
evicted_count, 5,
|
||
"exactly 5 unpinned NPCs (the oldest) should have been evicted to reach capacity"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// LastInteractionTick component tests (#97)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn last_interaction_tick_defaults_to_zero() {
|
||
let tick = LastInteractionTick::default();
|
||
assert_eq!(tick.0, 0);
|
||
}
|
||
}
|