]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Fix ghosts getting spawned in nullspace (#27617)
authorShadowCommander <shadowjjt@gmail.com>
Sat, 11 May 2024 15:03:40 +0000 (08:03 -0700)
committerGitHub <noreply@github.com>
Sat, 11 May 2024 15:03:40 +0000 (11:03 -0400)
* Add tests for ghost spawn position

* Make ghosts spawn immediately

* Format mind system

* Move ghost spawning to GhostSystem

* Spawn ghost on grid or map

This fixes the ghosts being attached the parent entity instead of the grid.

* Move logging out of the ghost system

* Make round start observer spawn using GhostSystem

* Move GameTicker ghost spawning to GhostSystem

Moved the more robust character name selection code over.
Moved the TimeOfDeath code over.
Added canReturn logic.

* Add overrides and default for ghost spawn coordinates

* Add warning log to ghost spawn fail

* Clean up test

* Dont spawn ghost on map delete

* Minor changes to the role test

* Fix role test failing to spawn ghost

It was failing the map check due to using Nullspace

* Fix ghost tests when running in parallel

Not sure what happened, but it seems to be because they were running simultaneously and overwriting values.

* Clean up ghost tests

* Test that map deletion does not spawn ghosts

* Spawn ghost on the next available map

* Disallow spawning on deleted maps

* Fix map deletion ghost test

* Cleanup

Content.IntegrationTests/Tests/Minds/GhostRoleTests.cs
Content.IntegrationTests/Tests/Minds/GhostTests.cs [new file with mode: 0644]
Content.IntegrationTests/Tests/Minds/MindTests.EntityDeletion.cs
Content.Server/GameTicking/GameTicker.GamePreset.cs
Content.Server/GameTicking/GameTicker.Spawning.cs
Content.Server/Ghost/GhostSystem.cs
Content.Server/Mind/MindSystem.cs

index ca97e435a7f068fa923a3f9fd77e2a44de01490c..150bc951f8c294b36e8e4772635766d21f608918 100644 (file)
@@ -1,5 +1,6 @@
 #nullable enable
 using System.Linq;
+using Content.IntegrationTests.Pair;
 using Content.Server.Ghost.Roles;
 using Content.Server.Ghost.Roles.Components;
 using Content.Server.Players;
@@ -26,7 +27,7 @@ public sealed class GhostRoleTests
 ";
 
     /// <summary>
-    /// This is a simple test that just checks if a player can take a ghost roll and then regain control of their
+    /// This is a simple test that just checks if a player can take a ghost role and then regain control of their
     /// original entity without encountering errors.
     /// </summary>
     [Test]
@@ -34,12 +35,15 @@ public sealed class GhostRoleTests
     {
         await using var pair = await PoolManager.GetServerClient(new PoolSettings
         {
+            Dirty = true,
             DummyTicker = false,
             Connected = true
         });
         var server = pair.Server;
         var client = pair.Client;
 
+        var mapData = await pair.CreateTestMap();
+
         var entMan = server.ResolveDependency<IEntityManager>();
         var sPlayerMan = server.ResolveDependency<Robust.Server.Player.IPlayerManager>();
         var conHost = client.ResolveDependency<IConsoleHost>();
@@ -51,7 +55,7 @@ public sealed class GhostRoleTests
         EntityUid originalMob = default;
         await server.WaitPost(() =>
         {
-            originalMob = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
+            originalMob = entMan.SpawnEntity(null, mapData.GridCoords);
             mindSystem.TransferTo(originalMindId, originalMob, true);
         });
 
@@ -69,12 +73,12 @@ public sealed class GhostRoleTests
         Assert.That(entMan.HasComponent<GhostComponent>(ghost));
         Assert.That(ghost, Is.Not.EqualTo(originalMob));
         Assert.That(session.ContentData()?.Mind, Is.EqualTo(originalMindId));
-        Assert.That(originalMind.OwnedEntity, Is.EqualTo(originalMob));
+        Assert.That(originalMind.OwnedEntity, Is.EqualTo(originalMob), $"Original mob: {originalMob}, Ghost: {ghost}");
         Assert.That(originalMind.VisitingEntity, Is.EqualTo(ghost));
 
         // Spawn ghost takeover entity.
         EntityUid ghostRole = default;
-        await server.WaitPost(() => ghostRole = entMan.SpawnEntity("GhostRoleTestEntity", MapCoordinates.Nullspace));
+        await server.WaitPost(() => ghostRole = entMan.SpawnEntity("GhostRoleTestEntity", mapData.GridCoords));
 
         // Take the ghost role
         await server.WaitPost(() =>
diff --git a/Content.IntegrationTests/Tests/Minds/GhostTests.cs b/Content.IntegrationTests/Tests/Minds/GhostTests.cs
new file mode 100644 (file)
index 0000000..ad9d53a
--- /dev/null
@@ -0,0 +1,159 @@
+using System.Numerics;
+using Content.IntegrationTests.Pair;
+using Content.Shared.Ghost;
+using Content.Shared.Mind;
+using Content.Shared.Players;
+using Robust.Server.GameObjects;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Player;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.Tests.Minds;
+
+[TestFixture]
+public sealed class GhostTests
+{
+    struct GhostTestData
+    {
+        public IEntityManager SEntMan;
+        public Robust.Server.Player.IPlayerManager SPlayerMan;
+        public Server.Mind.MindSystem SMindSys;
+        public SharedTransformSystem STransformSys = default!;
+
+        public TestPair Pair = default!;
+
+        public TestMapData MapData => Pair.TestMap!;
+
+        public RobustIntegrationTest.ServerIntegrationInstance Server => Pair.Server;
+        public RobustIntegrationTest.ClientIntegrationInstance Client => Pair.Client;
+
+        /// <summary>
+        /// Initial player coordinates. Note that this does not necessarily correspond to the position of the
+        /// <see cref="Player"/> entity.
+        /// </summary>
+        public NetCoordinates PlayerCoords = default!;
+
+        public NetEntity Player = default!;
+        public EntityUid SPlayerEnt = default!;
+
+        public ICommonSession ClientSession = default!;
+        public ICommonSession ServerSession = default!;
+
+        public GhostTestData()
+        {
+        }
+    }
+
+    private async Task<GhostTestData> SetupData()
+    {
+        var data = new GhostTestData();
+
+        // Client is needed to create a session for the ghost system. Creating a dummy session was too difficult.
+        data.Pair = await PoolManager.GetServerClient(new PoolSettings
+        {
+            DummyTicker = false,
+            Connected = true,
+            Dirty = true
+        });
+
+        data.SEntMan = data.Pair.Server.ResolveDependency<IServerEntityManager>();
+        data.SPlayerMan = data.Pair.Server.ResolveDependency<Robust.Server.Player.IPlayerManager>();
+        data.SMindSys = data.SEntMan.System<Server.Mind.MindSystem>();
+        data.STransformSys = data.SEntMan.System<SharedTransformSystem>();
+
+        // Setup map.
+        await data.Pair.CreateTestMap();
+        data.PlayerCoords = data.SEntMan.GetNetCoordinates(data.MapData.GridCoords.Offset(new Vector2(0.5f, 0.5f)).WithEntityId(data.MapData.MapUid, data.STransformSys, data.SEntMan));
+
+        if (data.Client.Session == null)
+            Assert.Fail("No player");
+        data.ClientSession = data.Client.Session!;
+        data.ServerSession = data.SPlayerMan.GetSessionById(data.ClientSession.UserId);
+
+        Entity<MindComponent> mind = default!;
+        await data.Pair.Server.WaitPost(() =>
+        {
+            data.Player = data.SEntMan.GetNetEntity(data.SEntMan.SpawnEntity(null, data.SEntMan.GetCoordinates(data.PlayerCoords)));
+            mind = data.SMindSys.CreateMind(data.ServerSession.UserId, "DummyPlayerEntity");
+            data.SPlayerEnt = data.SEntMan.GetEntity(data.Player);
+            data.SMindSys.TransferTo(mind, data.SPlayerEnt, mind: mind.Comp);
+            data.Server.PlayerMan.SetAttachedEntity(data.ServerSession, data.SPlayerEnt);
+        });
+
+        await data.Pair.RunTicksSync(5);
+
+        Assert.Multiple(() =>
+        {
+            Assert.That(data.ServerSession.ContentData()?.Mind, Is.EqualTo(mind.Owner));
+            Assert.That(data.ServerSession.AttachedEntity, Is.EqualTo(data.SPlayerEnt));
+            Assert.That(data.ServerSession.AttachedEntity, Is.EqualTo(mind.Comp.CurrentEntity),
+                "Player is not attached to the mind's current entity.");
+            Assert.That(data.SEntMan.EntityExists(mind.Comp.OwnedEntity),
+                "The mind's current entity does not exist");
+            Assert.That(mind.Comp.VisitingEntity == null || data.SEntMan.EntityExists(mind.Comp.VisitingEntity),
+                "The minds visited entity does not exist.");
+        });
+
+        Assert.That(data.SPlayerEnt, Is.Not.EqualTo(null));
+
+        return data;
+    }
+
+    /// <summary>
+    /// Test that a ghost gets created when the player entity is deleted.
+    /// 1. Delete mob
+    /// 2. Assert is ghost
+    /// </summary>
+    [Test]
+    public async Task TestGridGhostOnDelete()
+    {
+        var data = await SetupData();
+
+        var oldPosition = data.SEntMan.GetComponent<TransformComponent>(data.SPlayerEnt).Coordinates;
+
+        Assert.That(!data.SEntMan.HasComponent<GhostComponent>(data.SPlayerEnt), "Player was initially a ghost?");
+
+        // Delete entity
+        await data.Server.WaitPost(() => data.SEntMan.DeleteEntity(data.SPlayerEnt));
+        await data.Pair.RunTicksSync(5);
+
+        var ghost = data.ServerSession.AttachedEntity!.Value;
+        Assert.That(data.SEntMan.HasComponent<GhostComponent>(ghost), "Player did not become a ghost");
+
+        // Ensure the position is the same
+        var ghostPosition = data.SEntMan.GetComponent<TransformComponent>(ghost).Coordinates;
+        Assert.That(ghostPosition, Is.EqualTo(oldPosition));
+
+        await data.Pair.CleanReturnAsync();
+    }
+
+    /// <summary>
+    /// Test that a ghost gets created when the player entity is queue deleted.
+    /// 1. Delete mob
+    /// 2. Assert is ghost
+    /// </summary>
+    [Test]
+    public async Task TestGridGhostOnQueueDelete()
+    {
+        var data = await SetupData();
+
+        var oldPosition = data.SEntMan.GetComponent<TransformComponent>(data.SPlayerEnt).Coordinates;
+
+        Assert.That(!data.SEntMan.HasComponent<GhostComponent>(data.SPlayerEnt), "Player was initially a ghost?");
+
+        // Delete entity
+        await data.Server.WaitPost(() => data.SEntMan.QueueDeleteEntity(data.SPlayerEnt));
+        await data.Pair.RunTicksSync(5);
+
+        var ghost = data.ServerSession.AttachedEntity!.Value;
+        Assert.That(data.SEntMan.HasComponent<GhostComponent>(ghost), "Player did not become a ghost");
+
+        // Ensure the position is the same
+        var ghostPosition = data.SEntMan.GetComponent<TransformComponent>(ghost).Coordinates;
+        Assert.That(ghostPosition, Is.EqualTo(oldPosition));
+
+        await data.Pair.CleanReturnAsync();
+    }
+
+}
index 2ebe750f98de77683469d0838a49d2db0e608af1..de7739b2ad703ae261a6cc1772236a99335f304f 100644 (file)
@@ -1,3 +1,4 @@
+#nullable enable
 using System.Linq;
 using Content.Server.GameTicking;
 using Content.Shared.Ghost;
@@ -77,7 +78,7 @@ public sealed partial class MindTests
         await using var pair = await SetupPair(dirty: true);
         var server = pair.Server;
         var testMap = await pair.CreateTestMap();
-        var coordinates = testMap.GridCoords;
+        var testMap2 = await pair.CreateTestMap();
 
         var entMan = server.ResolveDependency<IServerEntityManager>();
         var mapManager = server.ResolveDependency<IMapManager>();
@@ -91,7 +92,7 @@ public sealed partial class MindTests
         MindComponent mind = default!;
         await server.WaitAssertion(() =>
         {
-            playerEnt = entMan.SpawnEntity(null, coordinates);
+            playerEnt = entMan.SpawnEntity(null, testMap.GridCoords);
             mindId = player.ContentData()!.Mind!.Value;
             mind = entMan.GetComponent<MindComponent>(mindId);
             mindSystem.TransferTo(mindId, playerEnt);
@@ -100,14 +101,20 @@ public sealed partial class MindTests
         });
 
         await pair.RunTicksSync(5);
-        await server.WaitPost(() => mapManager.DeleteMap(testMap.MapId));
+        await server.WaitAssertion(() => mapManager.DeleteMap(testMap.MapId));
         await pair.RunTicksSync(5);
 
         await server.WaitAssertion(() =>
         {
 #pragma warning disable NUnit2045 // Interdependent assertions.
-            Assert.That(entMan.EntityExists(mind.CurrentEntity), Is.True);
-            Assert.That(mind.CurrentEntity, Is.Not.EqualTo(playerEnt));
+            // Spawn ghost on the second map
+            var attachedEntity = player.AttachedEntity;
+            Assert.That(entMan.EntityExists(attachedEntity), Is.True);
+            Assert.That(attachedEntity, Is.Not.EqualTo(playerEnt));
+            Assert.That(entMan.HasComponent<GhostComponent>(attachedEntity));
+            var transform = entMan.GetComponent<TransformComponent>(attachedEntity.Value);
+            Assert.That(transform.MapID, Is.Not.EqualTo(MapId.Nullspace));
+            Assert.That(transform.MapID, Is.Not.EqualTo(testMap.MapId));
 #pragma warning restore NUnit2045
         });
 
index a1946d34a0aa05bade8c1537d4e6abe208c9187c..fffacb59dee1511881732a38f3ca5ff299b0dbdd 100644 (file)
@@ -274,35 +274,13 @@ namespace Content.Server.GameTicking
                 }
             }
 
-            var xformQuery = GetEntityQuery<TransformComponent>();
-            var coords = _transform.GetMoverCoordinates(position, xformQuery);
-
-            var ghost = Spawn(ObserverPrototypeName, coords);
-
-            // Try setting the ghost entity name to either the character name or the player name.
-            // If all else fails, it'll default to the default entity prototype name, "observer".
-            // However, that should rarely happen.
-            if (!string.IsNullOrWhiteSpace(mind.CharacterName))
-                _metaData.SetEntityName(ghost, mind.CharacterName);
-            else if (!string.IsNullOrWhiteSpace(mind.Session?.Name))
-                _metaData.SetEntityName(ghost, mind.Session.Name);
-
-            var ghostComponent = Comp<GhostComponent>(ghost);
-
-            if (mind.TimeOfDeath.HasValue)
-            {
-                _ghost.SetTimeOfDeath(ghost, mind.TimeOfDeath!.Value, ghostComponent);
-            }
+            var ghost = _ghost.SpawnGhost((mindId, mind), position, canReturn);
+            if (ghost == null)
+                return false;
 
             if (playerEntity != null)
                 _adminLogger.Add(LogType.Mind, $"{EntityManager.ToPrettyString(playerEntity.Value):player} ghosted{(!canReturn ? " (non-returnable)" : "")}");
 
-            _ghost.SetCanReturnToBody(ghostComponent, canReturn);
-
-            if (canReturn)
-                _mind.Visit(mindId, ghost, mind);
-            else
-                _mind.TransferTo(mindId, ghost, mind: mind);
             return true;
         }
 
