diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index 19cd733ad..86c24b35d 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -94,6 +94,7 @@ mod tests { let entity = world .spawn(( Npc, + crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(5, 5, 0), // Not at afternoon location DailyRoutine { entries: vec![RoutineEntry { @@ -216,6 +217,7 @@ mod tests { let entity = world .spawn(( Npc, + crate::simulation::tier::ActiveSim, // system requires With (#94) TilePosition::new(20, 20, 0), DailyRoutine { entries: vec![RoutineEntry { diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 357becc4a..66f69e830 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -10,7 +10,8 @@ use bevy_ecs::prelude::*; use std::collections::BTreeSet; use crate::bridge::types::*; -use crate::knowledge::types::KnowledgeState; +use crate::knowledge::graph::filter_by_access; +use crate::knowledge::types::{AccessRule, KnowledgeState}; use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; @@ -80,6 +81,7 @@ pub fn compute_observer_snapshot( &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, + Option<&AccessRule>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, @@ -121,8 +123,13 @@ pub fn compute_observer_snapshot( }) .unwrap_or_default(); + // Resolve observer's StableId for component-level access control (#139, D-010) + let observer_stable_id = registry + .to_stable(observer_entity) + .unwrap_or(StableId(0)); + let (mut entities, visible_ids, blocked_entities) = - filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); + filter_visible_entities(&geometry, ®istry, observer_kg, observer_stable_id, &all_entities); collect_remembered_entities( observer_kg, @@ -237,18 +244,20 @@ fn filter_visible_entities( geometry: &VisibilityGeometry, registry: &EntityRegistry, observer_kg: &KnowledgeGraph, + observer_stable_id: StableId, all_entities: &Query<( Entity, &TilePosition, Option<&PlayerCharacter>, Option<&crate::npc::Npc>, + Option<&AccessRule>, )>, ) -> (Vec, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); let mut blocked_ids: BTreeSet = BTreeSet::new(); - for (entity, pos, is_player, is_npc) in all_entities.iter() { + for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() { if pos.z != geometry.observer_z { continue; } @@ -289,7 +298,16 @@ fn filter_visible_entities( let relationship = if is_player.is_some() { RelationshipState::Known // Self } else if let Some(stable_id) = registry.to_stable(entity) { - observer_kg.relationship_with(&stable_id) + // D-010 principle 2: check access control before exposing relationship (#139) + let access_granted = match access_rule { + Some(rule) => filter_by_access(observer_stable_id, stable_id, &rule.0, observer_kg), + None => true, // No AccessRule → Public (default) + }; + if access_granted { + observer_kg.relationship_with(&stable_id) + } else { + RelationshipState::Unknown // Access denied — redact relationship data + } } else { RelationshipState::Unknown }; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index be4b3560c..e5c3c2914 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::knowledge::types::KnowledgeState; +use crate::knowledge::types::{KnowledgeState, ObserverAccess}; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; @@ -2178,6 +2178,234 @@ fn different_z_level_not_in_blocked_entities() { ); } +// ----------------------------------------------------------------------- +// Component-level access control tests (#139, D-010 principle 2) +// ----------------------------------------------------------------------- + +#[test] +fn access_rule_owner_only_redacts_relationship() { + // THE critical negative test for #139: NPC with OwnerOnly access rule + // is physically visible (in LOS) but relationship data is redacted. + use crate::knowledge::types::AccessRule; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC with OwnerOnly access rule — only the NPC itself can read its data + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::OwnerOnly), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows NPC as Hostile — but access should be denied + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // NPC should be visible (physically in LOS) + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible even with OwnerOnly access"); + + // But relationship must be redacted to Unknown (access denied) + assert_eq!( + npc_entity.relationship, + RelationshipState::Unknown, + "OwnerOnly access should redact relationship to Unknown for non-owner observer" + ); + assert_eq!(npc_entity.observation, EntityVisibility::Visible); +} + +#[test] +fn access_rule_knowledge_gated_passes_with_matching_fact() { + // Positive test: observer has the required fact, relationship visible. + use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC gated on a specific fact + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::KnowledgeGated("contraband.ring_exists".into())), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows the required fact AND has a relationship with the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + kg.facts.insert( + FactId("contraband.ring_exists".into()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + }, + ); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + + // Observer has the required fact — relationship should be visible + assert_eq!( + npc_entity.relationship, + RelationshipState::PersonOfInterest, + "KnowledgeGated access should pass when observer has the required fact" + ); +} + +#[test] +fn access_rule_knowledge_gated_redacts_without_fact() { + // Negative test: observer lacks the required fact, relationship redacted. + use crate::knowledge::types::AccessRule; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC gated on a fact the observer doesn't have + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 14, 0), + AccessRule(ObserverAccess::KnowledgeGated("conspiracy.mastermind".into())), + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has relationship but NOT the required fact + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible (in LOS)"); + + assert_eq!( + npc_entity.relationship, + RelationshipState::Unknown, + "KnowledgeGated access should redact relationship when observer lacks the fact" + ); +} + +#[test] +fn no_access_rule_defaults_to_public() { + // Existing behavior: entities without AccessRule are fully visible. + // This is a regression guard — existing tests also cover this implicitly. + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC with NO AccessRule component + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Friendly); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + + assert_eq!( + npc_entity.relationship, + RelationshipState::Friendly, + "No AccessRule should default to Public — relationship fully visible" + ); +} + #[test] fn blocked_entities_sorted_ascending() { // Multiple blocked NPCs should appear in ascending entity_id order diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs index 7aa260851..9e156a2fa 100644 --- a/server/src/simulation/path_follow.rs +++ b/server/src/simulation/path_follow.rs @@ -98,6 +98,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(0, 0, 0), ComputedPath { steps: vec![ @@ -129,6 +130,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(2, 0, 0), ComputedPath { steps: vec![TilePosition::new(3, 0, 0)], @@ -154,6 +156,7 @@ mod tests { let entity = world .spawn(( Npc, + super::ActiveSim, // system requires With (#94) TilePosition::new(0, 0, 0), ComputedPath { steps: vec![TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0)], diff --git a/server/src/simulation/sound.rs b/server/src/simulation/sound.rs index c752550da..a2f359011 100644 --- a/server/src/simulation/sound.rs +++ b/server/src/simulation/sound.rs @@ -278,4 +278,220 @@ mod tests { schedule.run(&mut world); assert_eq!(world.resource::().events.len(), 0); } + + // --- Multi-emitter and multi-event tests --- + + #[test] + fn multiple_emitters_collected_in_one_tick() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + world.spawn(SoundEventEmitter::new(close_event(&tile(1, 1)))); + world.spawn(SoundEventEmitter::new(close_event(&tile(2, 2)))); + world.spawn(SoundEventEmitter::new(medium_event(&tile(3, 3)))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 3, "all three emitters collected"); + } + + #[test] + fn emitter_with_multiple_pending_events_all_drained() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let emitter = SoundEventEmitter::new(close_event(&pos)) + .with(medium_event(&pos)) + .with(SoundEvent::at( + &pos, + SoundEventKind::Alert, + 1.0, + SoundRange::Long, + None, + )); + world.spawn(emitter); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!( + queue.events.len(), + 3, + "all three pending events from one emitter collected" + ); + } + + // --- Long range audibility tests (D-018) --- + + #[test] + fn long_range_sound_audible_within_20_tiles() { + let source = tile(0, 0); + let event = + SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None); + + // Manhattan distance 20 — exactly at boundary + let listener = tile(10, 10); + assert!( + event.audible_at(&listener), + "Long range sound audible at manhattan 20" + ); + } + + #[test] + fn long_range_sound_not_audible_beyond_20_tiles() { + let source = tile(0, 0); + let event = + SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None); + + let listener = tile(11, 10); // manhattan 21 + assert!( + !event.audible_at(&listener), + "Long range sound not audible at manhattan 21" + ); + } + + #[test] + fn long_range_audible_at_origin() { + let source = tile(5, 5); + let event = + SoundEvent::at(&source, SoundEventKind::Machinery, 0.5, SoundRange::Long, None); + assert!(event.audible_at(&source), "audible at source position"); + } + + // --- Sound event field preservation tests --- + + #[test] + fn source_entity_id_preserved_through_collection() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let event = SoundEvent::at( + &pos, + SoundEventKind::Voice, + 0.8, + SoundRange::Close, + Some(42), + ); + world.spawn(SoundEventEmitter::new(event)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!( + queue.events[0].source_entity_id, + Some(42), + "source_entity_id must be preserved through collection" + ); + } + + #[test] + fn intensity_preserved_through_collection() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + + let pos = tile(5, 5); + let event = SoundEvent::at( + &pos, + SoundEventKind::Footstep, + 0.37, + SoundRange::Close, + None, + ); + world.spawn(SoundEventEmitter::new(event)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert!( + (queue.events[0].intensity - 0.37).abs() < f32::EPSILON, + "intensity must be preserved through collection" + ); + } + + // --- All sound event kinds --- + + #[test] + fn all_sound_event_kinds_can_be_emitted_and_collected() { + let kinds = [ + SoundEventKind::Footstep, + SoundEventKind::Voice, + SoundEventKind::Machinery, + SoundEventKind::Alert, + SoundEventKind::Ambient, + ]; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SoundEventQueue::default()); + let pos = tile(5, 5); + + for kind in kinds { + world.spawn(SoundEventEmitter::new(SoundEvent::at( + &pos, + kind, + 0.5, + SoundRange::Close, + None, + ))); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(collect_sound_events); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!( + queue.events.len(), + 5, + "all five SoundEventKind variants collected" + ); + + let collected_kinds: std::collections::HashSet = + queue.events.iter().map(|e| e.kind).collect(); + for kind in [ + SoundEventKind::Footstep, + SoundEventKind::Voice, + SoundEventKind::Machinery, + SoundEventKind::Alert, + SoundEventKind::Ambient, + ] { + assert!(collected_kinds.contains(&kind), "{kind:?} not collected"); + } + } + + // --- max_range_tiles spec verification (D-018) --- + + #[test] + fn max_range_tiles_values_match_d018() { + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Close), 3); + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Medium), 8); + assert_eq!(SoundEvent::max_range_tiles(SoundRange::Long), 20); + } + + // --- SoundEventQueue::drain test --- + + #[test] + fn queue_drain_empties_the_queue() { + let mut queue = SoundEventQueue::default(); + let pos = tile(5, 5); + queue.events.push(close_event(&pos)); + queue.events.push(medium_event(&pos)); + assert_eq!(queue.events.len(), 2); + + let drained = queue.drain(); + assert_eq!(drained.len(), 2, "drain returns all events"); + assert_eq!(queue.events.len(), 0, "queue empty after drain"); + } } diff --git a/server/src/simulation/tier.rs b/server/src/simulation/tier.rs index 0a5c2ca77..2ecef4c9a 100644 --- a/server/src/simulation/tier.rs +++ b/server/src/simulation/tier.rs @@ -1,11 +1,24 @@ // Simulation tier system // Implements D-026: Active/Background/State-saved/Ungenerated tiers -// Timestamp-based LRU eviction for simulation space management +// Tier transitions based on player approach distance (#99). use bevy_app::prelude::*; use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; + +// --- Tier radius constants (D-026) --- +// These thresholds define the distance bands at which entities transition +// between simulation tiers. Manhattan distance in tiles. + +/// Entities within this radius receive full Active simulation (D-026). +pub const ACTIVE_RADIUS: u32 = 40; + +/// Entities within this radius (and beyond ACTIVE_RADIUS) receive +/// lightweight Background schedule-keeping (D-026). +pub const BACKGROUND_RADIUS: u32 = 120; + // --- Zero-sized marker components (D-026) --- // Tag-based tier identification. Systems query With to scope work // to nearby NPCs only, avoiding full-world iteration every tick. @@ -27,19 +40,97 @@ pub struct BackgroundSim; #[derive(Component, Debug, Clone, Copy, Default)] pub struct StateSaved; -/// Plugin registering the tier marker components and associated resources. -/// Systems that filter by tier (With, etc.) require these markers -/// to exist in the type registry. Future: tier transition systems live here. +/// Plugin registering the tier marker components and the tier transition system. pub struct TierPlugin; impl Plugin for TierPlugin { - fn build(&self, _app: &mut App) { - // Marker components are zero-sized — no resources to initialize. - // Tier transition systems will be added here in ticket #99. + fn build(&self, app: &mut App) { + // Tier transition runs after movement so positions are current. + app.add_systems( + Update, + update_tier_markers.after(crate::simulation::movement::validate_movement), + ); tracing::debug!("TierPlugin initialized"); } } +// --- Tier transition system (D-026, #99) --- + +/// Manhattan tile distance between two positions, returning `u32::MAX` for +/// entities on different z-levels (they are effectively unreachable). +fn tile_distance(a: &TilePosition, b: &TilePosition) -> u32 { + if a.z != b.z { + return u32::MAX; + } + a.x.abs_diff(b.x) + a.y.abs_diff(b.y) +} + +/// System: promote/demote NPC tier markers based on player distance (D-026, #99). +/// +/// Each tick, after movement has settled positions: +/// - Entities within `ACTIVE_RADIUS` → `ActiveSim` +/// - Entities within `BACKGROUND_RADIUS` → `BackgroundSim` +/// - Entities beyond `BACKGROUND_RADIUS` → `StateSaved` +/// +/// No-op when there is no `PlayerCharacter` entity (headless tests, no observer +/// spawned). Entities that are already in the correct tier are left unchanged. +pub fn update_tier_markers( + mut commands: Commands, + player_query: Query<&TilePosition, With>, + 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); + } + } +} + #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SimulationTier { Active, @@ -306,4 +397,149 @@ mod tests { app.add_plugins(TierPlugin); // Just verifying it doesn't panic on build } + + // --- update_tier_markers system tests (D-026, #99) --- + + fn make_pos(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + fn run_tier_update(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_tier_markers); + schedule.run(world); + } + + #[test] + fn no_op_when_no_player_entity() { + // The system should be a no-op if there is no PlayerCharacter. + let mut world = World::new(); + let npc = world.spawn((ActiveSim, make_pos(200, 200))).id(); + run_tier_update(&mut world); + // NPC should still be ActiveSim — no player to compare against. + assert!(world.get::(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()); + } } diff --git a/server/src/test_world/rooms/confrontation_stage.rs b/server/src/test_world/rooms/confrontation_stage.rs index 719eb1f8d..9f2af86da 100644 --- a/server/src/test_world/rooms/confrontation_stage.rs +++ b/server/src/test_world/rooms/confrontation_stage.rs @@ -24,6 +24,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 84; @@ -62,6 +63,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/crowd_plaza.rs b/server/src/test_world/rooms/crowd_plaza.rs index 7c0fa712b..443c69a78 100644 --- a/server/src/test_world/rooms/crowd_plaza.rs +++ b/server/src/test_world/rooms/crowd_plaza.rs @@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 80; @@ -57,6 +58,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/dialogue_room.rs b/server/src/test_world/rooms/dialogue_room.rs index 8474588c8..fd6e28ba2 100644 --- a/server/src/test_world/rooms/dialogue_room.rs +++ b/server/src/test_world/rooms/dialogue_room.rs @@ -22,6 +22,7 @@ use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 36; @@ -90,6 +91,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/eavesdrop_alcove.rs b/server/src/test_world/rooms/eavesdrop_alcove.rs index 3816cff3e..32b25c9ed 100644 --- a/server/src/test_world/rooms/eavesdrop_alcove.rs +++ b/server/src/test_world/rooms/eavesdrop_alcove.rs @@ -26,6 +26,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 74; @@ -65,6 +66,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/fog_theater.rs b/server/src/test_world/rooms/fog_theater.rs index 30d26d640..ef66529d7 100644 --- a/server/src/test_world/rooms/fog_theater.rs +++ b/server/src/test_world/rooms/fog_theater.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 28; @@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/inventory_warehouse.rs b/server/src/test_world/rooms/inventory_warehouse.rs index efd2dbbd5..95036a08a 100644 --- a/server/src/test_world/rooms/inventory_warehouse.rs +++ b/server/src/test_world/rooms/inventory_warehouse.rs @@ -20,6 +20,7 @@ use crate::simulation::interaction::Interactable; use crate::simulation::inventory::ItemName; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 2; @@ -66,6 +67,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, npc_pos, Want { diff --git a/server/src/test_world/rooms/occlusion_corridor.rs b/server/src/test_world/rooms/occlusion_corridor.rs index 49638465f..14ac27940 100644 --- a/server/src/test_world/rooms/occlusion_corridor.rs +++ b/server/src/test_world/rooms/occlusion_corridor.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 74; @@ -43,6 +44,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/pause_chamber.rs b/server/src/test_world/rooms/pause_chamber.rs index fb8aefe4d..1e9bbdff4 100644 --- a/server/src/test_world/rooms/pause_chamber.rs +++ b/server/src/test_world/rooms/pause_chamber.rs @@ -17,6 +17,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 42; @@ -29,6 +30,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, pos, Want { diff --git a/server/src/test_world/rooms/sprint_gauntlet.rs b/server/src/test_world/rooms/sprint_gauntlet.rs index bd49958a1..c81902519 100644 --- a/server/src/test_world/rooms/sprint_gauntlet.rs +++ b/server/src/test_world/rooms/sprint_gauntlet.rs @@ -22,6 +22,7 @@ use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::path_follow::MovementSpeed; +use crate::simulation::tier::ActiveSim; /// Room origin (top-left corner including walls). const ORIGIN_X: i32 = 0; @@ -51,6 +52,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { .world_mut() .spawn(( Npc, + ActiveSim, Interactable, npc_pos, Want {