feat(simulation): Sprint 19 — 7 server systems

Protocol handshake (#555): HandshakeMessage as first IPC frame,
HandshakeState resource, forward-compatible input handling.

State serialization (#96): serialize_npc_to_frozen/deserialize with
full D-024 axis coverage (10 new optional fields on NpcSaveState).

Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic
assignment from KnowledgeGraph and RelationshipGraph.

Timestamp eviction (#97): LastInteractionTick, SimSpacePressure,
BinaryHeap LRU eviction respecting ScopePinned entities.

Save/load (#553): save_to_file/load_from_file via MessagePack,
SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot.

Test infrastructure (#200): Layer 3 integration test entry point,
three-layer architecture documented per D-030.

Information boundary tests (#272): 4 negative tests proving no
passive KG leakage, LOS fog holds, tier boundary holds, save
isolation per NPC.

1063 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 12:13:03 +01:00
co-authored by Claude Opus 4.6
parent 0dd33690f7
commit 6ed8d11502
22 changed files with 2561 additions and 35 deletions
+790
View File
@@ -1,9 +1,21 @@
// 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::collections::{BTreeSet, BinaryHeap};
use std::cmp::Reverse;
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::{Npc, RelationshipKind};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
// --- Tier radius constants (D-026) ---
@@ -38,20 +50,356 @@ pub struct BackgroundSim;
#[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 currently in `ActiveSim`.
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).
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
@@ -432,4 +780,446 @@ mod tests {
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");
}
// -----------------------------------------------------------------------
// LastInteractionTick component tests (#97)
// -----------------------------------------------------------------------
#[test]
fn last_interaction_tick_defaults_to_zero() {
let tick = LastInteractionTick::default();
assert_eq!(tick.0, 0);
}
}