index 869b8b92f76720502b0e2f59e1349d48aa307dbf..74785603799bf1c6ae90d51670deede1383f2af5 100644 (file)
@@ -8,6 +8,7 @@ using Content.Server.Speech.Components;
 using Content.Server.Station.Components;
 using Content.Shared.CCVar;
 using Content.Shared.Database;
+using Content.Shared.Mind;
 using Content.Shared.Players;
 using Content.Shared.Preferences;
 using Content.Shared.Roles;
@@ -96,8 +97,7 @@ namespace Content.Server.GameTicking
                 if (job == null)
                 {
                     var playerSession = _playerManager.GetSessionById(netUser);
-                    _chatManager.DispatchServerMessage(playerSession,
-                        Loc.GetString("job-not-available-wait-in-lobby"));
+                    _chatManager.DispatchServerMessage(playerSession, Loc.GetString("job-not-available-wait-in-lobby"));
                 }
                 else
                 {
@@ -315,10 +315,7 @@ namespace Content.Server.GameTicking
         /// <param name="station">The station they're spawning on</param>
         /// <param name="jobId">An optional job for them to spawn as</param>
         /// <param name="silent">Whether or not the player should be greeted upon joining</param>
-        public void MakeJoinGame(ICommonSession player,
-            EntityUid station,
-            string? jobId = null,
-            bool silent = false)
+        public void MakeJoinGame(ICommonSession player, EntityUid station, string? jobId = null, bool silent = false)
         {
             if (!_playerGameStatuses.ContainsKey(player.UserId))
                 return;
@@ -351,42 +348,29 @@ namespace Content.Server.GameTicking
             if (DummyTicker)
                 return;
 
-            var mind = player.GetMind();
+            Entity<MindComponent?>? mind = player.GetMind();
             if (mind == null)
             {
-                mind = _mind.CreateMind(player.UserId);
+                var name = GetPlayerProfile(player).Name;
+                var (mindId, mindComp) = _mind.CreateMind(player.UserId, name);
+                mind = (mindId, mindComp);
                 _mind.SetUserId(mind.Value, player.UserId);
                 _roles.MindAddRole(mind.Value, new ObserverRoleComponent());
             }
 
-            var name = GetPlayerProfile(player).Name;
-            var ghost = SpawnObserverMob();
-            _metaData.SetEntityName(ghost, name);
-            _ghost.SetCanReturnToBody(ghost, false);
-            _mind.TransferTo(mind.Value, ghost);
+            var ghost = _ghost.SpawnGhost(mind.Value);
             _adminLogger.Add(LogType.LateJoin,
                 LogImpact.Low,
                 $"{player.Name} late joined the round as an Observer with {ToPrettyString(ghost):entity}.");
         }
 
-        #region Mob Spawning Helpers
-
-        private EntityUid SpawnObserverMob()
-        {
-            var coordinates = GetObserverSpawnPoint();
-            return EntityManager.SpawnEntity(ObserverPrototypeName, coordinates);
-        }
-
-        #endregion
-
         #region Spawn Points
 
         public EntityCoordinates GetObserverSpawnPoint()
         {
             _possiblePositions.Clear();
 
-            foreach (var (point, transform) in EntityManager
-                         .EntityQuery<SpawnPointComponent, TransformComponent>(true))
+            foreach (var (point, transform) in EntityManager.EntityQuery<SpawnPointComponent, TransformComponent>(true))
             {
                 if (point.SpawnType != SpawnPointType.Observer)
                     continue;
@@ -402,7 +386,7 @@ namespace Content.Server.GameTicking
                 var query = AllEntityQuery<MapGridComponent>();
                 while (query.MoveNext(out var uid, out var grid))
                 {
-                    if (!metaQuery.TryGetComponent(uid, out var meta) || meta.EntityPaused)
+                    if (!metaQuery.TryGetComponent(uid, out var meta) || meta.EntityPaused || TerminatingOrDeleted(uid))
                     {
                         continue;
                     }
@@ -439,7 +423,9 @@ namespace Content.Server.GameTicking
             {
                 var mapUid = _mapManager.GetMapEntityId(map);
 
-                if (!metaQuery.TryGetComponent(mapUid, out var meta) || meta.EntityPaused)
+                if (!metaQuery.TryGetComponent(mapUid, out var meta)
+                    || meta.EntityPaused
+                    || TerminatingOrDeleted(mapUid))
                 {
                     continue;
                 }
index 0be93d2054c870b37ae1b9592e71bcfbee8ba8e8..ac519b4c2e508f7265d032cd1fc1bf79f036a248 100644 (file)
@@ -19,6 +19,7 @@ using Content.Shared.Movement.Systems;
 using Content.Shared.Storage.Components;
 using Robust.Server.GameObjects;
 using Robust.Server.Player;
+using Robust.Shared.Map;
 using Robust.Shared.Physics.Components;
 using Robust.Shared.Physics.Systems;
 using Robust.Shared.Player;
@@ -42,6 +43,8 @@ namespace Content.Server.Ghost
         [Dependency] private readonly GameTicker _ticker = default!;
         [Dependency] private readonly TransformSystem _transformSystem = default!;
         [Dependency] private readonly VisibilitySystem _visibilitySystem = default!;
+        [Dependency] private readonly MetaDataSystem _metaData = default!;
+        [Dependency] private readonly IMapManager _mapManager = default!;
 
         private EntityQuery<GhostComponent> _ghostQuery;
         private EntityQuery<PhysicsComponent> _physicsQuery;
@@ -389,5 +392,59 @@ namespace Content.Server.Ghost
 
             return ghostBoo.Handled;
         }
+
+        public EntityUid? SpawnGhost(Entity<MindComponent?> mind, EntityUid targetEntity,
+            bool canReturn = false)
+        {
+            _transformSystem.TryGetMapOrGridCoordinates(targetEntity, out var spawnPosition);
+            return SpawnGhost(mind, spawnPosition, canReturn);
+        }
+
+        public EntityUid? SpawnGhost(Entity<MindComponent?> mind, EntityCoordinates? spawnPosition = null,
+            bool canReturn = false)
+        {
+            if (!Resolve(mind, ref mind.Comp))
+                return null;
+
+            // Test if the map is being deleted
+            var mapUid = spawnPosition?.GetMapUid(EntityManager);
+            if (mapUid == null || TerminatingOrDeleted(mapUid.Value))
+                spawnPosition = null;
+
+            spawnPosition ??= _ticker.GetObserverSpawnPoint();
+
+            if (!spawnPosition.Value.IsValid(EntityManager))
+            {
+                Log.Warning($"No spawn valid ghost spawn position found for {mind.Comp.CharacterName}"
+                    + " \"{ToPrettyString(mind)}\"");
+                _minds.TransferTo(mind.Owner, null, createGhost: false, mind: mind.Comp);
+                return null;
+            }
+
+            var ghost = SpawnAtPosition(GameTicker.ObserverPrototypeName, spawnPosition.Value);
+            var ghostComponent = Comp<GhostComponent>(ghost);
+
+            // Try setting the ghost entity name to either the character name or the player name.
+            // If all else fails, it'll default to the default entity prototype name, "observer".
+            // However, that should rarely happen.
+            if (!string.IsNullOrWhiteSpace(mind.Comp.CharacterName))
+                _metaData.SetEntityName(ghost, mind.Comp.CharacterName);
+            else if (!string.IsNullOrWhiteSpace(mind.Comp.Session?.Name))
+                _metaData.SetEntityName(ghost, mind.Comp.Session.Name);
+
+            if (mind.Comp.TimeOfDeath.HasValue)
+            {
+                SetTimeOfDeath(ghost, mind.Comp.TimeOfDeath!.Value, ghostComponent);
+            }
+
+            SetCanReturnToBody(ghostComponent, canReturn);
+
+            if (canReturn)
+                _minds.Visit(mind.Owner, ghost, mind.Comp);
+            else
+                _minds.TransferTo(mind.Owner, ghost, mind: mind.Comp);
+            Log.Debug($"Spawned ghost \"{ToPrettyString(ghost)}\" for {mind.Comp.CharacterName}.");
+            return ghost;
+        }
     }
 }
index dc12836d9049107d85e6f134245cccf13adb1f4b..4271d76b445916d1e7def9efc59805f1ec40517d 100644 (file)
@@ -1,6 +1,7 @@
 using System.Diagnostics.CodeAnalysis;
 using Content.Server.Administration.Logs;
 using Content.Server.GameTicking;
+using Content.Server.Ghost;
 using Content.Server.Mind.Commands;
 using Content.Shared.Database;
 using Content.Shared.Ghost;
@@ -9,10 +10,8 @@ using Content.Shared.Mind.Components;
 using Content.Shared.Players;
 using Robust.Server.GameStates;
 using Robust.Server.Player;
-using Robust.Shared.Map.Components;
 using Robust.Shared.Network;
 using Robust.Shared.Player;
-using Robust.Shared.Timing;
 using Robust.Shared.Utility;
 
 namespace Content.Server.Mind;
@@ -22,8 +21,7 @@ public sealed class MindSystem : SharedMindSystem
     [Dependency] private readonly GameTicker _gameTicker = default!;
     [Dependency] private readonly IAdminLogManager _adminLogger = default!;
     [Dependency] private readonly IPlayerManager _players = default!;
-    [Dependency] private readonly MetaDataSystem _metaData = default!;
-    [Dependency] private readonly SharedGhostSystem _ghosts = default!;
+    [Dependency] private readonly GhostSystem _ghosts = default!;
     [Dependency] private readonly SharedTransformSystem _transform = default!;
     [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
 
@@ -63,8 +61,8 @@ public sealed class MindSystem : SharedMindSystem
             && !Terminating(visiting))
         {
             TransferTo(mindId, visiting, mind: mind);
-            if (TryComp(visiting, out GhostComponent? ghost))
-                _ghosts.SetCanReturnToBody(ghost, false);
+            if (TryComp(visiting, out GhostComponent? ghostComp))
+                _ghosts.SetCanReturnToBody(ghostComp, false);
             return;
         }
 
@@ -74,40 +72,13 @@ public sealed class MindSystem : SharedMindSystem
         if (!component.GhostOnShutdown || mind.Session == null || _gameTicker.RunLevel == GameRunLevel.PreRoundLobby)
             return;
 
-        var xform = Transform(uid);
-        var gridId = xform.GridUid;
-        var spawnPosition = Transform(uid).Coordinates;
-
-        // Use a regular timer here because the entity has probably been deleted.
-        Timer.Spawn(0, () =>
-        {
-            // Make extra sure the round didn't end between spawning the timer and it being executed.
-            if (_gameTicker.RunLevel == GameRunLevel.PreRoundLobby)
-                return;
-
-            // Async this so that we don't throw if the grid we're on is being deleted.
-            if (!HasComp<MapGridComponent>(gridId))
-                spawnPosition = _gameTicker.GetObserverSpawnPoint();
-
-            // TODO refactor observer spawning.
-            // please.
-            if (!spawnPosition.IsValid(EntityManager))
-            {
-                // This should be an error, if it didn't cause tests to start erroring when they delete a player.
-                Log.Warning($"Entity \"{ToPrettyString(uid)}\" for {mind.CharacterName} was deleted, and no applicable spawn location is available.");
-                TransferTo(mindId, null, createGhost: false, mind: mind);
-                return;
-            }
-
-            var ghost = Spawn(GameTicker.ObserverPrototypeName, spawnPosition);
-            var ghostComponent = Comp<GhostComponent>(ghost);
-            _ghosts.SetCanReturnToBody(ghostComponent, false);
-
+        var ghost = _ghosts.SpawnGhost((mindId, mind), uid);
+        if (ghost != null)
             // Log these to make sure they're not causing the GameTicker round restart bugs...
             Log.Debug($"Entity \"{ToPrettyString(uid)}\" for {mind.CharacterName} was deleted, spawned \"{ToPrettyString(ghost)}\".");
-            _metaData.SetEntityName(ghost, mind.CharacterName ?? string.Empty);
-            TransferTo(mindId, ghost, mind: mind);
-        });
+        else
+            // This should be an error, if it didn't cause tests to start erroring when they delete a player.
+            Log.Warning($"Entity \"{ToPrettyString(uid)}\" for {mind.CharacterName} was deleted, and no applicable spawn location is available.");
     }
 
     public override bool TryGetMind(NetUserId user, [NotNullWhen(true)] out EntityUid? mindId, [NotNullWhen(true)] out MindComponent? mind)