// 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 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, } 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::(); // 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>, rel_graph: Res, mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With>, 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` to skip pinned NPCs. pub fn sync_scope_pins( mut commands: Commands, needs_pin: Query<(Entity, &ScopeTag), Without>, may_need_unpin: Query<(Entity, Option<&ScopeTag>), With>, ) { // 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::(); } } } // --------------------------------------------------------------------------- // 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, vis_geo: Res, mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With>, npcs_without_tick: Query<(Entity, &TilePosition), (With, Without)>, 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>, active_npcs: Query< (Entity, &TilePosition, Option<&LastInteractionTick>), (With, With, Without), >, active_count_query: Query<(), With>, mut pressure: ResMut, ) { 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> = 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::() .insert(StateSaved); } else { commands .entity(entity) .remove::() .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>, active_npcs: Query<(Entity, &TilePosition), With>, background_npcs: Query<(Entity, &TilePosition), With>, state_saved_npcs: Query<(Entity, &TilePosition), With>, ) { 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::() .insert(StateSaved); } else if dist > ACTIVE_RADIUS { commands .entity(entity) .remove::() .insert(BackgroundSim); } } for (entity, pos) in &background_npcs { let dist = tile_distance(player_pos, pos); if dist <= ACTIVE_RADIUS { commands .entity(entity) .remove::() .insert(ActiveSim); } else if dist > BACKGROUND_RADIUS { commands .entity(entity) .remove::() .insert(StateSaved); } } for (entity, pos) in &state_saved_npcs { let dist = tile_distance(player_pos, pos); if dist <= ACTIVE_RADIUS { commands .entity(entity) .remove::() .insert(ActiveSim); } else if dist <= BACKGROUND_RADIUS { commands .entity(entity) .remove::() .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 / With / With // 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::>(); let results: Vec = 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::>(); let results: Vec = 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::>(); let results: Vec = 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::>(); let mut results: Vec = 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::>(); let results: Vec = 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::() .insert(ActiveSim); assert!(world.get::(entity).is_some(), "ActiveSim added"); assert!( world.get::(entity).is_none(), "StateSaved removed" ); // Must appear in ActiveSim query after promotion let mut query = world.query_filtered::>(); let results: Vec = 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::() .insert(BackgroundSim); assert!( world.get::(entity).is_some(), "BackgroundSim added" ); assert!( world.get::(entity).is_none(), "ActiveSim removed" ); // Must NOT appear in ActiveSim query after demotion let mut active_query = world.query_filtered::>(); 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::() .insert(StateSaved); assert!(world.get::(entity).is_some()); assert!(world.get::(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::(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::(npc).is_some(), "stays Active"); assert!(world.get::(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::(npc).is_none(), "ActiveSim removed"); assert!( world.get::(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::(npc).is_none(), "ActiveSim removed"); assert!(world.get::(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::(npc).is_none(), "BackgroundSim removed" ); assert!(world.get::(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::(npc).is_none(), "BackgroundSim removed" ); assert!(world.get::(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::(npc).is_none(), "StateSaved removed"); assert!(world.get::(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::(npc).is_none(), "StateSaved removed"); assert!( world.get::(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::(npc).is_some(), "stays StateSaved"); assert!(world.get::(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::(npc).is_none(), "ActiveSim removed"); assert!( world.get::(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::(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::(npc).is_none(), "demoted to Background" ); assert!(world.get::(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::(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::(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::(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::(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::(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::(); // 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::(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::(); 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::(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::(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::(); 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::(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 // 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::, Without)>(); let unpinned_results: Vec = 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::(npc1).is_some()); assert!(world.get::(npc2).is_some()); assert!(world.get::(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::(oldest).is_none(), "oldest evicted"); assert!( world.get::(oldest).is_some(), "oldest → Background" ); assert!(world.get::(mid).is_some(), "mid stays Active"); assert!( world.get::(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::(pinned).is_some(), "pinned NPC stays Active" ); assert!( world.get::(unpinned).is_none(), "unpinned NPC evicted" ); assert!(world.get::(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::(far).is_none(), "far NPC evicted"); assert!( world.get::(far).is_some(), "far NPC → StateSaved" ); assert!( world.get::(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::(no_tick).is_none(), "no-tick NPC evicted first" ); assert!(world.get::(no_tick).is_some()); assert!( world.get::(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::(); // 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 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 were absent. let pinned: Vec = (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 = (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::(entity).is_some(), "ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)", i ); assert!( world.get::(entity).is_none(), "ScopePinned NPC {} must NOT be demoted to BackgroundSim", i ); assert!( world.get::(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::(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); } }