diff --git a/Intersect.Server.Core/Networking/PacketSender.cs b/Intersect.Server.Core/Networking/PacketSender.cs index 26285249c0..d6b318d9de 100644 --- a/Intersect.Server.Core/Networking/PacketSender.cs +++ b/Intersect.Server.Core/Networking/PacketSender.cs @@ -12,7 +12,6 @@ using Intersect.Framework.Core.GameObjects.PlayerClass; using Intersect.Framework.Core.GameObjects.Resources; using Intersect.Framework.Core.GameObjects.Variables; -using Intersect.Framework.Core.Network.Packets.Security; using Intersect.Framework.Core.Security; using Intersect.GameObjects; using Intersect.Models; @@ -27,7 +26,6 @@ using Intersect.Server.General; using Intersect.Server.Localization; using Intersect.Server.Maps; -using Intersect.Utilities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -53,6 +51,30 @@ public static void ResetMetrics() SentBytes = 0; } + //Tracks last sent stats per entity so we can skip redundant packet updates. + private sealed record EntityStatsSnapshot(int[] Stats); + + private static readonly Dictionary _lastEntityStatsSnapshot = new Dictionary(); + + // Tracks last sent vitals per entity for map‑wide batch updates. + private sealed record EntityVitalSnapshot(long[] Vitals, long[] MaxVitals, long CombatTimeRemaining); + + private static readonly Dictionary _lastEntityVitalSnapshot = new Dictionary(); + + // Tracks last sent equipment item ids per player. + private static readonly Dictionary _lastPlayerEquipmentSnapshot = new Dictionary(); + + // Tracks last sent statuses per entity so we can skip redundant packet updates. + private sealed record EntityStatusSnapshot(StatusPacket[] Statuses); + + private static readonly Dictionary _lastEntityStatusSnapshot = new Dictionary(); + + // Tracks last sent NPC aggression per (player, npc) pair. + private static readonly Dictionary<(Guid PlayerId, Guid NpcId), NpcAggression> _lastNpcAggressionSnapshot = new Dictionary<(Guid PlayerId, Guid NpcId), NpcAggression>(); + + // Tracks last sent map item state per (player, map) to avoid regenerating and resending already sent item packets. + private static readonly Dictionary<(Guid PlayerId, Guid MapId), int> _lastMapItemsSnapshot = new Dictionary<(Guid PlayerId, Guid MapId), int>(); + //PingPacket public static void SendPing(Client client, bool request = true) { @@ -620,6 +642,24 @@ public static void SendNpcAggressionToProximity(Npc en) var players = mapInstance.GetPlayers(); foreach (var pl in players) { + if (pl == null) + { + continue; + } + + var key = (PlayerId: pl.Id, NpcId: en.Id); + var currentAggro = en.GetAggression(pl); // Returns NpcAggression now + + if (_lastNpcAggressionSnapshot.TryGetValue(key, out var lastAggro)) + { + if (lastAggro.Equals(currentAggro)) + { + // No change in aggression towards this player; skip. + continue; + } + } + + _lastNpcAggressionSnapshot[key] = currentAggro; SendNpcAggressionTo(pl, en); } } @@ -640,30 +680,35 @@ public static void SendNpcAggressionTo(Player player, Npc npc) public static void SendEntityLeaveMap(Entity en, Guid leftMap) { SendDataToMapInstance(leftMap, en.MapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeave(Entity en) { SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeaveLayer(Entity en, Guid mapInstanceId) { SendDataToProximityOnMapInstance(en.MapId, mapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeaveInstanceOfMap(Entity en, Guid mapId, Guid mapInstanceId) { SendDataToProximityOnMapInstance(mapId, mapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeavePacket public static void SendEntityLeaveTo(Player player, Entity en) { player.SendPacket(new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EventLeavePacket @@ -720,14 +765,16 @@ public static void SendGameData(Client client) continue; } - if ((GameObjectType)val == GameObjectType.Shop || - (GameObjectType)val == GameObjectType.Event || - (GameObjectType)val == GameObjectType.PlayerVariable || - (GameObjectType)val == GameObjectType.ServerVariable || - (GameObjectType)val == GameObjectType.GuildVariable || - (GameObjectType)val == GameObjectType.UserVariable) + switch ((GameObjectType)val) { - SendGameObjects(client, (GameObjectType)val, null); + case GameObjectType.Shop: + case GameObjectType.Event: + case GameObjectType.PlayerVariable: + case GameObjectType.ServerVariable: + case GameObjectType.GuildVariable: + case GameObjectType.UserVariable: + SendGameObjects(client, (GameObjectType)val, null); + break; } } } @@ -751,25 +798,105 @@ public static void CacheGameDataPacket() //Send massive amounts of game data foreach (var val in Enum.GetValues(typeof(GameObjectType))) { - if ((GameObjectType)val == GameObjectType.Map) + switch ((GameObjectType)val) { - continue; + case GameObjectType.Map: + case GameObjectType.Shop: + case GameObjectType.Event: + case GameObjectType.PlayerVariable: + case GameObjectType.ServerVariable: + case GameObjectType.GuildVariable: + case GameObjectType.UserVariable: + continue; + default: + SendGameObjects(null, (GameObjectType)val, gameObjects); + break; } + } + + CachedGameDataPacket = new GameDataPacket(gameObjects.ToArray(), CustomColors.Json()); + } + + private static void ClearEntitySnapshotCache(Entity en) + { + if (en != null) + { + _lastEntityStatsSnapshot.Remove(en.Id); + _lastEntityVitalSnapshot.Remove(en.Id); + _lastPlayerEquipmentSnapshot.Remove(en.Id); + _lastEntityStatusSnapshot.Remove(en.Id); + ClearNpcAggressionSnapshot(en); + } + } + + private static void ClearNpcAggressionSnapshot(Entity en) + { + if (en == null) + { + return; + } + + // If this entity is an NPC, drop all entries referencing that NPC. + if (en is Npc npc) + { + var npcId = npc.Id; + var keysToRemove = _lastNpcAggressionSnapshot + .Where(kvp => kvp.Key.NpcId == npcId) + .Select(kvp => kvp.Key) + .ToArray(); - if ((GameObjectType)val == GameObjectType.Shop || - (GameObjectType)val == GameObjectType.Event || - (GameObjectType)val == GameObjectType.PlayerVariable || - (GameObjectType)val == GameObjectType.ServerVariable || - (GameObjectType)val == GameObjectType.GuildVariable || - (GameObjectType)val == GameObjectType.UserVariable) + foreach (var key in keysToRemove) + { + _lastNpcAggressionSnapshot.Remove(key); + } + } + + // If this entity is a Player, drop all entries referencing that player. + if (en is Player player) + { + var playerId = player.Id; + var keysToRemove = _lastNpcAggressionSnapshot + .Where(kvp => kvp.Key.PlayerId == playerId) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in keysToRemove) + { + _lastNpcAggressionSnapshot.Remove(key); + } + } + } + + private static int ComputeVisibleMapItemsHash(Player player, Guid mapId) + { + if (player == null) + { + return 0; + } + + if (!MapController.TryGetInstanceFromMap(mapId, player.MapInstanceId, out var mapInstance)) + { + return 0; + } + + var hash = new HashCode(); + + foreach (var item in mapInstance.AllMapItems.Values) + { + if (!item.VisibleToAll && item.Owner != player.Id) { continue; } - SendGameObjects(null, (GameObjectType)val, gameObjects); + hash.Add(item.TileIndex); + hash.Add(item.UniqueId); + hash.Add(item.ItemId); + hash.Add(item.BagId); + hash.Add(item.Quantity); + hash.Add(item.Properties); } - CachedGameDataPacket = new GameDataPacket(gameObjects.ToArray(), CustomColors.Json()); + return hash.ToHashCode(); } /// @@ -945,40 +1072,108 @@ public static EntityVitalsPacket GenerateEntityVitalsPacket(Entity en) //EntityVitalsPacket public static void SendMapEntityVitalUpdate(MapController map, Entity[] entities, Guid mapInstanceId) { - // Generate a list of vitals to send to our users! + if (map == null || entities == null || entities.Length == 0) + { + return; + } + var data = new List(); + foreach (var entity in entities) { - data.Add(new EntityVitalData() + if (entity == null) + { + continue; + } + + var vitals = entity.GetVitals(); + var maxVitals = entity.GetMaxVitals(); + var combatRemaining = entity.CombatTimer - Timing.Global.Milliseconds; + + var newSnapshot = new EntityVitalSnapshot(vitals, maxVitals, combatRemaining); + + if (_lastEntityVitalSnapshot.TryGetValue(entity.Id, out var oldSnapshot)) { - Id = entity.Id, - Type = entity.GetEntityType(), - Vitals = entity.GetVitals(), - MaxVitals = entity.GetMaxVitals(), - CombatTimeRemaining = entity.CombatTimer - Timing.Global.Milliseconds - }); + if (oldSnapshot.Vitals.SequenceEqual(newSnapshot.Vitals) && + oldSnapshot.MaxVitals.SequenceEqual(newSnapshot.MaxVitals) && + oldSnapshot.CombatTimeRemaining == newSnapshot.CombatTimeRemaining) + { + // Nothing relevant changed, skip this entity. + continue; + } + } + + _lastEntityVitalSnapshot[entity.Id] = newSnapshot; + + data.Add( + new EntityVitalData + { + Id = entity.Id, + Type = entity.GetEntityType(), + Vitals = vitals, + MaxVitals = maxVitals, + CombatTimeRemaining = combatRemaining + } + ); } - // Send the data to the surroundings! - SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityVitalsPacket(map.Id, data.ToArray())); + // Only send if at least one entity actually changed. + if (data.Count > 0) + { + SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityVitalsPacket(map.Id, data.ToArray())); + } } public static void SendMapEntityStatusUpdate(MapController map, Entity[] entities, Guid mapInstanceId) { - // Generate a list of statuses to send to our users! + if (map == null || entities == null || entities.Length == 0) + { + return; + } + var data = new List(); + foreach (var entity in entities) { - data.Add(new EntityStatusData() + if (entity == null) { - Id = entity.Id, - Type = entity.GetEntityType(), - Statuses = entity.StatusPackets() - }); + continue; + } + + var statuses = entity.StatusPackets(); // Existing API + + var newSnapshot = new EntityStatusSnapshot(statuses); + + if (_lastEntityStatusSnapshot.TryGetValue(entity.Id, out var oldSnapshot)) + { + if (oldSnapshot.Statuses.SequenceEqual(newSnapshot.Statuses)) + { + // No status change; skip this entity. + continue; + } + } + + _lastEntityStatusSnapshot[entity.Id] = newSnapshot; + + data.Add( + new EntityStatusData + { + Id = entity.Id, + Type = entity.GetEntityType(), + Statuses = statuses + } + ); } - // Send the data to the surroundings! - SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityStatusPacket(map.Id, data.ToArray())); + // Only send if at least one entity actually changed. + if (data.Count > 0) + { + SendDataToProximityOnMapInstance( + map.Id, + mapInstanceId, + new MapEntityStatusPacket(map.Id, data.ToArray()) + ); + } } //EntityStatsPacket @@ -989,7 +1184,26 @@ public static void SendEntityStats(Entity en) return; } - SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, GenerateEntityStatsPacket(en), null, TransmissionMode.Any); + // Build current stats array (same logic as GenerateEntityStatsPacket) + var stats = new int[Enum.GetValues().Length]; + for (var i = 0; i < stats.Length; i++) + { + stats[i] = en.Stat[i].Value(); + } + + var newSnapshot = new EntityStatsSnapshot(stats); + + if (_lastEntityStatsSnapshot.TryGetValue(en.Id, out var oldSnapshot)) + { + if (oldSnapshot.Stats.SequenceEqual(newSnapshot.Stats)) + { + // No visible stat changes; skip the packet update entirely. + return; + } + } + + _lastEntityStatsSnapshot[en.Id] = newSnapshot; + SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityStatsPacket(en.Id, en.GetEntityType(), en.MapId, stats), null, TransmissionMode.Any); } //EntityVitalsPacket @@ -1039,6 +1253,7 @@ public static void SendEntityAttack(Entity en, int attackTime, bool isBlocking = public static void SendEntityDie(Entity en) { SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityDiePacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityDirectionPacket @@ -1127,6 +1342,25 @@ public static void SendMapItemsToProximity(Guid mapId, MapInstance mapInstance) // Send all players on a map instance and its surrounding instances a map item update. foreach (var player in mapInstance.GetPlayers(true)) { + if (player == null) + { + continue; + } + + var key = (PlayerId: player.Id, MapId: mapId); + var newHash = ComputeVisibleMapItemsHash(player, mapId); + + if (_lastMapItemsSnapshot.TryGetValue(key, out var lastHash)) + { + if (lastHash == newHash) + { + // Visible item set unchanged for this player on this map; skip. + continue; + } + } + + _lastMapItemsSnapshot[key] = newHash; + player.SendPacket(GenerateMapItemsPacket(player, mapId)); } } @@ -1295,6 +1529,36 @@ public static void SendPlayerEquipmentTo(Player forPlayer, Player en) //EquipmentPacket public static void SendPlayerEquipmentToProximity(Player en) { + if (en == null) + { + return; + } + + var slots = Options.Instance.Equipment.Slots.Count; + var equipment = new Guid[slots]; + + for (var i = 0; i < slots; i++) + { + if (en.Equipment[i] == -1 || en.Items[en.Equipment[i]].ItemId == Guid.Empty) + { + equipment[i] = Guid.Empty; + } + else + { + equipment[i] = en.Items[en.Equipment[i]].ItemId; + } + } + + if (_lastPlayerEquipmentSnapshot.TryGetValue(en.Id, out var last)) + { + if (last.Length == equipment.Length && last.SequenceEqual(equipment)) + { + // Nothing changed, skip entirely. + return; + } + } + + _lastPlayerEquipmentSnapshot[en.Id] = equipment; SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, GenerateEquipmentPacket(null, en), null, TransmissionMode.Any); SendPlayerEquipmentTo(en, en); }