diff --git a/OpenHellion/IO/PersistenceJsonConverter.cs b/OpenHellion/IO/PersistenceJsonConverter.cs index bb060f1..b9fe243 100644 --- a/OpenHellion/IO/PersistenceJsonConverter.cs +++ b/OpenHellion/IO/PersistenceJsonConverter.cs @@ -7,6 +7,11 @@ namespace OpenHellion.IO; public class PersistenceJsonConverter : JsonConverter { + // This converter only resolves the concrete type when reading. Writing is left to the default + // serialiser, which stores the type in PersistenceData.__ObjectType. Serialising here would + // re-enter this converter through the same JsonSerializer and recurse until the stack overflows. + public override bool CanWrite => false; + public override bool CanConvert(Type objectType) { return objectType == typeof(PersistenceData) || objectType == typeof(PersistenceObjectData); @@ -24,6 +29,6 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - serializer.Serialize(writer, value); + throw new NotSupportedException("PersistenceJsonConverter is read-only."); } } diff --git a/ZeroGravity/BulletPhysics/BulletPhysicsController.cs b/ZeroGravity/BulletPhysics/BulletPhysicsController.cs index 5dcb683..5dd0d8d 100644 --- a/ZeroGravity/BulletPhysics/BulletPhysicsController.cs +++ b/ZeroGravity/BulletPhysics/BulletPhysicsController.cs @@ -292,18 +292,30 @@ public bool RemoveRigidBody(SpaceObjectVessel ship) { try { - lock (_dynamicsWorld) + // Walk up the docking tree iteratively, remembering where we have been. A vessel loaded + // from persistence can end up in a docking cycle (A docked to B, B docked back to A), and + // following it recursively overflows the stack and takes the whole server down. + HashSet visited = []; + while (ship != null && visited.Add(ship)) { - if (ship.RigidBody != null && _dynamicsWorld.CollisionObjectArray.Contains(ship.RigidBody)) + lock (_dynamicsWorld) + { + if (ship.RigidBody != null && _dynamicsWorld.CollisionObjectArray.Contains(ship.RigidBody)) + { + _dynamicsWorld.RemoveRigidBody(ship.RigidBody); + ship.RigidBody = null; + return true; + } + } + if (!ship.IsDocked) { - _dynamicsWorld.RemoveRigidBody(ship.RigidBody); - ship.RigidBody = null; return true; } + ship = ship.DockedToMainVessel as Ship; } - if (ship.IsDocked) + if (ship != null) { - return RemoveRigidBody(ship.DockedToMainVessel as Ship); + Debug.LogError("Cycle in docking tree while removing rigid body", ship.Guid); } return true; } diff --git a/ZeroGravity/Objects/DynamicObject.cs b/ZeroGravity/Objects/DynamicObject.cs index 8b2f00a..c56ceb4 100644 --- a/ZeroGravity/Objects/DynamicObject.cs +++ b/ZeroGravity/Objects/DynamicObject.cs @@ -22,7 +22,10 @@ public class DynamicObject : SpaceObjectTransferable, IPersistantObject private long _MasterClientID; - private DateTime lastSenderTime; + // Starts counting from when the object comes into existence. Left at DateTime.MinValue, an + // object restored from persistence looks abandoned for two millennia and SelfDestructCheck + // deletes it on the first tick, before any client ever gets the chance to touch it. + private DateTime lastSenderTime = DateTime.UtcNow; private DateTime takeoverTime; @@ -413,16 +416,45 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) else if (message.AttachData.ParentType is SpaceObjectType.PlayerPivot or SpaceObjectType.CorpsePivot or SpaceObjectType.DynamicObjectPivot) { ArtificialBody refObject = GetParent(oldParent); + + // The module the item was released in, before refObject is moved up to the station + // it is docked into. Null means it was released in open space. + SpaceObjectVessel releasedInside = refObject as SpaceObjectVessel; + if (refObject is SpaceObjectVessel vessel) { refObject = vessel.MainVessel; } - newParent = new Pivot(this, refObject); - removeFromOldParent(); - if (message.AttachData.LocalPosition != null && message.AttachData.LocalRotation != null) + + if (releasedInside != null) { - LocalPosition = message.AttachData.LocalPosition.ToVector3D(); - LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); + // Let go of inside a vessel, so it stays the vessel's own, and the reply + // below carries that back to the client. + // + // The client says otherwise, over and over: an item resting on the floor + // leaves and re-enters its room trigger about once a second, because every + // answer re-parents it and re-parenting fires the trigger again. Believing + // it puts the item on a pivot of its own, and a pivot's contents are only + // ever described once, in the message announcing the drop - the movement + // message walks vessels, and a pivot is not a vessel. So the item becomes + // invisible to anyone who arrives afterwards, including the player who + // dropped it once they reconnect, and there is nothing under any vessel to + // write down when the world is saved. + // + // The shipped game hides all of this: items let go of in a vessel are + // cleaned up after five minutes and were never saved, so nobody could tell + // they had already stopped existing for everyone else. + newParent = releasedInside; + } + else + { + newParent = new Pivot(this, refObject); + removeFromOldParent(); + if (message.AttachData.LocalPosition != null && message.AttachData.LocalRotation != null) + { + LocalPosition = message.AttachData.LocalPosition.ToVector3D(); + LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); + } } } else if (message.AttachData.ParentType == SpaceObjectType.DynamicObject) diff --git a/ZeroGravity/Objects/Player.cs b/ZeroGravity/Objects/Player.cs index ff6d5c4..711feda 100644 --- a/ZeroGravity/Objects/Player.cs +++ b/ZeroGravity/Objects/Player.cs @@ -190,7 +190,9 @@ public bool PlayerReady } private set { - if (_playerReady = value) + // This compared with a single '=', so the body ran on every assignment instead of only when + // the value actually changed, firing whatever hangs off it many times a second. + if (_playerReady != value) { _playerReady = value; if (PlayerReady && EnvironmentReady) diff --git a/ZeroGravity/Objects/QuestTrigger.cs b/ZeroGravity/Objects/QuestTrigger.cs index 62481f2..544292c 100644 --- a/ZeroGravity/Objects/QuestTrigger.cs +++ b/ZeroGravity/Objects/QuestTrigger.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -24,9 +25,12 @@ public override bool Equals(object obj) public override int GetHashCode() { - return new object[(int)checked((nint)PlayerGUID), QuestID, ID].GetHashCode(); + return HashCode.Combine(PlayerGUID, QuestID, ID); } + // Most vessels carry no quest trigger id at all, so both sides have to tolerate null. Without + // this, comparing against them throws, and since the comparison happens while a player is being + // killed the exception escapes an async void path and takes the whole server down. public static bool operator ==(QuestTriggerID x, QuestTriggerID y) { if (ReferenceEquals(x, y)) diff --git a/ZeroGravity/Objects/Ship.cs b/ZeroGravity/Objects/Ship.cs index c9279f5..38caf23 100644 --- a/ZeroGravity/Objects/Ship.cs +++ b/ZeroGravity/Objects/Ship.cs @@ -1616,52 +1616,52 @@ public async Task LoadPersistenceData(PersistenceObjectData persistenceData) } if (data.ResourceTanks != null) { - await Parallel.ForEachAsync(data.ResourceTanks, async (rtd, ct) => + foreach (var rtd in data.ResourceTanks) { await DistributionManager.GetResourceContainer(new VesselObjectID(Guid, rtd.InSceneID))?.LoadPersistenceData(rtd); - }); + } } if (data.Generators != null) { - await Parallel.ForEachAsync(data.Generators, async (vc, ct) => + foreach (var vc in data.Generators) { await DistributionManager.GetGenerator(new VesselObjectID(Guid, vc.InSceneID))?.LoadPersistenceData(vc); - }); + } } if (data.SubSystems != null) { - await Parallel.ForEachAsync(data.SubSystems, async (subSystem, ct) => + foreach (var subSystem in data.SubSystems) { await DistributionManager.GetSubSystem(new VesselObjectID(Guid, subSystem.InSceneID))?.LoadPersistenceData(subSystem); - }); + } } if (data.Rooms != null) { - await Parallel.ForEachAsync(data.Rooms, async (room, ct) => + foreach (var room in data.Rooms) { await DistributionManager.GetRoom(new VesselObjectID(Guid, room.InSceneID))?.LoadPersistenceData(room); - }); + } } if (data.Doors != null) { - await Parallel.ForEachAsync(data.Doors, async (door, ct) => + foreach (var door in data.Doors) { await Doors.Find((Door x) => x.ID.InSceneID == door.InSceneID)?.LoadPersistenceData(door); - }); + } } if (data.DockingPorts != null) { - await Parallel.ForEachAsync(data.DockingPorts, async (dp, ct) => + foreach (var dp in data.DockingPorts) { - await DockingPorts.First((VesselDockingPort m) => m.ID.InSceneID == dp.InSceneID)?.LoadPersistenceData(dp); - }); + await DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == dp.InSceneID)?.LoadPersistenceData(dp); + } } if (data.Executors != null) { - await Parallel.ForEachAsync(data.Executors, async (executor, ct) => + foreach (var executor in data.Executors) { await SceneTriggerExecutors.Find(x => x.InSceneID == executor.InSceneID)?.LoadPersistenceData(executor); - }); + } } if (data.NameTags != null) { @@ -1676,10 +1676,10 @@ await Parallel.ForEachAsync(data.Executors, async (executor, ct) => } if (data.RepairPoints is { Count: > 0 }) { - await Parallel.ForEachAsync(data.RepairPoints, async (rp, ct) => + foreach (var rp in data.RepairPoints) { await RepairPoints.Find((VesselRepairPoint x) => x.ID.InSceneID == rp.InSceneID)?.LoadPersistenceData(rp); - }); + } } await MainDistributionManager.UpdateSystems(); if (data.OrbitData != null) @@ -1735,6 +1735,43 @@ public async Task RestoreDocking(PersistenceObjectDataShip data) } } + /// + /// Restores docking and stabilisation, which both refer to other vessels by GUID. + /// Must run after every vessel has been created, and one vessel at a time: docking rewrites + /// the shared docked-vessels tree, so doing it concurrently corrupts that tree into cycles. + /// + public async Task LoadDockingPersistenceData(PersistenceObjectData persistenceData) + { + PersistenceObjectDataShip data = persistenceData as PersistenceObjectDataShip; + if (data.DockedToShipGUID.HasValue) + { + if (Server.Instance.GetVessel(data.DockedToShipGUID.Value) is not Ship dockToShip) + { + Debug.LogError("Could not find vessel to dock to", Guid, data.DockedToShipGUID.Value); + } + else + { + VesselDockingPort myPort = DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == data.DockedPortID.Value); + VesselDockingPort dockedToPort = dockToShip.DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == data.DockedToPortID.Value); + if (myPort == null || dockedToPort == null) + { + Debug.LogError("Could not find docking port", Guid, data.DockedPortID, data.DockedToPortID); + } + else + { + await DockToVessel(myPort, dockedToPort, dockToShip, disableStabilization: false, useCurrentSolarSystemTime: true, buildingStation: true); + } + } + } + if (data.StabilizeToTargetGUID.HasValue) + { + SpaceObjectVessel ab = Server.Instance.GetObject(data.StabilizeToTargetGUID.Value) as SpaceObjectVessel; + StabilizeToTarget(ab, forceStabilize: true); + StabilizeToTargetRelPosition = data.StabilizeToTargetPosition.ToVector3D(); + await UpdateStabilization(); + } + } + public async void VesselRequestListener(NetworkData data) { var request = data as VesselRequest; diff --git a/ZeroGravity/Persistence.cs b/ZeroGravity/Persistence.cs index e460ab9..9624b77 100644 --- a/ZeroGravity/Persistence.cs +++ b/ZeroGravity/Persistence.cs @@ -99,7 +99,7 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) Players = new HashSet(), RespawnObjects = new HashSet(), SpawnPoints = new HashSet(), - ArenaControllers = new HashSet() + ArenaControllers = new HashSet(), }; foreach (SpaceObjectVessel ves in Server.Instance.AllVessels) @@ -139,6 +139,7 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) { per.ArenaControllers.Add(dmac.GetPersistenceData()); } + per.DoomControllerData = Server.Instance.DoomedShipController.GetPersistenceData(); per.SpawnManagerData = SpawnManager.GetPersistenceData(); DirectoryInfo d = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), Server.ConfigDir)); @@ -157,9 +158,17 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) filename = string.Format(PersistanceFileName, DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss")); } - // TODO: Fix saving - // JsonSerialiser.SerializeToFile(per, Path.Combine(Server.ConfigDir, filename), JsonSerialiser.Formatting.None); - Debug.Log("Saved world..."); + string savePath = Path.Combine(d.FullName, filename); + try + { + JsonSerialiser.SerializeToFile(per, savePath, JsonSerialiser.Formatting.None); + Debug.Log("Saved world..."); + } + catch (Exception ex) + { + Debug.LogError("Failed to save world", savePath, ex.Message); + return; + } } private static void LoadRespawnObjectPersistence(PersistenceObjectDataRespawnObject data) @@ -207,6 +216,12 @@ private static void LoadRespawnObjectPersistence(PersistenceObjectDataRespawnObj }); } + /// + /// Puts an item that a player left lying in a vessel back into that vessel, at the place it was + /// left. It comes back as an ordinary loose item in a room rather than on a pivot, which is what + /// keeps it saved, shown to clients that come near, and out of reach of the pivot cleanup timer. + /// + private static void LoadSpawnPointPeristence(PersistenceObjectDataSpawnPoint data) { try @@ -244,11 +259,13 @@ public static async Task Load(string filename = null) Server.Instance.SolarSystem.CalculatePositionsAfterTime(persistence.SolarSystemTime); if (persistence.Asteroids != null) { - await Parallel.ForEachAsync(persistence.Asteroids, async (asteroidData, ct) => + // Loading is deliberately sequential: the game code it drives mutates shared state + // (rooms, docking trees, air consumers) through plain List, which is not thread safe. + foreach (var asteroidData in persistence.Asteroids) { Asteroid ast = new Asteroid(asteroidData.GUID, initializeOrbit: false, Vector3D.Zero, Vector3D.One, QuaternionD.Identity); await ast.LoadPersistenceData(asteroidData); - }); + } } if (persistence.Ships != null) { @@ -272,12 +289,12 @@ await Parallel.ForEachAsync(persistence.Ships, async (shipData, ct) => } if (persistence.Players != null) { - await Parallel.ForEachAsync(persistence.Players, async (data, ct) => + foreach (var data in persistence.Players) { var playerData = data as PersistenceObjectDataPlayer; Player player = await Player.CreatePlayerAsync(playerData.GUID, Vector3D.Zero, QuaternionD.Identity, "PersistenceLoad", "", playerData.Gender, playerData.HeadType, playerData.HairType, addToServerList: false); await player.LoadPersistenceData(playerData); - }); + } } if (persistence.RespawnObjects != null) { @@ -295,12 +312,12 @@ await Parallel.ForEachAsync(persistence.Players, async (data, ct) => } if (persistence.ArenaControllers != null) { - await Parallel.ForEachAsync(persistence.ArenaControllers, async (data, ct) => + foreach (var data in persistence.ArenaControllers) { var arenaControllerData = data as PersistenceArenaControllerData; DeathMatchArenaController arenaController = new DeathMatchArenaController(); await arenaController.LoadPersistenceData(arenaControllerData); - }); + } } if (persistence.DoomControllerData != null) { @@ -408,6 +425,7 @@ public struct PersistenceObject public HashSet ArenaControllers; + public PersistenceObjectData DoomControllerData; public PersistenceObjectData SpawnManagerData; diff --git a/ZeroGravity/Server.cs b/ZeroGravity/Server.cs index 8d13581..36d42f4 100644 --- a/ZeroGravity/Server.cs +++ b/ZeroGravity/Server.cs @@ -2064,17 +2064,20 @@ public async Task DestroyArtificialBody(ArtificialBody ab, bool destroyChildren } if (ab is SpaceObjectVessel ves) { + // Destroying a vessel walks into the spawn system and the name generator, which keep plain + // dictionaries. Doing several at once corrupts them and takes the server down, so the + // docked vessels and the crew are dealt with one at a time. if (destroyChildren && ves.AllDockedVessels.Count > 0) { - await Parallel.ForEachAsync(ves.AllDockedVessels, async (child, ct) => + foreach (SpaceObjectVessel child in ves.AllDockedVessels.ToList()) { await DestroyArtificialBody(child, destroyChildren: false, vesselExploded); - }); + } } - await Parallel.ForEachAsync(ves.VesselCrew, async (pl, ct) => + foreach (Player pl in ves.VesselCrew.ToList()) { await pl.KillPlayer(HurtType.Shipwreck, createCorpse: false); - }); + } if (vesselExploded) { await ves.DamageVesselsInExplosionRadius(); diff --git a/ZeroGravity/ShipComponents/DistributionManager.cs b/ZeroGravity/ShipComponents/DistributionManager.cs index a63ce7f..5d10bca 100644 --- a/ZeroGravity/ShipComponents/DistributionManager.cs +++ b/ZeroGravity/ShipComponents/DistributionManager.cs @@ -1577,7 +1577,7 @@ private void UpdateCompoundRooms(float duration) quantityLoss = 0f; if (duration > 0f) { - foreach (IAirConsumer cons in cav.AirConsumers.Where((IAirConsumer m) => m is not AirConsumerFire)) + foreach (IAirConsumer cons in cav.AirConsumers.Where((IAirConsumer m) => m is not null and not AirConsumerFire)) { qualityLoss += cav.AirPressure > 0f ? cons.AirQualityDegradationRate / cav.Volume / cav.AirPressure * duration : 0f; quantityLoss += cons.AirQuantityDecreaseRate * duration; diff --git a/ZeroGravity/ShipComponents/Room.cs b/ZeroGravity/ShipComponents/Room.cs index 9114fb1..c46dac7 100644 --- a/ZeroGravity/ShipComponents/Room.cs +++ b/ZeroGravity/ShipComponents/Room.cs @@ -304,6 +304,11 @@ private RoomPressurizationStatus GetPressurizationStatus() public void AddAirConsumer(IAirConsumer consumer) { + if (consumer == null) + { + Debug.LogError("Tried to add a null air consumer to room", ID.InSceneID); + return; + } AirConsumers.Add(consumer); if (CompoundRoom != null) {