Files
settled-reach/server/src/simulation/tier.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
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>
2026-03-17 10:33:15 +01:00

1409 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 1020 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);
}
}