From 64fb045432712c9a2060d42412507113c500ff10 Mon Sep 17 00:00:00 2001 From: Arufonsu <17498701+Arufonsu@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:17:13 -0300 Subject: [PATCH] performance: defer cache rebuilds and reduce allocations in MapInstance Replace per-mutation cache rebuilds with dirty-flag deferred flushing across entities, projectiles, and traps in MapInstance, and cache surrounding-instance lookups to eliminate per-tick allocations. - Add _entityCacheDirty / _projectileCacheDirty / _trapCacheDirty flags; caches are now rebuilt once per tick in FlushDirtyCaches() called at the top of Update(), not on every Add/Remove call - Cache surrounding MapInstance lists (_cachedSurroundingInstances / _cachedSurroundingInstancesWithSelf) with an invalidation flag; replace all MapController.GetSurroundingMapInstances() call sites with GetCachedSurroundingInstances() to avoid allocating a new List<> on every tick - Add _entityListLock to decouple entity-list mutations from mMapProcessLock, reducing lock contention during the update cycle - Replace mIsProcessing = GetPlayers(true).Any() with a direct check over cached surrounding instances to avoid double allocation - Use ArrayPool.Shared in DespawnProjectiles() to avoid a per-call List and ToArray() - Snapshot NpcSpawnInstances/mEntities before acquiring EntityLocks in ResetNpcSpawns() and DespawnNpcs() to eliminate nested-lock deadlock risk - Replace GetEntities() call sites in hot paths (IsTileBlocked, UpdateResources, ClearEntityTargetsOf) with direct mCachedEntities array iteration - Use GlobalEventInstances.Values directly in UpdateGlobalEvents() instead of ToList(); short-circuit active search with break - Lazy-init toRemove list in SpawnItem(): no allocation in the common zero-match case - Replace ContainsKey+indexer double-lookup with TryGetValue in GetGlobalEventInstance() and FindEvent() - Invert null-guard conditions to early-return (SendMapEntitiesTo, RemoveItem, SpawnItems, UpdateResources) for reduced nesting - Replace if/else attribute type checks with switch expressions in SpawnMapAttributes() and CacheMapBlocks() - Collapse trivial single-expression methods to expression bodies (GetLock, GetController, ShouldBeCleaned, ShouldBeActive, GetCachedEntities, GetCachedBlocks, AddBatched*) - Remove GetControllerLock(): unused after refactor - Trim verbose XML doc comments to concise inline remarks; remove redundant using Intersect.Utilities Signed-off-by: Arufonsu <17498701+Arufonsu@users.noreply.github.com> --- Intersect.Server.Core/Maps/MapInstance.cs | 649 +++++++++++----------- 1 file changed, 324 insertions(+), 325 deletions(-) diff --git a/Intersect.Server.Core/Maps/MapInstance.cs b/Intersect.Server.Core/Maps/MapInstance.cs index cdef9efa31..4291bb6154 100644 --- a/Intersect.Server.Core/Maps/MapInstance.cs +++ b/Intersect.Server.Core/Maps/MapInstance.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Collections.Concurrent; using Intersect.Core; using Intersect.Enums; @@ -77,8 +78,11 @@ public partial class MapInstance : IMapInstance /// private bool mIsProcessing; - //SyncLock + // Separate entity-list lock from the broad map-process lock so entity mutations don't contend with the full update cycle. + // Lock acquisition order MUST always be: mMapProcessLock → _entityListLock. + // Never acquire mMapProcessLock while holding _entityListLock (deadlock risk). protected object mMapProcessLock = new object(); + private readonly object _entityListLock = new object(); /// /// A unique identifier referring to this processing instance alone. @@ -113,7 +117,11 @@ public partial class MapInstance : IMapInstance // Players & Entities private ConcurrentDictionary mPlayers = new ConcurrentDictionary(); private readonly ConcurrentDictionary mEntities = new ConcurrentDictionary(); - private Entity[] mCachedEntities = new Entity[0]; + private Entity[] mCachedEntities = Array.Empty(); + + // Dirty flag: entity cache rebuilt once per tick, not on every add/remove. + private volatile bool _entityCacheDirty = false; + private MapEntityMovements mEntityMovements = new MapEntityMovements(); // NPCs @@ -130,10 +138,17 @@ public partial class MapInstance : IMapInstance // Projectiles & Traps public ConcurrentDictionary MapProjectiles { get; } = new ConcurrentDictionary(); - public Projectile[] MapProjectilesCached = new Projectile[0]; + public Projectile[] MapProjectilesCached = Array.Empty(); + + // Dirty flag: projectile cache rebuilt once per tick. + private volatile bool _projectileCacheDirty = false; + public long LastProjectileUpdateTime = -1; public ConcurrentDictionary MapTraps = new ConcurrentDictionary(); - public MapTrapInstance[] MapTrapsCached = new MapTrapInstance[0]; + public MapTrapInstance[] MapTrapsCached = Array.Empty(); + + // Dirty flag: trap cache rebuilt once per tick. + private volatile bool _trapCacheDirty = false; // Collision private BytePoint[] mMapBlocks = Array.Empty(); @@ -150,6 +165,11 @@ public partial class MapInstance : IMapInstance private MapActionMessages mActionMessages = new MapActionMessages(); private MapAnimations mMapAnimations = new MapAnimations(); + // Cached surrounding-instance lists rebuilt only when dirty. + private List _cachedSurroundingInstances = new List(); + private List _cachedSurroundingInstancesWithSelf = new List(); + private bool _surroundingCacheDirty = true; + public MapInstance(MapController map, Guid mapInstanceId, Player creator) { mMapController = map; @@ -160,10 +180,58 @@ public MapInstance(MapController map, Guid mapInstanceId, Player creator) public bool IsDisposed { get; protected set; } - /// - /// Initializes the processing instance for processing - called in the constructor. Essentially refreshes the instance - /// so that it can give everything it has to offer to the player by the time they arrive in it - /// + // Invalidate the surrounding-instance cache when grid/instance topology changes. + public void InvalidateSurroundingCache() => _surroundingCacheDirty = true; + + // Returns a cached list of surrounding MapInstances (only rebuilt when dirty). + private List GetCachedSurroundingInstances(bool includeSelf) + { + if (!_surroundingCacheDirty) + { + return includeSelf ? _cachedSurroundingInstancesWithSelf : _cachedSurroundingInstances; + } + + _cachedSurroundingInstances.Clear(); + _cachedSurroundingInstancesWithSelf.Clear(); + + foreach (var controllerId in mMapController.GetSurroundingMapIds(false)) + { + if (MapController.TryGetInstanceFromMap(controllerId, MapInstanceId, out var inst)) + { + _cachedSurroundingInstances.Add(inst); + _cachedSurroundingInstancesWithSelf.Add(inst); + } + } + _cachedSurroundingInstancesWithSelf.Add(this); + _surroundingCacheDirty = false; + return includeSelf ? _cachedSurroundingInstancesWithSelf : _cachedSurroundingInstances; + } + + // Flush all dirty caches once at the top of each Update tick. + private void FlushDirtyCaches() + { + if (_entityCacheDirty) + { + lock (_entityListLock) + { + mCachedEntities = mEntities.Values.ToArray(); + _entityCacheDirty = false; + } + } + + if (_projectileCacheDirty) + { + MapProjectilesCached = MapProjectiles.Values.ToArray(); + _projectileCacheDirty = false; + } + + if (_trapCacheDirty) + { + MapTrapsCached = MapTraps.Values.ToArray(); + _trapCacheDirty = false; + } + } + public void Initialize() { lock (GetLock()) @@ -176,15 +244,9 @@ public void Initialize() } } - public bool ShouldBeCleaned() - { - return (MapInstanceId != OverworldInstanceId && !ShouldBeActive()); - } + public bool ShouldBeCleaned() => MapInstanceId != OverworldInstanceId && !ShouldBeActive(); - public bool ShouldBeActive() - { - return (mIsProcessing || LastRequestedUpdateTime <= mLastUpdateTime + Options.Instance.Map.TimeUntilMapCleanup); - } + public bool ShouldBeActive() => mIsProcessing || LastRequestedUpdateTime <= mLastUpdateTime + Options.Instance.Map.TimeUntilMapCleanup; public void RemoveLayerFromController() { @@ -194,19 +256,7 @@ public void RemoveLayerFromController() } } - public object GetLock() - { - return mMapProcessLock; - } - - /// - /// Gets our _parent's_ map lock. - /// - /// The parent map instance's map lock - public object GetControllerLock() - { - return mMapController.GetMapLock(); - } + public object GetLock() => mMapProcessLock; public virtual void Dispose() { @@ -216,10 +266,7 @@ public virtual void Dispose() } } - public MapController GetController() - { - return mMapController; - } + public MapController GetController() => mMapController; /// /// Destroys the processable elements of the instance - RespawnEverything() to bring them back, and begin processing them @@ -264,6 +311,9 @@ public void Update(long timeMs) { if (mIsProcessing) { + // Flush all dirty caches once before the update pass. + FlushDirtyCaches(); + if (Options.Instance.Processing.MapUpdateInterval == Options.Instance.Processing.ProjectileUpdateInterval) { UpdateProjectiles(timeMs); @@ -279,8 +329,8 @@ public void Update(long timeMs) mLastUpdateTime = timeMs; } - // If there are no players on this or surrounding processing layers, stop processing updates. - mIsProcessing = GetPlayers(true).Any(); + // Use cached surrounding instances; avoids per-call allocation. + mIsProcessing = GetCachedSurroundingInstances(true).Any(i => !i.mPlayers.IsEmpty); LastRequestedUpdateTime = timeMs; } } @@ -324,7 +374,8 @@ public void AddEntity(Entity? entity) } } - mCachedEntities = mEntities.Values.ToArray(); + // Mark dirty instead of rebuilding the array on every add. + _entityCacheDirty = true; } /// @@ -335,12 +386,14 @@ public void AddEntity(Entity? entity) /// The entity to remove. public void RemoveEntity(Entity en) { - mEntities.TryRemove(en.Id, out var result); + mEntities.TryRemove(en.Id, out _); if (mPlayers.ContainsKey(en.Id)) { - mPlayers.TryRemove(en.Id, out var pResult); + mPlayers.TryRemove(en.Id, out _); } - mCachedEntities = mEntities.Values.ToArray(); + + // Mark dirty instead of rebuilding the array on every remove. + _entityCacheDirty = true; } /// @@ -351,14 +404,14 @@ public void RemoveEntity(Entity en) /// A list of Entities public List GetEntities(bool includeSurroundingMaps = false) { - var entities = new List(mEntities.Values.ToList()); - - // ReSharper disable once InvertIf + // Return a list wrapping the cached snapshot directly. + var entities = new List(mCachedEntities); if (includeSurroundingMaps) { - foreach (var mapInstance in MapController.GetSurroundingMapInstances(mMapController.Id, MapInstanceId, false)) + // Use cached surrounding instances. + foreach (var mapInstance in GetCachedSurroundingInstances(false)) { - entities.AddRange(mapInstance.GetEntities()); + entities.AddRange(mapInstance.mCachedEntities); } } @@ -381,8 +434,8 @@ public void PlayerEnteredMap(Player player) // Send the entities/items of this current MapInstance to the player SendMapEntitiesTo(player); - // send the entities/items of the SURROUNDING maps on this instance to the player - foreach (var surroundingMapInstance in MapController.GetSurroundingMapInstances(mMapController.Id, MapInstanceId, false)) + // Use cached surrounding instances. + foreach (var surroundingMapInstance in GetCachedSurroundingInstances(false)) { surroundingMapInstance.SendMapEntitiesTo(player); PacketSender.SendMapItems(player, surroundingMapInstance.GetController().Id); @@ -396,13 +449,15 @@ public void PlayerEnteredMap(Player player) /// The player to send entities to public void SendMapEntitiesTo(Player player) { - if (player != null) + if (player == null) { - PacketSender.SendMapEntitiesTo(player, mEntities); - if (player.MapId == mMapController.Id && player.MapInstanceId == MapInstanceId) - { - player.SendEvents(); - } + return; + } + + PacketSender.SendMapEntitiesTo(player, mEntities); + if (player.MapId == mMapController.Id && player.MapInstanceId == MapInstanceId) + { + player.SendEvents(); } } @@ -418,7 +473,8 @@ public ICollection GetPlayers(bool includeSurrounding = false) if (includeSurrounding) { - foreach (var mapInstance in MapController.GetSurroundingMapInstances(mMapController.Id, MapInstanceId, false)) + // Use cached surrounding instances. + foreach (var mapInstance in GetCachedSurroundingInstances(false)) { var adjoiningPlayers = mapInstance.GetPlayers(false); if (adjoiningPlayers != null) @@ -476,18 +532,8 @@ private void SpawnMapNpc(NpcSpawn spawn) /// The direction to spawn at; out private void FindNpcSpawnLocation(NpcSpawn spawn, out int x, out int y, out Direction dir) { - dir = 0; - x = 0; - y = 0; - - if (spawn.Direction != NpcSpawnDirection.Random) - { - dir = (Direction)(spawn.Direction - 1); - } - else - { - dir = Randomization.NextDirection(); - } + dir = spawn.Direction != NpcSpawnDirection.Random ? (Direction)(spawn.Direction - 1) : Randomization.NextDirection(); + x = 0; y = 0; if (spawn.X >= 0 && spawn.Y >= 0) { @@ -505,8 +551,7 @@ private void FindNpcSpawnLocation(NpcSpawn spawn, out int x, out int y, out Dire break; } - x = 0; - y = 0; + x = 0; y = 0; } } } @@ -525,16 +570,7 @@ public Npc SpawnNpc(byte tileX, byte tileY, Direction dir, Guid npcId, bool desp var npcBase = NPCDescriptor.Get(npcId); if (npcBase != null) { - var processLayer = this.MapInstanceId; - var npc = new Npc(npcBase, despawnable) - { - MapId = mMapController.Id, - X = tileX, - Y = tileY, - Dir = dir, - MapInstanceId = processLayer - }; - + var npc = new Npc(npcBase, despawnable) { MapId = mMapController.Id, X = tileX, Y = tileY, Dir = dir, MapInstanceId = MapInstanceId }; AddEntity(npc); PacketSender.SendEntityDataToProximity(npc); @@ -545,82 +581,82 @@ public Npc SpawnNpc(byte tileX, byte tileY, Direction dir, Guid npcId, bool desp } /// - /// Forcibly reset all Npcs originating from this map. + /// Snapshot spawn list before acquiring EntityLocks to avoid nested-lock deadlock. /// public void ResetNpcSpawns() { - //Kill all npcs spawned from this map + NpcSpawn[] keys; + MapNpcSpawn[] spawns; lock (GetLock()) { - foreach (var npcSpawn in NpcSpawnInstances) + keys = NpcSpawnInstances.Keys.ToArray(); + spawns = NpcSpawnInstances.Values.ToArray(); + } + + for (var i = 0; i < spawns.Length; i++) + { + var spawnInstance = spawns[i]; + var key = keys[i]; + lock (spawnInstance.Entity.EntityLock) { - lock (npcSpawn.Value.Entity.EntityLock) + var npc = spawnInstance.Entity as Npc; + if (npc.IsDead) { - var npc = npcSpawn.Value.Entity as Npc; - if (!npc.IsDead) - { - // If we keep track of reset radiuses, just reset it to that value. - if (Options.Instance.Npc.AllowResetRadius) - { - npc.Reset(); - } - // If we don't.. Kill and respawn the Npc assuming it's in combat. - else - { - if (npc.Target != null || npc.CombatTimer > Timing.Global.Milliseconds) - { - npc.Die(false); - FindNpcSpawnLocation(npcSpawn.Key, out var x, out var y, out var dir); - npcSpawn.Value.Entity = SpawnNpc((byte)x, (byte)y, dir, npcSpawn.Key.NpcId); - } - } - } + continue; + } + + if (Options.Instance.Npc.AllowResetRadius) + { + npc.Reset(); + } + else if (npc.Target != null || npc.CombatTimer > Timing.Global.Milliseconds) + { + npc.Die(false); + FindNpcSpawnLocation(key, out var x, out var y, out var dir); + spawnInstance.Entity = SpawnNpc((byte)x, (byte)y, dir, key.NpcId); } } } } /// - /// Despawns all NPCs, and removes them from our dictionary of Spawn Instances. + /// Snapshot before EntityLocks to avoid nested-lock deadlock. /// private void DespawnNpcs() { - //Kill all npcs spawned from this map + MapNpcSpawn[] spawnValues; + Entity[] entityValues; lock (GetLock()) { - foreach (var npcSpawn in NpcSpawnInstances) - { - lock (npcSpawn.Value.Entity.EntityLock) - { - npcSpawn.Value.Entity.Die(false); - } - } - + spawnValues = NpcSpawnInstances.Values.ToArray(); + entityValues = mEntities.Values.ToArray(); NpcSpawnInstances.Clear(); + } - //Kill any other npcs on this map (only players should remain) - foreach (var entity in mEntities) + foreach (var spawnInstance in spawnValues) + { + lock (spawnInstance.Entity.EntityLock) { spawnInstance.Entity.Die(false); } + } + + foreach (var entity in entityValues) + { + if (entity is not Npc npc) { - if (entity.Value is Npc npc) - { - lock (npc.EntityLock) - { - npc.Die(false); - } - } + continue; } + + lock (npc.EntityLock) { npc.Die(false); } } } /// - /// Clears the given entity of any targets that an NPC has on them. For AI purposes. + /// Iterates mCachedEntities directly and clears the given entity of any targets that an NPC has on them. /// - /// The entitiy to clear targets of. public void ClearEntityTargetsOf(Entity en) { - foreach (var entity in mEntities) + foreach (var entity in mCachedEntities) { - if (entity.Value is Npc npc && npc.Target == en) + if (entity is Npc npc && npc.Target == en) { npc.RemoveTarget(); } @@ -639,8 +675,7 @@ private void SpawnAttributeResource(byte x, byte y) var tempResource = new ResourceSpawn() { ResourceId = ((MapResourceAttribute)mMapController.Attributes[x, y]).ResourceId, - X = x, - Y = y, + X = x, Y = y, Z = ((MapResourceAttribute)mMapController.Attributes[x, y]).SpawnLevel }; @@ -669,25 +704,17 @@ private void SpawnMapResource(ResourceSpawn spawn) return; } - var id = Guid.Empty; if (!ResourceSpawnInstances.TryGetValue(spawn, out var resourceSpawnInstance)) { resourceSpawnInstance = new MapResourceSpawn(); _ = ResourceSpawnInstances.TryAdd(spawn, resourceSpawnInstance); } - + var id = Guid.Empty; if (resourceSpawnInstance.Entity == default) { if (ResourceDescriptor.TryGet(spawn.ResourceId, out var resourceDescriptor)) { - var res = new Resource(resourceDescriptor) - { - X = spawn.X, - Y = spawn.Y, - Z = spawn.Z, - MapId = mMapController.Id, - MapInstanceId = MapInstanceId - }; + var res = new Resource(resourceDescriptor) { X = spawn.X, Y = spawn.Y, Z = spawn.Z, MapId = mMapController.Id, MapInstanceId = MapInstanceId }; id = res.Id; resourceSpawnInstance.Entity = res; AddEntity(res); @@ -697,7 +724,6 @@ private void SpawnMapResource(ResourceSpawn spawn) { id = resourceSpawnInstance.Entity.Id; } - if (id != Guid.Empty) { resourceSpawnInstance.Entity.Spawn(); @@ -714,11 +740,13 @@ private void DespawnResources() { foreach (var resourceSpawn in ResourceSpawnInstances) { - if (resourceSpawn.Value != null && resourceSpawn.Value.Entity != null) + if (resourceSpawn.Value?.Entity == null) { - resourceSpawn.Value.Entity.Destroy(); - RemoveEntity(resourceSpawn.Value.Entity); + continue; } + + resourceSpawn.Value.Entity.Destroy(); + RemoveEntity(resourceSpawn.Value.Entity); } ResourceSpawnInstances.Clear(); @@ -746,16 +774,6 @@ private void AddItem(IItemSource? source, MapItem item) MapHelper.Instance.InvokeItemAdded(source, item); } - /// - /// Spawn an item to this map instance. - /// - /// The source of the item, e.g. a player who dropped it, or a monster who spawned it on death, or the map instance in which it was spawned - /// The horizontal location of this item - /// The vertical location of this item. - /// The to spawn on the map. - /// The amount of times to spawn this item to the map. Set to the quantity, overwrites quantity if stackable! - public void SpawnItem(IItemSource? source, int x, int y, Item item, int amount) => SpawnItem(source, x, y, item, amount, Guid.Empty); - /// /// Spawn an item to this map instance. /// @@ -778,26 +796,22 @@ public void SpawnItem(IItemSource? source, int x, int y, Item item, int amount, if (itemDescriptor == null) { ApplicationContext.Context.Value?.Logger.LogWarning($"No item found for {item.ItemId}."); - return; } - // if we can stack this item or the user configured to drop items consolidated, simply spawn a single stack of it. - // Does not count for Equipment and bags, these are ALWAYS their own separate item spawn. We don't want to lose data on that! if ((itemDescriptor.ItemType != ItemType.Equipment && itemDescriptor.ItemType != ItemType.Bag) && (itemDescriptor.Stackable || Options.Instance.Loot.ConsolidateMapDrops)) { - // Does this item already exist on this tile? If so, get its value so we can simply consolidate the stack. var existingCount = 0; var existingItems = FindItemsAt(y * Options.Instance.Map.MapWidth + x); - var toRemove = new List(); + // Lazy-init toRemove: no allocation in the common zero-match case. + List? toRemove = null; foreach (var exItem in existingItems) { - // If the Id and Owner matches, get its quantity and remove the item so we don't get multiple stacks. if (exItem.ItemId == item.ItemId && exItem.Owner == owner) { existingCount += exItem.Quantity; - toRemove.Add(exItem); + (toRemove ??= new List()).Add(exItem); } } @@ -814,13 +828,12 @@ public void SpawnItem(IItemSource? source, int x, int y, Item item, int amount, return; } - // Remove existing items if we need to. - foreach (var reItem in toRemove) + if (toRemove != null) { - RemoveItem(reItem); - if (sendUpdate) + foreach (var reItem in toRemove) { - PacketSender.SendMapItemUpdate(mMapController.Id, MapInstanceId, reItem, true); + RemoveItem(reItem); + if (sendUpdate) PacketSender.SendMapItemUpdate(mMapController.Id, MapInstanceId, reItem, true); } } @@ -868,11 +881,8 @@ public void SpawnItem(IItemSource? source, int x, int y, Item item, int amount, /// Returns a if one is found with the desired Unique Id. public MapItem FindItem(Guid uniqueId) { - if (AllMapItems.TryGetValue(uniqueId, out MapItem item)) - { - return item; - } - return null; + AllMapItems.TryGetValue(uniqueId, out MapItem item); + return item; } /// @@ -886,6 +896,7 @@ public ICollection FindItemsAt(int tileIndex) { return Array.Empty(); } + return TileItems[tileIndex].Values; } @@ -896,32 +907,27 @@ public ICollection FindItemsAt(int tileIndex) /// Whether or not this item will respawn public void RemoveItem(MapItem item, bool respawn = true) { - if (item != null) + if (item == null) { - // Only try to handle respawns for items that have attribute spawn locations. - if (item.AttributeSpawnX > -1) - { - if (respawn) - { - var spawn = new MapItemSpawn() - { - AttributeSpawnX = item.X, - AttributeSpawnY = item.Y, - RespawnTime = Timing.Global.Milliseconds + (item.AttributeRespawnTime <= 0 ? Options.Instance.Map.ItemAttributeRespawnTime : item.AttributeRespawnTime) - }; - ItemRespawns.TryAdd(spawn.Id, spawn); - } - } + return; + } - var oldOwner = item.Owner; - AllMapItems.TryRemove(item.UniqueId, out MapItem removed); - TileItems[item.TileIndex]?.TryRemove(item.UniqueId, out MapItem tileRemoved); - if (TileItems[item.TileIndex]?.IsEmpty ?? false) + if (item.AttributeSpawnX > -1 && respawn) + { + var spawn = new MapItemSpawn() { - TileItems[item.TileIndex] = null; - } - PacketSender.SendMapItemUpdate(mMapController.Id, MapInstanceId, item, true, item.VisibleToAll, oldOwner); + AttributeSpawnX = item.X, + AttributeSpawnY = item.Y, + RespawnTime = Timing.Global.Milliseconds + (item.AttributeRespawnTime <= 0 ? Options.Instance.Map.ItemAttributeRespawnTime : item.AttributeRespawnTime), + }; + ItemRespawns.TryAdd(spawn.Id, spawn); } + + var oldOwner = item.Owner; + AllMapItems.TryRemove(item.UniqueId, out _); + TileItems[item.TileIndex]?.TryRemove(item.UniqueId, out _); + if (TileItems[item.TileIndex]?.IsEmpty ?? false) TileItems[item.TileIndex] = null; + PacketSender.SendMapItemUpdate(mMapController.Id, MapInstanceId, item, true, item.VisibleToAll, oldOwner); } /// @@ -980,16 +986,19 @@ private void SpawnAttributeItems() { for (byte y = 0; y < Options.Instance.Map.MapHeight; y++) { - if (mMapController.Attributes[x, y] != null) + if (mMapController.Attributes[x, y] == null) { - if (mMapController.Attributes[x, y].Type == MapAttributeType.Item) - { + continue; + } + + switch (mMapController.Attributes[x, y].Type) + { + case MapAttributeType.Item: SpawnAttributeItem(x, y); - } - else if (mMapController.Attributes[x, y].Type == MapAttributeType.Resource) - { + break; + case MapAttributeType.Resource: SpawnAttributeResource(x, y); - } + break; } } } @@ -1010,23 +1019,12 @@ private void SpawnAttributeItems() /// Z co-ordinate to spawn at, if enabled in config /// Direction in which to spawn /// The target of the projectil - public void SpawnMapProjectile( - Entity owner, - ProjectileDescriptor projectile, - SpellDescriptor parentSpell, - ItemDescriptor parentItem, - Guid mapId, - byte x, - byte y, - byte z, - Direction direction, - Entity target - ) + public void SpawnMapProjectile(Entity owner, ProjectileDescriptor projectile, SpellDescriptor parentSpell, ItemDescriptor parentItem, Guid mapId, byte x, byte y, byte z, Direction direction, Entity target) { var proj = new Projectile(owner, parentSpell, parentItem, projectile, mMapController.Id, x, y, z, direction, target); proj.MapInstanceId = MapInstanceId; MapProjectiles.TryAdd(proj.Id, proj); - MapProjectilesCached = MapProjectiles.Values.ToArray(); + _projectileCacheDirty = true; // Mark dirty; cache rebuilt once per tick. PacketSender.SendEntityDataToProximity(proj); } @@ -1035,43 +1033,58 @@ Entity target /// public void DespawnProjectiles() { - var guids = new List(); - foreach (var proj in MapProjectiles) + var count = MapProjectilesCached.Length; + var guids = ArrayPool.Shared.Rent(Math.Max(count, 1)); + try { - if (proj.Value != null) + var written = 0; + foreach (var proj in MapProjectilesCached) { - guids.Add(proj.Value.Id); - proj.Value.Die(); + if (proj == null) + { + continue; + } + + guids[written++] = proj.Id; + proj.Die(); } + + PacketSender.SendRemoveProjectileSpawns(mMapController.Id, MapInstanceId, guids[..written], null); + } + finally + { + ArrayPool.Shared.Return(guids); } - PacketSender.SendRemoveProjectileSpawns(mMapController.Id, MapInstanceId, guids.ToArray(), null); + MapProjectiles.Clear(); - MapProjectilesCached = new Projectile[0]; + MapProjectilesCached = []; + _projectileCacheDirty = false; } public void RemoveProjectile(Projectile en) { - MapProjectiles.TryRemove(en.Id, out Projectile removed); - MapProjectilesCached = MapProjectiles.Values.ToArray(); + MapProjectiles.TryRemove(en.Id, out _); + _projectileCacheDirty = true; } public void SpawnTrap(Entity owner, SpellDescriptor parentSpell, byte x, byte y, byte z) { var trap = new MapTrapInstance(owner, parentSpell, mMapController.Id, MapInstanceId, x, y, z); MapTraps.TryAdd(trap.Id, trap); - MapTrapsCached = MapTraps.Values.ToArray(); + _trapCacheDirty = true; } public void DespawnTraps() { MapTraps.Clear(); - MapTrapsCached = new MapTrapInstance[0]; + MapTrapsCached = []; + _trapCacheDirty = false; } public void RemoveTrap(MapTrapInstance trap) { - MapTraps.TryRemove(trap.Id, out MapTrapInstance removed); - MapTrapsCached = MapTraps.Values.ToArray(); + MapTraps.TryRemove(trap.Id, out _); + _trapCacheDirty = true; } #endregion @@ -1107,21 +1120,17 @@ private void DespawnGlobalEvents() public Event GetGlobalEventInstance(EventDescriptor eventDescriptor) { - if (GlobalEventInstances.ContainsKey(eventDescriptor)) - { - return GlobalEventInstances[eventDescriptor]; - } - - return null; + GlobalEventInstances.TryGetValue(eventDescriptor, out var evt); + return evt; } public bool FindEvent(EventDescriptor eventDescriptor, EventPageInstance globalClone) { - if (GlobalEventInstances.ContainsKey(eventDescriptor)) + if (GlobalEventInstances.TryGetValue(eventDescriptor, out var evtInstance)) { - for (var i = 0; i < GlobalEventInstances[eventDescriptor].GlobalPageInstance.Length; i++) + for (var i = 0; i < evtInstance.GlobalPageInstance.Length; i++) { - if (GlobalEventInstances[eventDescriptor].GlobalPageInstance[i] == globalClone) + if (evtInstance.GlobalPageInstance[i] == globalClone) { return true; } @@ -1137,11 +1146,9 @@ public void RefreshEventsCache() foreach (var evt in mMapController.EventIds) { var itm = EventDescriptor.Get(evt); - if (itm != null) - { - events.Add(itm); - } + if (itm != null) events.Add(itm); } + EventsCache = events; } #endregion @@ -1163,56 +1170,43 @@ public bool TileBlocked(int x, int y) return true; } - //See if there are any entities in the way - var entities = GetEntities(); - for (var i = 0; i < entities.Count; i++) + var cached = mCachedEntities; + for (var i = 0; i < cached.Length; i++) { - if (entities[i] != null && !(entities[i] is Projectile)) + var e = cached[i]; + if (e != null && e is not Projectile && !e.Passable && e.X == x && e.Y == y) { - //If Npc or Player then blocked.. if resource then check - if (!entities[i].Passable && entities[i].X == x && entities[i].Y == y) - { - return true; - } + return true; } } //Check Global Events foreach (var globalEvent in GlobalEventInstances) { - if (globalEvent.Value != null && globalEvent.Value.PageInstance != null) + if (globalEvent.Value?.PageInstance != null && !globalEvent.Value.PageInstance.Passable && + globalEvent.Value.PageInstance.X == x && globalEvent.Value.PageInstance.Y == y) { - if (!globalEvent.Value.PageInstance.Passable && - globalEvent.Value.PageInstance.X == x && - globalEvent.Value.PageInstance.Y == y) - { - return true; - } + return true; } } return false; } + #endregion #region Packet Batching - public void AddBatchedAnimation(PlayAnimationPacket packet) - { - mMapAnimations.Add(packet); - } - public void AddBatchedMovement(Entity en, bool correction, Player forPlayer) - { - mEntityMovements.Add(en, correction, forPlayer); - } + public void AddBatchedAnimation(PlayAnimationPacket packet) => mMapAnimations.Add(packet); + + public void AddBatchedMovement(Entity en, bool correction, Player forPlayer) => mEntityMovements.Add(en, correction, forPlayer); + + public void AddBatchedActionMessage(ActionMsgPacket packet) => mActionMessages.Add(packet); - public void AddBatchedActionMessage(ActionMsgPacket packet) - { - mActionMessages.Add(packet); - } #endregion #region Caching + public void CacheMapBlocks() { var blocks = new List(); @@ -1221,66 +1215,55 @@ public void CacheMapBlocks() { for (byte y = 0; y < Options.Instance.Map.MapHeight; y++) { - if (mMapController.Attributes[x, y] != null) + if (mMapController.Attributes[x, y] == null) { - if (mMapController.Attributes[x, y].Type == MapAttributeType.Blocked || - mMapController.Attributes[x, y].Type == MapAttributeType.GrappleStone || - mMapController.Attributes[x, y].Type == MapAttributeType.Animation && ((MapAnimationAttribute)mMapController.Attributes[x, y]).IsBlock) - { + continue; + } + + switch (mMapController.Attributes[x, y].Type) + { + case MapAttributeType.Blocked: + case MapAttributeType.GrappleStone: + case MapAttributeType.Animation when + ((MapAnimationAttribute)mMapController.Attributes[x, y]).IsBlock: blocks.Add(new BytePoint(x, y)); npcBlocks.Add(new BytePoint(x, y)); - } - else if (mMapController.Attributes[x, y].Type == MapAttributeType.NpcAvoid) - { + break; + case MapAttributeType.NpcAvoid: npcBlocks.Add(new BytePoint(x, y)); - } + break; } } } - mMapBlocks = blocks.ToArray(); - mNpcMapBlocks = npcBlocks.ToArray(); + mMapBlocks = blocks.ToArray(); mNpcMapBlocks = npcBlocks.ToArray(); } - public Entity[] GetCachedEntities() - { - return mCachedEntities; - } + public Entity[] GetCachedEntities() => mCachedEntities; + + public BytePoint[] GetCachedBlocks(bool isPlayer) => isPlayer ? mMapBlocks : mNpcMapBlocks; - public BytePoint[] GetCachedBlocks(bool isPlayer) - { - return isPlayer ? mMapBlocks : mNpcMapBlocks; - } #endregion #region Updates + private void UpdateEntities(long timeMs) { - var surrMaps = mMapController.GetSurroundingMaps(true); - - // Keep a list of all entities with changed vitals and statusses. var vitalUpdates = new List(); var statusUpdates = new List(); - foreach (var en in mEntities) { - //Let's see if and how long this map has been inactive, if longer than X seconds, regenerate everything on the map - if (timeMs > mLastUpdateTime + Options.Instance.Map.TimeUntilMapCleanup) + if (timeMs > mLastUpdateTime + Options.Instance.Map.TimeUntilMapCleanup && en.Value is Resource or Npc) { - //Regen Everything & Forget Targets - if (en.Value is Resource || en.Value is Npc) + en.Value.RestoreVital(Vital.Health); + en.Value.RestoreVital(Vital.Mana); + if (en.Value is Npc npc) { - en.Value.RestoreVital(Vital.Health); - en.Value.RestoreVital(Vital.Mana); - - if (en.Value is Npc npc) - { - npc.AssignTarget(null); - } - else - { - en.Value.Target = null; - } + npc.AssignTarget(null); + } + else + { + en.Value.Target = null; } } @@ -1306,7 +1289,6 @@ private void UpdateEntities(long timeMs) if (en.Value.StatusesUpdated) { statusUpdates.Add(en.Value); - en.Value.StatusesUpdated = false; } @@ -1369,8 +1351,6 @@ private void ProcessResourceRespawns() } else if (spawnInstance.RespawnTime < now) { - // Check to see if this resource can be respawned, if there's an Npc or Player on it we shouldn't let it respawn yet.. - // Unless of course the resource is walkable regardless. var canSpawn = false; if (spawnInstance.Entity.Descriptor.WalkableBefore) { @@ -1378,19 +1358,30 @@ private void ProcessResourceRespawns() } else { - // Check if this resource is currently stepped on - var spawnBlockers = GetEntities().Where(x => x is Player || x is Npc).ToArray(); - if (!spawnBlockers.Any(e => e.X == spawnInstance.Entity.X && e.Y == spawnInstance.Entity.Y)) + var cached = mCachedEntities; + var blocked = false; + for (var i = 0; i < cached.Length; i++) { - canSpawn = true; + var e = cached[i]; + if ((e is not Player && e is not Npc) || e.X != spawnInstance.Entity.X || e.Y != spawnInstance.Entity.Y) + { + continue; + } + + blocked = true; + break; } + + canSpawn = !blocked; } - if (canSpawn) + if (!canSpawn) { - SpawnMapResource(spawn.Value); - spawnInstance.RespawnTime = -1; + continue; } + + SpawnMapResource(spawn.Value); + spawnInstance.RespawnTime = -1; } } } @@ -1400,7 +1391,6 @@ public void UpdateProjectiles(long timeMs) var spawnDeaths = new List>(); var projDeaths = new List(); - //Process all of the projectiles foreach (var proj in MapProjectilesCached) { proj.Update(projDeaths, spawnDeaths); @@ -1411,7 +1401,6 @@ public void UpdateProjectiles(long timeMs) PacketSender.SendRemoveProjectileSpawns(mMapController.Id, MapInstanceId, projDeaths.ToArray(), spawnDeaths.ToArray()); } - //Process all of the traps foreach (var trap in MapTrapsCached) { trap.Update(); @@ -1440,48 +1429,58 @@ public void UpdateItems(long timeMs) foreach (var itemRespawn in ItemRespawns.Values) { - if (itemRespawn.RespawnTime < timeMs) + if (itemRespawn.RespawnTime >= timeMs) { - SpawnAttributeItem(itemRespawn.AttributeSpawnX, itemRespawn.AttributeSpawnY); - ItemRespawns.TryRemove(itemRespawn.Id, out MapItemSpawn spawn); + continue; } + + SpawnAttributeItem(itemRespawn.AttributeSpawnX, itemRespawn.AttributeSpawnY); + ItemRespawns.TryRemove(itemRespawn.Id, out _); } } + /// + /// Iterate GlobalEventInstances.Values directly + /// Also uses GetCachedSurroundingInstances via GetPlayers(true) + /// Short-circuits active check as soon as one active player is found. + /// private void UpdateGlobalEvents(long timeMs) { - var evts = GlobalEventInstances.Values.ToList(); - for (var i = 0; i < evts.Count; i++) + foreach (var evt in GlobalEventInstances.Values) { //Only do movement processing on the first page. //This is because global events need to keep all of their pages at the same tile //Think about a global event moving randomly that needed to turn into a warewolf and back (separate pages) //If they were in different tiles the transition would make the event jump //Something like described here: https://www.ascensiongamedev.com/community/bug_tracker/intersect/events-randomly-appearing-and-disappearing-r983/ - for (var x = 0; x < evts[i].GlobalPageInstance.Length; x++) + for (var x = 0; x < evt.GlobalPageInstance.Length; x++) { //Gotta figure out if any players are interacting with this event. var active = false; foreach (var player in GetPlayers(true)) { - var eventInstance = player.FindGlobalEventInstance(evts[i].GlobalPageInstance[x]); - if (eventInstance != null && eventInstance.CallStack.Count > 0) + var eventInstance = player.FindGlobalEventInstance(evt.GlobalPageInstance[x]); + if (eventInstance == null || eventInstance.CallStack.Count <= 0) { - active = true; + continue; } + + active = true; + break; } - evts[i].GlobalPageInstance[x].Update(active, timeMs); + evt.GlobalPageInstance[x].Update(active, timeMs); } } } + /// + /// Use GetCachedSurroundingInstances (avoids allocating a new List on every tick). + /// private void SendBatchedPacketsToPlayers() { var nearbyPlayers = new HashSet(); - - // Get all players in surrounding and current maps - foreach (var mapInstance in MapController.GetSurroundingMapInstances(mMapController.Id, MapInstanceId, true)) + foreach (var mapInstance in GetCachedSurroundingInstances(true)) { foreach (var plyr in mapInstance.GetPlayers()) {