using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Numerics; using System.Threading; using System.Threading.Tasks; using log4net; using ACE.Common.Performance; using ACE.Database; using ACE.Database.Models.World; using ACE.DatLoader; using ACE.DatLoader.FileTypes; using ACE.Entity; using ACE.Entity.Enum; using ACE.Entity.Enum.Properties; using ACE.Entity.Models; using ACE.Server.Entity.Actions; using ACE.Server.Factories; using ACE.Server.Managers; using ACE.Server.Physics.Common; using ACE.Server.Network.GameMessages; using ACE.Server.WorldObjects; using Position = ACE.Entity.Position; namespace ACE.Server.Entity { /// /// the gist of a landblock is that, generally, everything on it publishes /// to and subscribes to everything else in the landblock. x/y in an outdoor /// landblock goes from 0 to 192. "indoor" (dungeon) landblocks have no /// functional limit as players can't freely roam in/out of them /// public class Landblock : IActor { private static readonly ILog log = LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType); public static float AdjacencyLoadRange { get; } = 96f; public static float OutdoorChatRange { get; } = 75f; public static float IndoorChatRange { get; } = 25f; public static float MaxXY { get; } = 192f; public static float MaxObjectRange { get; } = 192f; public static float MaxObjectGhostRange { get; } = 250f; public LandblockId Id { get; } /// /// Flag indicates if this landblock is permanently loaded (for example, towns on high-traffic servers) /// public bool Permaload = false; /// /// Flag indicates if this landblock has no keep alive objects /// public bool HasNoKeepAliveObjects = true; /// /// This must be true before a player enters a landblock. /// This prevents a player from possibly pasing through a door that hasn't spawned in yet, and other scenarios. /// public bool CreateWorldObjectsCompleted { get; private set; } private DateTime lastActiveTime; /// /// Dormant landblocks suppress Monster AI ticking and physics processing /// public bool IsDormant; private readonly Dictionary worldObjects = new Dictionary(); private readonly Dictionary pendingAdditions = new Dictionary(); private readonly List pendingRemovals = new List(); // Cache used for Tick efficiency private readonly List players = new List(); private readonly LinkedList sortedCreaturesByNextTick = new LinkedList(); private readonly LinkedList sortedWorldObjectsByNextHeartbeat = new LinkedList(); private readonly LinkedList sortedGeneratorsByNextGeneratorUpdate = new LinkedList(); private readonly LinkedList sortedGeneratorsByNextRegeneration = new LinkedList(); /// /// This is used to detect and manage cross-landblock group (which is potentially cross-thread) operations. /// public LandblockGroup CurrentLandblockGroup { get; internal set; } public List Adjacents = new List(); private readonly ActionQueue actionQueue = new ActionQueue(); /// /// Landblocks heartbeat every 5 seconds /// private static readonly TimeSpan heartbeatInterval = TimeSpan.FromSeconds(5); private DateTime lastHeartBeat = DateTime.MinValue; /// /// Landblock items will be saved to the database every 5 minutes /// private static readonly TimeSpan databaseSaveInterval = TimeSpan.FromMinutes(5); private DateTime lastDatabaseSave = DateTime.MinValue; /// /// Landblocks which have been inactive for this many seconds will be dormant /// private static readonly TimeSpan dormantInterval = TimeSpan.FromMinutes(1); /// /// Landblocks which have been inactive for this many seconds will be unloaded /// public static readonly TimeSpan UnloadInterval = TimeSpan.FromMinutes(5); /// /// The clientlib backing store landblock /// Eventually these classes could be merged, but for now they are separate... /// public Physics.Common.Landblock PhysicsLandblock { get; } public CellLandblock CellLandblock { get; } public LandblockInfo LandblockInfo { get; } /// /// The landblock static meshes for /// collision detection and physics simulation /// public LandblockMesh LandblockMesh { get; private set; } public List LandObjects { get; private set; } public List Buildings { get; private set; } public List WeenieMeshes { get; private set; } public List Scenery { get; private set; } public readonly RateMonitor Monitor5m = new RateMonitor(); private readonly TimeSpan last5mClearInteval = TimeSpan.FromMinutes(5); private DateTime last5mClear; public readonly RateMonitor Monitor1h = new RateMonitor(); private readonly TimeSpan last1hClearInteval = TimeSpan.FromHours(1); private DateTime last1hClear; private bool monitorsRequireEventStart = true; // Used for cumulative ServerPerformanceMonitor event recording private readonly Stopwatch stopwatch = new Stopwatch(); private EnvironChangeType fogColor; public EnvironChangeType FogColor { get { if (LandblockManager.GlobalFogColor.HasValue) return LandblockManager.GlobalFogColor.Value; return fogColor; } set => fogColor = value; } public Landblock(LandblockId id) { //log.DebugFormat("Landblock({0:X8})", (id.Raw | 0xFFFF)); Id = id; CellLandblock = DatManager.CellDat.ReadFromDat(Id.Raw | 0xFFFF); LandblockInfo = DatManager.CellDat.ReadFromDat((uint)Id.Landblock << 16 | 0xFFFE); lastActiveTime = DateTime.UtcNow; var cellLandblock = DBObj.GetCellLandblock(Id.Raw | 0xFFFF); PhysicsLandblock = new Physics.Common.Landblock(cellLandblock); } public void Init(bool reload = false) { if (!reload) PhysicsLandblock.PostInit(); Task.Run(() => { CreateWorldObjects(); SpawnDynamicShardObjects(); SpawnEncounters(); }); //LoadMeshes(objects); } /// /// Monster Locations, Generators /// This will be called from a separate task from our constructor. Use thread safety when interacting with this landblock. /// private void CreateWorldObjects() { var objects = DatabaseManager.World.GetCachedInstancesByLandblock(Id.Landblock); var shardObjects = DatabaseManager.Shard.BaseDatabase.GetStaticObjectsByLandblock(Id.Landblock); var factoryObjects = WorldObjectFactory.CreateNewWorldObjects(objects, shardObjects); actionQueue.EnqueueAction(new ActionEventDelegate(() => { // for mansion linking var houses = new List(); foreach (var fo in factoryObjects) { WorldObject parent = null; if (fo.WeenieType == WeenieType.House) { var house = fo as House; Houses.Add(house); if (fo.HouseType == HouseType.Mansion) { houses.Add(house); house.LinkedHouses.Add(houses[0]); if (houses.Count > 1) { houses[0].LinkedHouses.Add(house); parent = houses[0]; } } } AddWorldObject(fo); fo.ActivateLinks(objects, shardObjects, parent); if (fo.PhysicsObj != null) fo.PhysicsObj.Order = 0; } CreateWorldObjectsCompleted = true; PhysicsLandblock.SortObjects(); })); } /// /// Corpses /// This will be called from a separate task from our constructor. Use thread safety when interacting with this landblock. /// private void SpawnDynamicShardObjects() { var dynamics = DatabaseManager.Shard.BaseDatabase.GetDynamicObjectsByLandblock(Id.Landblock); var factoryShardObjects = WorldObjectFactory.CreateWorldObjects(dynamics); actionQueue.EnqueueAction(new ActionEventDelegate(() => { foreach (var fso in factoryShardObjects) AddWorldObject(fso); })); } /// /// Spawns the semi-randomized monsters scattered around the outdoors /// This will be called from a separate task from our constructor. Use thread safety when interacting with this landblock. /// private void SpawnEncounters() { // get the encounter spawns for this landblock var encounters = DatabaseManager.World.GetCachedEncountersByLandblock(Id.Landblock); foreach (var encounter in encounters) { var wo = WorldObjectFactory.CreateNewWorldObject(encounter.WeenieClassId); if (wo == null) continue; actionQueue.EnqueueAction(new ActionEventDelegate(() => { var xPos = Math.Clamp(encounter.CellX * 24.0f, 0.5f, 191.5f); var yPos = Math.Clamp(encounter.CellY * 24.0f, 0.5f, 191.5f); var pos = new Physics.Common.Position(); pos.ObjCellID = (uint)(Id.Landblock << 16) | 1; pos.Frame = new Physics.Animation.AFrame(new Vector3(xPos, yPos, 0), Quaternion.Identity); pos.adjust_to_outside(); pos.Frame.Origin.Z = PhysicsLandblock.GetZ(pos.Frame.Origin); wo.Location = new Position(pos.ObjCellID, pos.Frame.Origin, pos.Frame.Orientation); var sortCell = LScape.get_landcell(pos.ObjCellID) as SortCell; if (sortCell != null && sortCell.has_building()) { wo.Destroy(); return; } if (PropertyManager.GetBool("override_encounter_spawn_rates").Item) { wo.RegenerationInterval = PropertyManager.GetDouble("encounter_regen_interval").Item; wo.ReinitializeHeartbeats(); if (wo.Biota.PropertiesGenerator != null) { // While this may be ugly, it's done for performance reasons. // Common weenie properties are not cloned into the bota on creation. Instead, the biota references simply point to the weenie collections. // The problem here is that we want to update one of those common collection properties. If the biota is referencing the weenie collection, // then we'll end up updating the global weenie (from the cache), instead of just this specific biota. if (wo.Biota.PropertiesGenerator == wo.Weenie.PropertiesGenerator) { wo.Biota.PropertiesGenerator = new List(wo.Weenie.PropertiesGenerator.Count); foreach (var record in wo.Weenie.PropertiesGenerator) wo.Biota.PropertiesGenerator.Add(record.Clone()); } foreach (var profile in wo.Biota.PropertiesGenerator) profile.Delay = (float)PropertyManager.GetDouble("encounter_delay").Item; } } if (!AddWorldObject(wo)) wo.Destroy(); })); } } /// /// Loads the meshes for the landblock /// This isn't used by ACE, but we still retain it for the following reason: /// its useful, concise, high level overview code for everything needed to load landblocks, all their objects, scenery, polygons /// without getting into all of the low level methods that acclient uses to do it /// private void LoadMeshes(List objects) { LandblockMesh = new LandblockMesh(Id); LoadLandObjects(); LoadBuildings(); LoadWeenies(objects); LoadScenery(); } /// /// Loads the meshes for the static landblock objects, /// also known as obstacles /// private void LoadLandObjects() { LandObjects = new List(); foreach (var obj in LandblockInfo.Objects) LandObjects.Add(new ModelMesh(obj.Id, obj.Frame)); } /// /// Loads the meshes for the buildings on the landblock /// private void LoadBuildings() { Buildings = new List(); foreach (var obj in LandblockInfo.Buildings) Buildings.Add(new ModelMesh(obj.ModelId, obj.Frame)); } /// /// Loads the meshes for the weenies on the landblock /// private void LoadWeenies(List objects) { WeenieMeshes = new List(); foreach (var obj in objects) { var weenie = DatabaseManager.World.GetCachedWeenie(obj.WeenieClassId); WeenieMeshes.Add( new ModelMesh(weenie.GetProperty(PropertyDataId.Setup) ?? 0, new DatLoader.Entity.Frame(new Position(obj.ObjCellId, obj.OriginX, obj.OriginY, obj.OriginZ, obj.AnglesX, obj.AnglesY, obj.AnglesZ, obj.AnglesW)))); } } /// /// Loads the meshes for the scenery on the landblock /// private void LoadScenery() { Scenery = Entity.Scenery.Load(this); } /// /// This should be called before TickLandblockGroupThreadSafeWork() and before Tick() /// public void TickPhysics(double portalYearTicks, ConcurrentBag movedObjects) { if (IsDormant) return; Monitor5m.Restart(); Monitor1h.Restart(); monitorsRequireEventStart = false; ProcessPendingWorldObjectAdditionsAndRemovals(); foreach (WorldObject wo in worldObjects.Values) { // set to TRUE if object changes landblock var landblockUpdate = wo.UpdateObjectPhysics(); if (landblockUpdate) movedObjects.Add(wo); } Monitor5m.Pause(); Monitor1h.Pause(); } /// /// This will tick anything that can be multi-threaded safely using LandblockGroups as thread boundaries /// This should be called after TickPhysics() and before Tick() /// public void TickMultiThreadedWork(double currentUnixTime) { if (monitorsRequireEventStart) { Monitor5m.Restart(); Monitor1h.Restart(); } else { Monitor5m.Resume(); Monitor1h.Resume(); } stopwatch.Restart(); // This will consist of the following work: // - this.CreateWorldObjects // - this.SpawnDynamicShardObjects // - this.SpawnEncounters // - Adding items back onto the landblock from failed player movements: Player_Inventory.cs DoHandleActionPutItemInContainer() // - Executing trade between two players: Player_Trade.cs FinalizeTrade() actionQueue.RunActions(); ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_RunActions, stopwatch.Elapsed.TotalSeconds); ProcessPendingWorldObjectAdditionsAndRemovals(); // When a WorldObject Ticks, it can end up adding additional WorldObjects to this landblock if (!IsDormant) { stopwatch.Restart(); while (sortedCreaturesByNextTick.Count > 0) // Monster_Tick() { var first = sortedCreaturesByNextTick.First.Value; // If they wanted to run before or at now if (first.NextMonsterTickTime <= currentUnixTime) { sortedCreaturesByNextTick.RemoveFirst(); first.Monster_Tick(currentUnixTime); sortedCreaturesByNextTick.AddLast(first); // All creatures tick at a fixed interval } else { break; } } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_Monster_Tick, stopwatch.Elapsed.TotalSeconds); } stopwatch.Restart(); while (sortedGeneratorsByNextGeneratorUpdate.Count > 0) { var first = sortedGeneratorsByNextGeneratorUpdate.First.Value; // If they wanted to run before or at now if (first.NextGeneratorUpdateTime <= currentUnixTime) { sortedGeneratorsByNextGeneratorUpdate.RemoveFirst(); first.GeneratorUpdate(currentUnixTime); //InsertWorldObjectIntoSortedGeneratorUpdateList(first); sortedGeneratorsByNextGeneratorUpdate.AddLast(first); } else { break; } } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_GeneratorUpdate, stopwatch.Elapsed.TotalSeconds); stopwatch.Restart(); while (sortedGeneratorsByNextRegeneration.Count > 0) // GeneratorRegeneration() { var first = sortedGeneratorsByNextRegeneration.First.Value; //Console.WriteLine($"{first.Name}.Landblock_Tick_GeneratorRegeneration({currentUnixTime})"); // If they wanted to run before or at now if (first.NextGeneratorRegenerationTime <= currentUnixTime) { sortedGeneratorsByNextRegeneration.RemoveFirst(); first.GeneratorRegeneration(currentUnixTime); InsertWorldObjectIntoSortedGeneratorRegenerationList(first); // Generators can have regnerations at different intervals } else { break; } } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_GeneratorRegeneration, stopwatch.Elapsed.TotalSeconds); // Heartbeat stopwatch.Restart(); if (lastHeartBeat + heartbeatInterval <= DateTime.UtcNow) { var thisHeartBeat = DateTime.UtcNow; ProcessPendingWorldObjectAdditionsAndRemovals(); // Decay world objects if (lastHeartBeat != DateTime.MinValue) { foreach (var wo in worldObjects.Values) { if (wo.IsDecayable()) wo.Decay(thisHeartBeat - lastHeartBeat); } } if (!Permaload && HasNoKeepAliveObjects) { if (lastActiveTime + dormantInterval < thisHeartBeat) { if (!IsDormant) { var spellProjectiles = worldObjects.Values.Where(i => i is SpellProjectile).ToList(); foreach (var spellProjectile in spellProjectiles) { spellProjectile.PhysicsObj.set_active(false); spellProjectile.Destroy(); } } IsDormant = true; } if (lastActiveTime + UnloadInterval < thisHeartBeat) LandblockManager.AddToDestructionQueue(this); } //log.Info($"Landblock {Id.ToString()}.Tick({currentUnixTime}).Landblock_Tick_Heartbeat: thisHeartBeat: {thisHeartBeat.ToString()} | lastHeartBeat: {lastHeartBeat.ToString()} | worldObjects.Count: {worldObjects.Count()}"); lastHeartBeat = thisHeartBeat; } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_Heartbeat, stopwatch.Elapsed.TotalSeconds); // Database Save stopwatch.Restart(); if (lastDatabaseSave + databaseSaveInterval <= DateTime.UtcNow) { ProcessPendingWorldObjectAdditionsAndRemovals(); SaveDB(); lastDatabaseSave = DateTime.UtcNow; } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_Database_Save, stopwatch.Elapsed.TotalSeconds); Monitor5m.Pause(); Monitor1h.Pause(); } /// /// This will tick everything that should be done single threaded on the main ACE World thread /// This should be called after TickPhysics() and after Tick() /// public void TickSingleThreadedWork(double currentUnixTime) { if (monitorsRequireEventStart) { Monitor5m.Restart(); Monitor1h.Restart(); } else { Monitor5m.Resume(); Monitor1h.Resume(); } ProcessPendingWorldObjectAdditionsAndRemovals(); stopwatch.Restart(); foreach (var player in players) player.Player_Tick(currentUnixTime); ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_Player_Tick, stopwatch.Elapsed.TotalSeconds); stopwatch.Restart(); while (sortedWorldObjectsByNextHeartbeat.Count > 0) // Heartbeat() { var first = sortedWorldObjectsByNextHeartbeat.First.Value; // If they wanted to run before or at now if (first.NextHeartbeatTime <= currentUnixTime) { sortedWorldObjectsByNextHeartbeat.RemoveFirst(); first.Heartbeat(currentUnixTime); InsertWorldObjectIntoSortedHeartbeatList(first); // WorldObjects can have heartbeats at different intervals } else { break; } } ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Landblock_Tick_WorldObject_Heartbeat, stopwatch.Elapsed.TotalSeconds); Monitor5m.RegisterEventEnd(); Monitor1h.RegisterEventEnd(); monitorsRequireEventStart = true; if (DateTime.UtcNow - last5mClear >= last5mClearInteval) { Monitor5m.ClearEventHistory(); last5mClear = DateTime.UtcNow; } if (DateTime.UtcNow - last1hClear >= last1hClearInteval) { Monitor1h.ClearEventHistory(); last1hClear = DateTime.UtcNow; } } private void ProcessPendingWorldObjectAdditionsAndRemovals() { if (pendingAdditions.Count > 0) { foreach (var kvp in pendingAdditions) { worldObjects[kvp.Key] = kvp.Value; if (kvp.Value is Player player) players.Add(player); else if (kvp.Value is Creature creature) sortedCreaturesByNextTick.AddLast(creature); InsertWorldObjectIntoSortedHeartbeatList(kvp.Value); InsertWorldObjectIntoSortedGeneratorUpdateList(kvp.Value); InsertWorldObjectIntoSortedGeneratorRegenerationList(kvp.Value); if (kvp.Value.WeenieClassId == 80007) // Landblock KeepAlive weenie (ACE custom) HasNoKeepAliveObjects = false; } pendingAdditions.Clear(); } if (pendingRemovals.Count > 0) { foreach (var objectGuid in pendingRemovals) { if (worldObjects.Remove(objectGuid, out var wo)) { if (wo is Player player) players.Remove(player); else if (wo is Creature creature) sortedCreaturesByNextTick.Remove(creature); sortedWorldObjectsByNextHeartbeat.Remove(wo); sortedGeneratorsByNextGeneratorUpdate.Remove(wo); sortedGeneratorsByNextRegeneration.Remove(wo); if (wo.WeenieClassId == 80007) // Landblock KeepAlive weenie (ACE custom) { var keepAliveObject = worldObjects.Values.FirstOrDefault(w => w.WeenieClassId == 80007); if (keepAliveObject == null) HasNoKeepAliveObjects = true; } } } pendingRemovals.Clear(); } } private void InsertWorldObjectIntoSortedHeartbeatList(WorldObject worldObject) { // If you want to add checks to exclude certain object types from heartbeating, you would do it here if (worldObject.NextHeartbeatTime == double.MaxValue) return; if (sortedWorldObjectsByNextHeartbeat.Count == 0) { sortedWorldObjectsByNextHeartbeat.AddFirst(worldObject); return; } if (sortedWorldObjectsByNextHeartbeat.Last.Value.NextHeartbeatTime <= worldObject.NextHeartbeatTime) { sortedWorldObjectsByNextHeartbeat.AddLast(worldObject); return; } var currentNode = sortedWorldObjectsByNextHeartbeat.First; while (currentNode != null) { if (worldObject.NextHeartbeatTime <= currentNode.Value.NextHeartbeatTime) { sortedWorldObjectsByNextHeartbeat.AddBefore(currentNode, worldObject); return; } currentNode = currentNode.Next; } sortedWorldObjectsByNextHeartbeat.AddLast(worldObject); // This line really shouldn't be hit } private void InsertWorldObjectIntoSortedGeneratorUpdateList(WorldObject worldObject) { // If you want to add checks to exclude certain object types from heartbeating, you would do it here if (worldObject.NextGeneratorUpdateTime == double.MaxValue) return; if (sortedGeneratorsByNextGeneratorUpdate.Count == 0) { sortedGeneratorsByNextGeneratorUpdate.AddFirst(worldObject); return; } if (sortedGeneratorsByNextGeneratorUpdate.Last.Value.NextGeneratorUpdateTime <= worldObject.NextGeneratorUpdateTime) { sortedGeneratorsByNextGeneratorUpdate.AddLast(worldObject); return; } var currentNode = sortedGeneratorsByNextGeneratorUpdate.First; while (currentNode != null) { if (worldObject.NextGeneratorUpdateTime <= currentNode.Value.NextGeneratorUpdateTime) { sortedGeneratorsByNextGeneratorUpdate.AddBefore(currentNode, worldObject); return; } currentNode = currentNode.Next; } sortedGeneratorsByNextGeneratorUpdate.AddLast(worldObject); // This line really shouldn't be hit } private void InsertWorldObjectIntoSortedGeneratorRegenerationList(WorldObject worldObject) { // If you want to add checks to exclude certain object types from heartbeating, you would do it here if (worldObject.NextGeneratorRegenerationTime == double.MaxValue) return; if (sortedGeneratorsByNextRegeneration.Count == 0) { sortedGeneratorsByNextRegeneration.AddFirst(worldObject); return; } if (sortedGeneratorsByNextRegeneration.Last.Value.NextGeneratorRegenerationTime <= worldObject.NextGeneratorRegenerationTime) { sortedGeneratorsByNextRegeneration.AddLast(worldObject); return; } var currentNode = sortedGeneratorsByNextRegeneration.First; while (currentNode != null) { if (worldObject.NextGeneratorRegenerationTime <= currentNode.Value.NextGeneratorRegenerationTime) { sortedGeneratorsByNextRegeneration.AddBefore(currentNode, worldObject); return; } currentNode = currentNode.Next; } sortedGeneratorsByNextRegeneration.AddLast(worldObject); // This line really shouldn't be hit } public void ResortWorldObjectIntoSortedGeneratorRegenerationList(WorldObject worldObject) { if (sortedGeneratorsByNextRegeneration.Contains(worldObject)) { sortedGeneratorsByNextRegeneration.Remove(worldObject); InsertWorldObjectIntoSortedGeneratorRegenerationList(worldObject); } } public void EnqueueAction(IAction action) { actionQueue.EnqueueAction(action); } /// /// This will fail if the wo doesn't have a valid location. /// public bool AddWorldObject(WorldObject wo) { if (wo.Location == null) { log.DebugFormat("Landblock 0x{0} failed to add 0x{1:X8} {2}. Invalid Location", Id, wo.Biota.Id, wo.Name); return false; } return AddWorldObjectInternal(wo); } public void AddWorldObjectForPhysics(WorldObject wo) { AddWorldObjectInternal(wo); } private bool AddWorldObjectInternal(WorldObject wo) { if (LandblockManager.CurrentlyTickingLandblockGroupsMultiThreaded) { if (CurrentLandblockGroup != null && CurrentLandblockGroup != LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value) { // Prevent possible multi-threaded crash // The following scenario can happen rarely in ACE, all in the same call stack with no ActionQueue usage: // Moster successfully lands an attack on a player that procs a cloak spell // The code goes through and does the LaunchSpellProjectiles() which adds the spell projectiles to (presumably) the players landblock // For some unknown reason, the LandblockGroup/Thread where the monster exists seems to be a different LandblockGroup/Thread where the spells are added to. // Maybe there's a player death race condition? Maybe it's a teleport race condition? I dunno. // Because this happens so rarely, and, it only seems to affect cloak projectiles, and, cloak projectiles are pretty benign, we simply don't add the object, and only log it as a warning. if (wo.WeenieType == WeenieType.ProjectileSpell) { log.Warn($"Landblock 0x{Id} entered AddWorldObjectInternal in a cross-thread operation for a ProjectileSpell. This is normally not an issue unless it's happening more than once an hour."); return false; } log.Error($"Landblock 0x{Id} entered AddWorldObjectInternal in a cross-thread operation."); log.Error($"Landblock 0x{Id} CurrentLandblockGroup: {CurrentLandblockGroup}"); log.Error($"LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value: {LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value}"); log.Error($"wo: 0x{wo.Guid}:{wo.Name} [{wo.WeenieClassId} - {wo.WeenieType}], previous landblock 0x{wo.CurrentLandblock?.Id}"); if (wo.WeenieType == WeenieType.ProjectileSpell) { if (wo.ProjectileSource != null) log.Error($"wo.ProjectileSource: 0x{wo.ProjectileSource?.Guid}:{wo.ProjectileSource?.Name}, position: {wo.ProjectileSource?.Location}"); if (wo.ProjectileTarget != null) log.Error($"wo.ProjectileTarget: 0x{wo.ProjectileTarget?.Guid}:{wo.ProjectileTarget?.Name}, position: {wo.ProjectileTarget?.Location}"); } log.Error(System.Environment.StackTrace); log.Error("PLEASE REPORT THIS TO THE ACE DEV TEAM !!!"); // This may still crash... } } wo.CurrentLandblock = this; if (wo.PhysicsObj == null) wo.InitPhysicsObj(); else wo.PhysicsObj.set_object_guid(wo.Guid); // re-add to ServerObjectManager if (wo.PhysicsObj.CurCell == null) { var success = wo.AddPhysicsObj(); if (!success) { wo.CurrentLandblock = null; if (wo.Generator != null) { if (log.IsDebugEnabled) log.Debug($"AddWorldObjectInternal: couldn't spawn 0x{wo.Guid}:{wo.Name} [{wo.WeenieClassId} - {wo.WeenieType}] at {wo.Location.ToLOCString()} from generator {wo.Generator.WeenieClassId} - 0x{wo.Generator.Guid}:{wo.Generator.Name}"); wo.NotifyOfEvent(RegenerationType.PickUp); // Notify generator the generated object is effectively destroyed, use Pickup to catch both cases. } else if (wo.IsGenerator) // Some generators will fail random spawns if they're circumference spans over water or cliff edges { if (log.IsDebugEnabled) log.Debug($"AddWorldObjectInternal: couldn't spawn generator 0x{wo.Guid}:{wo.Name} [{wo.WeenieClassId} - {wo.WeenieType}] at {wo.Location.ToLOCString()}"); } else if (wo.ProjectileTarget == null && !(wo is SpellProjectile)) log.Warn($"AddWorldObjectInternal: couldn't spawn 0x{wo.Guid}:{wo.Name} [{wo.WeenieClassId} - {wo.WeenieType}] at {wo.Location.ToLOCString()}"); return false; } } if (!worldObjects.ContainsKey(wo.Guid)) pendingAdditions[wo.Guid] = wo; else pendingRemovals.Remove(wo.Guid); // broadcast to nearby players wo.NotifyPlayers(); if (wo is Player player) player.SetFogColor(FogColor); if (wo is Corpse && wo.Level.HasValue) { var corpseLimit = PropertyManager.GetLong("corpse_spam_limit").Item; var corpseList = worldObjects.Values.Union(pendingAdditions.Values).Where(w => w is Corpse && w.Level.HasValue && w.VictimId == wo.VictimId).OrderBy(w => w.CreationTimestamp); if (corpseList.Count() > corpseLimit) { var corpse = GetObject(corpseList.First(w => w.TimeToRot > Corpse.EmptyDecayTime).Guid); if (corpse != null) { log.Warn($"[CORPSE] Landblock.AddWorldObjectInternal(): {wo.Name} (0x{wo.Guid}) exceeds the per player limit of {corpseLimit} corpses for 0x{Id.Landblock:X4}. Adjusting TimeToRot for oldest {corpse.Name} (0x{corpse.Guid}), CreationTimestamp: {corpse.CreationTimestamp} ({Common.Time.GetDateTimeFromTimestamp(corpse.CreationTimestamp ?? 0).ToLocalTime():yyyy-MM-dd HH:mm:ss}), to Corpse.EmptyDecayTime({Corpse.EmptyDecayTime})."); corpse.TimeToRot = Corpse.EmptyDecayTime; } } } return true; } public void RemoveWorldObject(ObjectGuid objectId, bool adjacencyMove = false, bool fromPickup = false, bool showError = true) { RemoveWorldObjectInternal(objectId, adjacencyMove, fromPickup, showError); } /// /// Should only be called by physics/relocation engines -- not from player /// /// The object ID to be removed from the current landblock /// Flag indicates if object is moving to an adjacent landblock public void RemoveWorldObjectForPhysics(ObjectGuid objectId, bool adjacencyMove = false) { RemoveWorldObjectInternal(objectId, adjacencyMove); } private void RemoveWorldObjectInternal(ObjectGuid objectId, bool adjacencyMove = false, bool fromPickup = false, bool showError = true) { if (LandblockManager.CurrentlyTickingLandblockGroupsMultiThreaded) { if (CurrentLandblockGroup != null && CurrentLandblockGroup != LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value) { log.Error($"Landblock 0x{Id} entered RemoveWorldObjectInternal in a cross-thread operation."); log.Error($"Landblock 0x{Id} CurrentLandblockGroup: {CurrentLandblockGroup}"); log.Error($"LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value: {LandblockManager.CurrentMultiThreadedTickingLandblockGroup.Value}"); log.Error($"objectId: 0x{objectId}"); log.Error(System.Environment.StackTrace); log.Error("PLEASE REPORT THIS TO THE ACE DEV TEAM !!!"); // This may still crash... } } if (worldObjects.TryGetValue(objectId, out var wo)) pendingRemovals.Add(objectId); else if (!pendingAdditions.Remove(objectId, out wo)) { if (showError) log.Warn($"RemoveWorldObjectInternal: Couldn't find {objectId.Full:X8}"); return; } wo.CurrentLandblock = null; // Weenies can come with a default of 0 (Instant Rot) or -1 (Never Rot). If they still have that value, we want to retain it. // We also want to make sure fromPickup is true so that we're not clearing out TimeToRot on server shutdown (unloads all landblocks and removed all objects). if (fromPickup && wo.TimeToRot.HasValue && wo.TimeToRot != 0 && wo.TimeToRot != -1) wo.TimeToRot = null; if (!adjacencyMove) { // really remove it - send message to client to remove object wo.EnqueueActionBroadcast(p => p.RemoveTrackedObject(wo, fromPickup)); wo.PhysicsObj.DestroyObject(); } } public void EmitSignal(WorldObject emitter, string message) { if (string.IsNullOrWhiteSpace(message)) return; foreach (var wo in worldObjects.Values.Where(w => w.HearLocalSignals).ToList()) { if (emitter == wo) continue; if (emitter.IsWithinUseRadiusOf(wo, wo.HearLocalSignalsRadius)) { //Console.WriteLine($"{wo.Name}.EmoteManager.OnLocalSignal({emitter.Name}, {message})"); wo.EmoteManager.OnLocalSignal(emitter, message); } } } /// /// Check to see if we are close enough to interact. Adds a fudge factor of 1.5f /// public bool WithinUseRadius(Player player, ObjectGuid targetGuid, out bool validTargetGuid, float? useRadius = null) { var target = GetObject(targetGuid); validTargetGuid = target != null; if (target != null) return player.IsWithinUseRadiusOf(target, useRadius); return false; } /// /// Returns landblock objects with physics initialized /// public ICollection GetWorldObjectsForPhysicsHandling() { // If a missile is destroyed when it runs it's UpdateObjectPhysics(), it will remove itself from the landblock, thus, modifying the worldObjects collection. ProcessPendingWorldObjectAdditionsAndRemovals(); return worldObjects.Values; } public List GetAllWorldObjectsForDiagnostics() { // We do not ProcessPending here, and we return ToList() to avoid cross-thread issues. // This can happen if we "loadalllandblocks" and do a "serverstatus". return worldObjects.Values.ToList(); } public WorldObject GetObject(uint objectId) { return GetObject(new ObjectGuid(objectId)); } /// /// This will return null if the object was not found in the current or adjacent landblocks. /// public WorldObject GetObject(ObjectGuid guid, bool searchAdjacents = true) { if (pendingRemovals.Contains(guid)) return null; if (worldObjects.TryGetValue(guid, out var worldObject) || pendingAdditions.TryGetValue(guid, out worldObject)) return worldObject; if (searchAdjacents) { foreach (Landblock lb in Adjacents) { if (lb != null) { var wo = lb.GetObject(guid, false); if (wo != null) return wo; } } } return null; } public WorldObject GetWieldedObject(uint objectGuid, bool searchAdjacents = true) { return GetWieldedObject(new ObjectGuid(objectGuid), searchAdjacents); // todo fix } /// /// Searches this landblock (and possibly adjacents) for an ObjectGuid wielded by a creature /// public WorldObject GetWieldedObject(ObjectGuid guid, bool searchAdjacents = true) { // search creature wielded items in current landblock var creatures = worldObjects.Values.OfType(); foreach (var creature in creatures) { var wieldedItem = creature.GetEquippedItem(guid); if (wieldedItem != null) { if ((wieldedItem.CurrentWieldedLocation & EquipMask.Selectable) != 0) return wieldedItem; return null; } } // try searching adjacent landblocks if not found if (searchAdjacents) { foreach (var adjacent in Adjacents) { if (adjacent == null) continue; var wieldedItem = adjacent.GetWieldedObject(guid, false); if (wieldedItem != null) return wieldedItem; } } return null; } /// /// Sets a landblock to active state, with the current time as the LastActiveTime /// /// Public calls to this function should always set isAdjacent to false public void SetActive(bool isAdjacent = false) { lastActiveTime = DateTime.UtcNow; IsDormant = false; if (isAdjacent || PhysicsLandblock == null || PhysicsLandblock.IsDungeon) return; // for outdoor landblocks, recursively call 1 iteration to set adjacents to active foreach (var landblock in Adjacents) { if (landblock != null) landblock.SetActive(true); } } /// /// Handles the cleanup process for a landblock /// This method is called by LandblockManager /// public void Unload() { var landblockID = Id.Raw | 0xFFFF; //log.DebugFormat("Landblock.Unload({0:X8})", landblockID); ProcessPendingWorldObjectAdditionsAndRemovals(); SaveDB(); // remove all objects foreach (var wo in worldObjects.ToList()) { if (!wo.Value.BiotaOriginatedFromOrHasBeenSavedToDatabase()) wo.Value.Destroy(false, true); else RemoveWorldObjectInternal(wo.Key); } ProcessPendingWorldObjectAdditionsAndRemovals(); actionQueue.Clear(); // remove physics landblock LScape.unload_landblock(landblockID); PhysicsLandblock.release_shadow_objs(); } public void DestroyAllNonPlayerObjects() { ProcessPendingWorldObjectAdditionsAndRemovals(); SaveDB(); // remove all objects foreach (var wo in worldObjects.Where(i => !(i.Value is Player)).ToList()) { if (!wo.Value.BiotaOriginatedFromOrHasBeenSavedToDatabase()) wo.Value.Destroy(false); else RemoveWorldObjectInternal(wo.Key); } ProcessPendingWorldObjectAdditionsAndRemovals(); actionQueue.Clear(); } private void SaveDB() { var biotas = new Collection<(Biota biota, ReaderWriterLockSlim rwLock)>(); foreach (var wo in worldObjects.Values) { if (wo.IsStaticThatShouldPersistToShard() || wo.IsDynamicThatShouldPersistToShard()) AddWorldObjectToBiotasSaveCollection(wo, biotas); } DatabaseManager.Shard.SaveBiotasInParallel(biotas, null); } private void AddWorldObjectToBiotasSaveCollection(WorldObject wo, Collection<(Biota biota, ReaderWriterLockSlim rwLock)> biotas) { if (wo.ChangesDetected) { wo.SaveBiotaToDatabase(false); biotas.Add((wo.Biota, wo.BiotaDatabaseLock)); } if (wo is Container container) { foreach (var item in container.Inventory.Values) AddWorldObjectToBiotasSaveCollection(item, biotas); } } /// /// This is only used for very specific instances, such as broadcasting player deaths to the destination lifestone block /// This is a rarely used method to broadcast network messages to all of the players within a landblock, /// and possibly the adjacent landblocks. /// public void EnqueueBroadcast(ICollection excludeList, bool adjacents, Position pos = null, float? maxRangeSq = null, params GameMessage[] msgs) { var players = worldObjects.Values.OfType(); // for landblock death broadcasts: // exclude players that have already been broadcast to within range of the death if (excludeList != null) players = players.Except(excludeList); // broadcast messages to player in this landblock foreach (var player in players) { if (pos != null && maxRangeSq != null) { var distSq = player.Location.SquaredDistanceTo(pos); if (distSq > maxRangeSq) continue; } player.Session.Network.EnqueueSend(msgs); } // if applicable, iterate into adjacent landblocks if (adjacents) { foreach (var adjacent in this.Adjacents.Where(adj => adj != null)) adjacent.EnqueueBroadcast(excludeList, false, pos, maxRangeSq, msgs); } } private bool? isDungeon; /// /// Returns TRUE if this landblock is a dungeon, /// with no traversable overworld /// public bool IsDungeon { get { // return cached value if (isDungeon != null) return isDungeon.Value; // hack for NW island // did a worldwide analysis for adding watercells into the formula, // but they are inconsistently defined for some of the edges of map unfortunately if (Id.LandblockX < 0x08 && Id.LandblockY > 0xF8) { isDungeon = false; return isDungeon.Value; } // a dungeon landblock is determined by: // - all heights being 0 // - having at least 1 EnvCell (0x100+) // - contains no buildings foreach (var height in CellLandblock.Height) { if (height != 0) { isDungeon = false; return isDungeon.Value; } } isDungeon = LandblockInfo != null && LandblockInfo.NumCells > 0 && LandblockInfo.Buildings != null && LandblockInfo.Buildings.Count == 0; return isDungeon.Value; } } private bool? hasDungeon; /// /// Returns TRUE if this landblock contains a dungeon // /// If a landblock contains both a dungeon + traversable overworld, /// this field will return TRUE, whereas IsDungeon will return FALSE /// /// This property should only be used in very specific scenarios, /// such as determining if a landblock contains a mansion basement /// public bool HasDungeon { get { // return cached value if (hasDungeon != null) return hasDungeon.Value; hasDungeon = LandblockInfo != null && LandblockInfo.NumCells > 0 && LandblockInfo.Buildings != null && LandblockInfo.Buildings.Count == 0; return hasDungeon.Value; } } public List Houses = new List(); public void SetFogColor(EnvironChangeType environChangeType) { if (environChangeType.IsFog()) { FogColor = environChangeType; foreach (var lb in Adjacents) lb.FogColor = environChangeType; foreach(var player in players) { player.SetFogColor(FogColor); } } } public void SendEnvironSound(EnvironChangeType environChangeType) { if (environChangeType.IsSound()) { SendEnvironChange(environChangeType); foreach (var lb in Adjacents) lb.SendEnvironChange(environChangeType); } } public void SendEnvironChange(EnvironChangeType environChangeType) { foreach (var player in players) { player.SendEnvironChange(environChangeType); } } public void SendCurrentEnviron() { foreach (var player in players) { if (FogColor.IsFog()) { player.SetFogColor(FogColor); } else { player.SendEnvironChange(FogColor); } } } public void DoEnvironChange(EnvironChangeType environChangeType) { if (environChangeType.IsFog()) SetFogColor(environChangeType); else SendEnvironSound(environChangeType); } } }