diff --git a/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs b/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs index b4cae0bd88..bff534a547 100644 --- a/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs +++ b/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs @@ -18,6 +18,38 @@ namespace Content.IntegrationTests.Tests.Minds; [TestFixture] public sealed partial class MindTests { + /// + /// Gets a server-client pair and ensures that the client is attached to a simple mind test entity. + /// + public async Task SetupPair() + { + var pairTracker = await PoolManager.GetServerClient(new PoolSettings{ ExtraPrototypes = Prototypes }); + var pair = pairTracker.Pair; + + var entMan = pair.Server.ResolveDependency(); + var playerMan = pair.Server.ResolveDependency(); + var mindSys = entMan.System(); + + var player = playerMan.ServerSessions.Single(); + + EntityUid entity = default; + await pair.Server.WaitPost(() => + { + entity = entMan.SpawnEntity("MindTestEntity", MapCoordinates.Nullspace); + mindSys.TransferTo(mindSys.CreateMind(player.UserId), entity); + }); + + await PoolManager.RunTicksSync(pair, 5); + + var mind = player.ContentData()?.Mind; + Assert.NotNull(mind); + Assert.That(player.AttachedEntity, Is.EqualTo(entity)); + Assert.That(player.AttachedEntity, Is.EqualTo(mind.CurrentEntity), "Player is not attached to the mind's current entity."); + Assert.That(entMan.EntityExists(mind.OwnedEntity), "The mind's current entity does not exist"); + Assert.That(mind.VisitingEntity == null || entMan.EntityExists(mind.VisitingEntity), "The minds visited entity does not exist."); + return pairTracker; + } + public async Task BecomeGhost(Pair pair, bool visit = false) { var entMan = pair.Server.ResolveDependency(); @@ -74,9 +106,9 @@ public sealed partial class MindTests var mind = player.ContentData()!.Mind; Assert.NotNull(mind); - Assert.That(player.AttachedEntity, Is.EqualTo(mind.CurrentEntity)); - Assert.That(entMan.EntityExists(mind.OwnedEntity)); - Assert.That(entMan.EntityExists(mind.CurrentEntity)); + Assert.That(player.AttachedEntity, Is.EqualTo(mind.CurrentEntity), "Player is not attached to the mind's current entity."); + Assert.That(entMan.EntityExists(mind.OwnedEntity), "The mind's current entity does not exist"); + Assert.That(mind.VisitingEntity == null || entMan.EntityExists(mind.VisitingEntity), "The minds visited entity does not exist."); return mind; } diff --git a/Content.IntegrationTests/Tests/Minds/MindTests.ReconnectTests.cs b/Content.IntegrationTests/Tests/Minds/MindTests.ReconnectTests.cs index 161bcf9dd2..031955b416 100644 --- a/Content.IntegrationTests/Tests/Minds/MindTests.ReconnectTests.cs +++ b/Content.IntegrationTests/Tests/Minds/MindTests.ReconnectTests.cs @@ -20,21 +20,19 @@ public sealed partial class MindTests [Test] public async Task TestGhostsCanReconnect() { - await using var pairTracker = await PoolManager.GetServerClient(); + await using var pairTracker = await SetupPair(); var pair = pairTracker.Pair; - var entMan = pair.Server.ResolveDependency(); - await PoolManager.RunTicksSync(pair, 5); var mind = GetMind(pair); var ghost = await BecomeGhost(pair); await DisconnectReconnect(pair); - // Player in control of a NEW entity - var newMind = GetMind(pair); - Assert.That(newMind != mind); + // Player in control of a new ghost, but with the same mind + Assert.That(GetMind(pair) == mind); Assert.That(entMan.Deleted(ghost)); - Assert.Null(newMind.VisitingEntity); + Assert.That(entMan.HasComponent(mind.OwnedEntity)); + Assert.Null(mind.VisitingEntity); await pairTracker.CleanReturnAsync(); } @@ -47,11 +45,9 @@ public sealed partial class MindTests [Test] public async Task TestDeletedCanReconnect() { - await using var pairTracker = await PoolManager.GetServerClient(); + await using var pairTracker = await SetupPair(); var pair = pairTracker.Pair; - var entMan = pair.Server.ResolveDependency(); - await PoolManager.RunTicksSync(pair, 5); var mind = GetMind(pair); var playerMan = pair.Server.ResolveDependency(); @@ -76,15 +72,12 @@ public sealed partial class MindTests // Reconnect await Connect(pair, name); player = playerMan.ServerSessions.Single(); - Assert.That(user == player.UserId); + Assert.That(user, Is.EqualTo(player.UserId)); - // Player is now a new entity - var newMind = GetMind(pair); - Assert.That(newMind != mind); - Assert.Null(mind.UserId); - Assert.Null(mind.CurrentEntity); - Assert.NotNull(newMind.OwnedEntity); - Assert.That(entMan.EntityExists(newMind.OwnedEntity)); + // Player is now a new ghost entity + Assert.That(GetMind(pair), Is.EqualTo(mind)); + Assert.That(mind.OwnedEntity, Is.Not.EqualTo(entity)); + Assert.That(entMan.HasComponent(mind.OwnedEntity)); await pairTracker.CleanReturnAsync(); } @@ -97,11 +90,10 @@ public sealed partial class MindTests [Test] public async Task TestVisitingGhostReconnect() { - await using var pairTracker = await PoolManager.GetServerClient(); + await using var pairTracker = await SetupPair(); var pair = pairTracker.Pair; var entMan = pair.Server.ResolveDependency(); - await PoolManager.RunTicksSync(pair, 5); var mind = GetMind(pair); var original = mind.CurrentEntity; @@ -109,8 +101,8 @@ public sealed partial class MindTests await DisconnectReconnect(pair); // Player now controls their original mob, mind was preserved - Assert.That(mind == GetMind(pair)); - Assert.That(mind.CurrentEntity == original); + Assert.That(mind, Is.EqualTo(GetMind(pair))); + Assert.That(mind.CurrentEntity, Is.EqualTo(original)); Assert.That(!entMan.Deleted(original)); Assert.That(entMan.Deleted(ghost)); @@ -125,7 +117,7 @@ public sealed partial class MindTests [Test] public async Task TestVisitingReconnect() { - await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{ ExtraPrototypes = Prototypes }); + await using var pairTracker = await SetupPair(); var pair = pairTracker.Pair; var entMan = pair.Server.ResolveDependency(); diff --git a/Content.IntegrationTests/Tests/Minds/MindTests.cs b/Content.IntegrationTests/Tests/Minds/MindTests.cs index e5d781c96d..57a4a9d510 100644 --- a/Content.IntegrationTests/Tests/Minds/MindTests.cs +++ b/Content.IntegrationTests/Tests/Minds/MindTests.cs @@ -61,26 +61,6 @@ public sealed partial class MindTests - !type:GibBehavior { } "; - /// - /// Exception handling for PlayerData and NetUserId invalid due to testing. - /// Can be removed when Players can be mocked. - /// - /// - private void CatchPlayerDataException(Action func) - { - try - { - func(); - } - catch (ArgumentException e) - { - // Prevent exiting due to PlayerData not being initialized. - if (e.Message == "New owner must have previously logged into the server. (Parameter 'newOwner')") - return; - throw; - } - } - [Test] public async Task TestCreateAndTransferMindToNewEntity() { @@ -382,11 +362,11 @@ public sealed partial class MindTests await pairTracker.CleanReturnAsync(); } - [Test] + // TODO Implement + /*[Test] public async Task TestPlayerCanReturnFromGhostWhenDead() { - // TODO Implement - } + }*/ [Test] public async Task TestGhostDoesNotInfiniteLoop() diff --git a/Content.Server/GameTicking/Commands/ObserveCommand.cs b/Content.Server/GameTicking/Commands/ObserveCommand.cs index e5eda2b7d9..d608dda9c1 100644 --- a/Content.Server/GameTicking/Commands/ObserveCommand.cs +++ b/Content.Server/GameTicking/Commands/ObserveCommand.cs @@ -30,7 +30,7 @@ namespace Content.Server.GameTicking.Commands if (ticker.PlayerGameStatuses.TryGetValue(player.UserId, out var status) && status != PlayerGameStatus.JoinedGame) { - ticker.MakeObserve(player); + ticker.JoinAsObserver(player); } else { diff --git a/Content.Server/GameTicking/Commands/RespawnCommand.cs b/Content.Server/GameTicking/Commands/RespawnCommand.cs index df08b05084..9c887dbf17 100644 --- a/Content.Server/GameTicking/Commands/RespawnCommand.cs +++ b/Content.Server/GameTicking/Commands/RespawnCommand.cs @@ -22,7 +22,7 @@ namespace Content.Server.GameTicking.Commands } var playerMgr = IoCManager.Resolve(); - var sysMan = IoCManager.Resolve(); + var sysMan = IoCManager.Resolve(); var ticker = sysMan.GetEntitySystem(); var mind = sysMan.GetEntitySystem(); diff --git a/Content.Server/GameTicking/GameTicker.GamePreset.cs b/Content.Server/GameTicking/GameTicker.GamePreset.cs index aa380e2cbd..97e91f04c1 100644 --- a/Content.Server/GameTicking/GameTicker.GamePreset.cs +++ b/Content.Server/GameTicking/GameTicker.GamePreset.cs @@ -3,13 +3,11 @@ using System.Linq; using System.Threading.Tasks; using Content.Server.GameTicking.Presets; using Content.Server.Ghost.Components; -using Content.Server.Mind; using Content.Shared.CCVar; using Content.Shared.Damage; using Content.Shared.Damage.Prototypes; using Content.Shared.Database; using Content.Shared.Mobs.Components; -using Content.Shared.Mobs.Systems; using JetBrains.Annotations; using Robust.Server.Player; @@ -19,9 +17,6 @@ namespace Content.Server.GameTicking { public const float PresetFailedCooldownIncrease = 30f; - [Dependency] private readonly MobStateSystem _mobStateSystem = default!; - [Dependency] private readonly MindSystem _mindSystem = default!; - public GamePresetPrototype? Preset { get; private set; } private bool StartPreset(IPlayerSession[] origReadyPlayers, bool force) @@ -190,7 +185,7 @@ namespace Content.Server.GameTicking if (mind.VisitingEntity != default) { - _mindSystem.UnVisit(mind); + _mind.UnVisit(mind); } var position = Exists(playerEntity) @@ -208,11 +203,11 @@ namespace Content.Server.GameTicking // + If we're in a mob that is critical, and we're supposed to be able to return if possible, // we're succumbing - the mob is killed. Therefore, character is dead. Ghosting OK. // (If the mob survives, that's a bug. Ghosting is kept regardless.) - var canReturn = canReturnGlobal && _mindSystem.IsCharacterDeadPhysically(mind); + var canReturn = canReturnGlobal && _mind.IsCharacterDeadPhysically(mind); if (canReturnGlobal && TryComp(playerEntity, out MobStateComponent? mobState)) { - if (_mobStateSystem.IsCritical(playerEntity.Value, mobState)) + if (_mobState.IsCritical(playerEntity.Value, mobState)) { canReturn = true; @@ -250,9 +245,9 @@ namespace Content.Server.GameTicking _ghosts.SetCanReturnToBody(ghostComponent, canReturn); if (canReturn) - _mindSystem.Visit(mind, ghost); + _mind.Visit(mind, ghost); else - _mindSystem.TransferTo(mind, ghost); + _mind.TransferTo(mind, ghost); return true; } diff --git a/Content.Server/GameTicking/GameTicker.Player.cs b/Content.Server/GameTicking/GameTicker.Player.cs index 31b2eb4773..e6930780e2 100644 --- a/Content.Server/GameTicking/GameTicker.Player.cs +++ b/Content.Server/GameTicking/GameTicker.Player.cs @@ -27,15 +27,16 @@ namespace Content.Server.GameTicking { var session = args.Session; - if (_mindSystem.TryGetMind(session.UserId, out var mind)) + if (_mind.TryGetMind(session.UserId, out var mind)) { if (args.OldStatus == SessionStatus.Connecting && args.NewStatus == SessionStatus.Connected) mind.Session = session; DebugTools.Assert(mind.Session == session); - DebugTools.Assert(session.Data.ContentData()?.Mind is not {} dataMind || dataMind == mind); } + DebugTools.Assert(session.GetMind() == mind); + switch (args.NewStatus) { case SessionStatus.Connected: @@ -75,32 +76,32 @@ namespace Content.Server.GameTicking { _userDb.ClientConnected(session); - var data = session.ContentData(); - - DebugTools.AssertNotNull(data); - - if (data!.Mind == null) + if (mind == null) { if (LobbyEnabled) - { PlayerJoinLobby(session); - return; - } + else + SpawnWaitDb(); - SpawnWaitDb(); break; } - if (data.Mind.CurrentEntity == null || Deleted(data.Mind.CurrentEntity)) + if (mind.CurrentEntity == null || Deleted(mind.CurrentEntity)) { - DebugTools.Assert(data.Mind.CurrentEntity == null, "a mind's current entity has been deleted"); - SpawnWaitDb(); + DebugTools.Assert(mind.CurrentEntity == null, "a mind's current entity was deleted without updating the mind"); + + // This player is joining the game with an existing mind, but the mind has no entity. + // Their entity was probably deleted sometime while they were disconnected, or they were an observer. + // Instead of allowing them to spawn in, we will dump and their existing mind in an observer ghost. + SpawnObserverWaitDb(); } else { - session.AttachToEntity(data.Mind.CurrentEntity); + // Simply re-attach to existing entity. + session.AttachToEntity(mind.CurrentEntity); PlayerJoinGame(session); } + break; } @@ -123,6 +124,12 @@ namespace Content.Server.GameTicking SpawnPlayer(session, EntityUid.Invalid); } + async void SpawnObserverWaitDb() + { + await _userDb.WaitLoadComplete(session); + JoinAsObserver(session); + } + async void AddPlayerToDb(Guid id) { if (RoundId != 0 && _runLevel != GameRunLevel.PreRoundLobby) diff --git a/Content.Server/GameTicking/GameTicker.Spawning.cs b/Content.Server/GameTicking/GameTicker.Spawning.cs index 73b3ad74d5..fd7fc6ce88 100644 --- a/Content.Server/GameTicking/GameTicker.Spawning.cs +++ b/Content.Server/GameTicking/GameTicker.Spawning.cs @@ -1,15 +1,12 @@ using System.Globalization; using System.Linq; using Content.Server.Ghost; -using Content.Server.Ghost.Components; using Content.Server.Players; -using Content.Server.Shuttles.Systems; using Content.Server.Spawners.Components; using Content.Server.Speech.Components; using Content.Server.Station.Components; using Content.Shared.Database; using Content.Shared.GameTicking; -using Content.Shared.Ghost; using Content.Shared.Preferences; using Content.Shared.Roles; using JetBrains.Annotations; @@ -131,7 +128,7 @@ namespace Content.Server.GameTicking if (lateJoin && DisallowLateJoin) { - MakeObserve(player); + JoinAsObserver(player); return; } @@ -163,7 +160,7 @@ namespace Content.Server.GameTicking { if (!LobbyEnabled) { - MakeObserve(player); + JoinAsObserver(player); } _chatManager.DispatchServerMessage(player, Loc.GetString("game-ticker-player-no-jobs-available-when-joining")); return; @@ -175,12 +172,12 @@ namespace Content.Server.GameTicking DebugTools.AssertNotNull(data); - var newMind = _mindSystem.CreateMind(data!.UserId, character.Name); - _mindSystem.SetUserId(newMind, data.UserId); + var newMind = _mind.CreateMind(data!.UserId, character.Name); + _mind.SetUserId(newMind, data.UserId); var jobPrototype = _prototypeManager.Index(jobId); var job = new Job(newMind, jobPrototype); - _mindSystem.AddRole(newMind, job); + _mind.AddRole(newMind, job); _playTimeTrackings.PlayerRolesChanged(player); @@ -189,7 +186,7 @@ namespace Content.Server.GameTicking DebugTools.AssertNotNull(mobMaybe); var mob = mobMaybe!.Value; - _mindSystem.TransferTo(newMind, mob); + _mind.TransferTo(newMind, mob); if (lateJoin) { @@ -243,7 +240,7 @@ namespace Content.Server.GameTicking public void Respawn(IPlayerSession player) { - _mindSystem.WipeMind(player); + _mind.WipeMind(player); _adminLogger.Add(LogType.Respawn, LogImpact.Medium, $"Player {player} was respawned."); if (LobbyEnabled) @@ -263,32 +260,42 @@ namespace Content.Server.GameTicking SpawnPlayer(player, station, jobId); } - public void MakeObserve(IPlayerSession player) + /// + /// Causes the given player to join the current game as observer ghost. See also + /// + public void JoinAsObserver(IPlayerSession player) { // Can't spawn players with a dummy ticker! if (DummyTicker) return; PlayerJoinGame(player); + SpawnObserver(player); + RaiseNetworkEvent(GetStatusSingle(player, PlayerGameStatus.JoinedGame)); + } + + /// + /// Spawns an observer ghost and attaches the given player to it. If the player does not yet have a mind, the + /// player is given a new mind with the observer role. Otherwise, the current mind is transferred to the ghost. + /// + public void SpawnObserver(IPlayerSession player) + { + if (DummyTicker) + return; + + var mind = player.GetMind(); + if (mind == null) + { + mind = _mind.CreateMind(player.UserId); + _mind.SetUserId(mind, player.UserId); + _mind.AddRole(mind, new ObserverRole(mind)); + } var name = GetPlayerProfile(player).Name; - - var data = player.ContentData(); - - DebugTools.AssertNotNull(data); - - var newMind = _mindSystem.CreateMind(data!.UserId); - _mindSystem.SetUserId(newMind, data.UserId); - _mindSystem.AddRole(newMind, new ObserverRole(newMind)); - - var mob = SpawnObserverMob(); - EntityManager.GetComponent(mob).EntityName = name; - var ghost = EntityManager.GetComponent(mob); - EntitySystem.Get().SetCanReturnToBody(ghost, false); - _mindSystem.TransferTo(newMind, mob); - - _playerGameStatuses[player.UserId] = PlayerGameStatus.JoinedGame; - RaiseNetworkEvent(GetStatusSingle(player, PlayerGameStatus.JoinedGame)); + var ghost = SpawnObserverMob(); + MetaData(ghost).EntityName = name; + _ghost.SetCanReturnToBody(ghost, false); + _mind.TransferTo(mind, ghost); } #region Mob Spawning Helpers diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 7951f5d119..55a6000fc8 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -5,6 +5,7 @@ using Content.Server.Chat.Systems; using Content.Server.Database; using Content.Server.Ghost; using Content.Server.Maps; +using Content.Server.Mind; using Content.Server.Players.PlayTimeTracking; using Content.Server.Preferences.Managers; using Content.Server.ServerUpdates; @@ -13,6 +14,8 @@ using Content.Server.Station.Systems; using Content.Shared.Chat; using Content.Shared.Damage; using Content.Shared.GameTicking; +using Content.Shared.Ghost; +using Content.Shared.Mobs.Systems; using Content.Shared.Roles; using Robust.Server; using Robust.Server.GameObjects; @@ -34,6 +37,9 @@ namespace Content.Server.GameTicking [Dependency] private readonly ArrivalsSystem _arrivals = default!; [Dependency] private readonly MapLoaderSystem _map = default!; [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly GhostSystem _ghost = default!; + [Dependency] private readonly MindSystem _mind = default!; + [Dependency] private readonly MobStateSystem _mobState = default!; [ViewVariables] private bool _initialized; [ViewVariables] private bool _postInitialized; diff --git a/Content.Server/Players/PlayerData.cs b/Content.Server/Players/PlayerData.cs index 90d59113c1..c144ff4115 100644 --- a/Content.Server/Players/PlayerData.cs +++ b/Content.Server/Players/PlayerData.cs @@ -61,5 +61,13 @@ namespace Content.Server.Players { return session.Data.ContentData(); } + + /// + /// Gets the mind that is associated with this player. + /// + public static Mind.Mind? GetMind(this IPlayerSession session) + { + return session.Data.ContentData()?.Mind; + } } } diff --git a/Content.Shared/Ghost/SharedGhostSystem.cs b/Content.Shared/Ghost/SharedGhostSystem.cs index fa91d5e2ff..73583e0154 100644 --- a/Content.Shared/Ghost/SharedGhostSystem.cs +++ b/Content.Shared/Ghost/SharedGhostSystem.cs @@ -29,6 +29,14 @@ namespace Content.Shared.Ghost args.Cancel(); } + public void SetCanReturnToBody(EntityUid uid, bool value, SharedGhostComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + component.CanReturnToBody = value; + } + public void SetCanReturnToBody(SharedGhostComponent component, bool value) { component.CanReturnToBody = value;