]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
fix antag selection being evil (#28197)
authordeltanedas <39013340+deltanedas@users.noreply.github.com>
Sun, 26 May 2024 05:14:29 +0000 (05:14 +0000)
committerGitHub <noreply@github.com>
Sun, 26 May 2024 05:14:29 +0000 (01:14 -0400)
* fix antag selection being evil

* fix test

* untroll the other tests

* remove role timer troll

* Allow tests to modify antag preferences

* Fix antag selection

* Misc test fixes

* Add AntagPreferenceTest

* Fix lazy mistakes

* Test cleanup

* Try stop players in lobbies from being assigned mid-round antags

* ranting

* I am going insane

---------

Co-authored-by: deltanedas <@deltanedas:kde.org>
Co-authored-by: ElectroJr <leonsfriedrich@gmail.com>
13 files changed:
Content.IntegrationTests/Pair/TestPair.Helpers.cs
Content.IntegrationTests/PoolManager.cs
Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs [new file with mode: 0644]
Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
Content.Server/Antag/AntagSelectionSystem.API.cs
Content.Server/Antag/AntagSelectionSystem.cs
Content.Server/GameTicking/GameTicker.RoundFlow.cs
Content.Server/Preferences/Managers/IServerPreferencesManager.cs
Content.Server/Preferences/Managers/ServerPreferencesManager.cs
Content.Shared/Antag/AntagAcceptability.cs
Content.Shared/Roles/Jobs/SharedJobSystem.cs

index 0ea6d3e2dcc0798d58ab1d44a5ee11d25891aeea..cc83232a066374906d5f9eb96dbe1566b255e240 100644 (file)
@@ -2,6 +2,9 @@
 using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.Linq;
+using Content.Server.Preferences.Managers;
+using Content.Shared.Preferences;
+using Content.Shared.Roles;
 using Robust.Shared.GameObjects;
 using Robust.Shared.Map;
 using Robust.Shared.Prototypes;
@@ -128,4 +131,29 @@ public sealed partial class TestPair
 
         return list;
     }
+
+    /// <summary>
+    /// Helper method for enabling or disabling a antag role
+    /// </summary>
+    public async Task SetAntagPref(ProtoId<AntagPrototype> id, bool value)
+    {
+        var prefMan = Server.ResolveDependency<IServerPreferencesManager>();
+
+        var prefs = prefMan.GetPreferences(Client.User!.Value);
+        // what even is the point of ICharacterProfile if we always cast it to HumanoidCharacterProfile to make it usable?
+        var profile = (HumanoidCharacterProfile) prefs.SelectedCharacter;
+
+        Assert.That(profile.AntagPreferences.Contains(id), Is.EqualTo(!value));
+        var newProfile = profile.WithAntagPreference(id, value);
+
+        await Server.WaitPost(() =>
+        {
+            prefMan.SetProfile(Client.User.Value, prefs.SelectedCharacterIndex, newProfile).Wait();
+        });
+
+        // And why the fuck does it always create a new preference and profile object instead of just reusing them?
+        var newPrefs = prefMan.GetPreferences(Client.User.Value);
+        var newProf = (HumanoidCharacterProfile) newPrefs.SelectedCharacter;
+        Assert.That(newProf.AntagPreferences.Contains(id), Is.EqualTo(value));
+    }
 }
index 25e6c7ef26f5bc916111bace572829ee1be3cc98..3b49ffcf8477578852aaea019583c891b8f600f5 100644 (file)
@@ -65,11 +65,11 @@ public static partial class PoolManager
 
         options.BeforeStart += () =>
         {
+            // Server-only systems (i.e., systems that subscribe to events with server-only components)
             var entSysMan = IoCManager.Resolve<IEntitySystemManager>();
-            entSysMan.LoadExtraSystemType<ResettingEntitySystemTests.TestRoundRestartCleanupEvent>();
-            entSysMan.LoadExtraSystemType<InteractionSystemTests.TestInteractionSystem>();
             entSysMan.LoadExtraSystemType<DeviceNetworkTestSystem>();
             entSysMan.LoadExtraSystemType<TestDestructibleListenerSystem>();
+
             IoCManager.Resolve<ILogManager>().GetSawmill("loc").Level = LogLevel.Error;
             IoCManager.Resolve<IConfigurationManager>()
                 .OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true);
diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs
new file mode 100644 (file)
index 0000000..662ea3b
--- /dev/null
@@ -0,0 +1,76 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Content.Server.Antag;
+using Content.Server.Antag.Components;
+using Content.Server.GameTicking;
+using Content.Shared.GameTicking;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.IntegrationTests.Tests.GameRules;
+
+// Once upon a time, players in the lobby weren't ever considered eligible for antag roles.
+// Lets not let that happen again.
+[TestFixture]
+public sealed class AntagPreferenceTest
+{
+    [Test]
+    public async Task TestLobbyPlayersValid()
+    {
+        await using var pair = await PoolManager.GetServerClient(new PoolSettings
+        {
+            DummyTicker = false,
+            Connected = true,
+            InLobby = true
+        });
+
+        var server = pair.Server;
+        var client = pair.Client;
+        var ticker = server.System<GameTicker>();
+        var sys = server.System<AntagSelectionSystem>();
+
+        // Initially in the lobby
+        Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby));
+        Assert.That(client.AttachedEntity, Is.Null);
+        Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay));
+
+        EntityUid uid = default;
+        await server.WaitPost(() => uid = server.EntMan.Spawn("Traitor"));
+        var rule = new Entity<AntagSelectionComponent>(uid, server.EntMan.GetComponent<AntagSelectionComponent>(uid));
+        var def = rule.Comp.Definitions.Single();
+
+        // IsSessionValid & IsEntityValid are preference agnostic and should always be true for players in the lobby.
+        // Though maybe that will change in the future, but then GetPlayerPool() needs to be updated to reflect that.
+        Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+        Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+
+        // By default, traitor/antag preferences are disabled, so the pool should be empty.
+        var sessions = new List<ICommonSession>{pair.Player!};
+        var pool = sys.GetPlayerPool(rule, sessions, def);
+        Assert.That(pool.Count, Is.EqualTo(0));
+
+        // Opt into the traitor role.
+        await pair.SetAntagPref("Traitor", true);
+
+        Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+        Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+        pool = sys.GetPlayerPool(rule, sessions, def);
+        Assert.That(pool.Count, Is.EqualTo(1));
+        pool.TryPickAndTake(pair.Server.ResolveDependency<IRobustRandom>(), out var picked);
+        Assert.That(picked, Is.EqualTo(pair.Player));
+        Assert.That(sessions.Count, Is.EqualTo(1));
+
+        // opt back out
+        await pair.SetAntagPref("Traitor", false);
+
+        Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+        Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+        pool = sys.GetPlayerPool(rule, sessions, def);
+        Assert.That(pool.Count, Is.EqualTo(0));
+
+        await server.WaitPost(() => server.EntMan.DeleteEntity(uid));
+        await pair.CleanReturnAsync();
+    }
+}
index 5bada98a3aa518f2c9a7d1c23e50c5e577187a80..3a77834b72596a6ff27d09214b04de9be160f71c 100644 (file)
@@ -58,6 +58,9 @@ public sealed class NukeOpsTest
         Assert.That(client.AttachedEntity, Is.Null);
         Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay));
 
+        // Opt into the nukies role.
+        await pair.SetAntagPref("NukeopsCommander", true);
+
         // There are no grids or maps
         Assert.That(entMan.Count<MapComponent>(), Is.Zero);
         Assert.That(entMan.Count<MapGridComponent>(), Is.Zero);
@@ -198,6 +201,7 @@ public sealed class NukeOpsTest
 
         ticker.SetGamePreset((GamePresetPrototype?)null);
         server.CfgMan.SetCVar(CCVars.GridFill, false);
+        await pair.SetAntagPref("NukeopsCommander", false);
         await pair.CleanReturnAsync();
     }
 }
index 317aa10400b9598dd39243c6db0aabf0ebf4fa5a..4415eddf376d25c7fbfafe4ec45f823a7ec02e6c 100644 (file)
@@ -407,7 +407,6 @@ namespace Content.IntegrationTests.Tests.Interaction.Click
             await pair.CleanReturnAsync();
         }
 
-        [Reflect(false)]
         public sealed class TestInteractionSystem : EntitySystem
         {
             public EntityEventHandler<InteractUsingEvent>? InteractUsingEvent;
index d5c2a9124dd9b6a5cdb07c963a6c8bc880aa0b8c..40457f54883570d6af72d0dc62de6ae94735cd9b 100644 (file)
@@ -9,7 +9,6 @@ namespace Content.IntegrationTests.Tests
     [TestOf(typeof(RoundRestartCleanupEvent))]
     public sealed class ResettingEntitySystemTests
     {
-        [Reflect(false)]
         public sealed class TestRoundRestartCleanupEvent : EntitySystem
         {
             public bool HasBeenReset { get; set; }
@@ -49,8 +48,6 @@ namespace Content.IntegrationTests.Tests
 
                 system.HasBeenReset = false;
 
-                Assert.That(system.HasBeenReset, Is.False);
-
                 gameTicker.RestartRound();
 
                 Assert.That(system.HasBeenReset);
index d8554e3fb22a4b2ef29d6e25cb1aa138baa9e238..3b5855cdf95c41dbc498d7a235952a550c237b5c 100644 (file)
@@ -27,6 +27,11 @@ public sealed partial class AntagSelectionSystem
         if (mindCount >= totalTargetCount)
             return false;
 
+        // TODO ANTAG fix this
+        // If here are two definitions with 1/10 and 10/10 slots filled, this will always return the second definition
+        // even though it has already met its target
+        // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA I fucking hate game ticker code.
+        // It needs to track selected minds for each definition independently.
         foreach (var def in ent.Comp.Definitions)
         {
             var target = GetTargetAntagCount(ent, null, def);
@@ -64,6 +69,10 @@ public sealed partial class AntagSelectionSystem
     /// </summary>
     public int GetTargetAntagCount(Entity<AntagSelectionComponent> ent, AntagSelectionPlayerPool? pool, AntagSelectionDefinition def)
     {
+        // TODO ANTAG
+        // make pool non-nullable
+        // Review uses and ensure that people are INTENTIONALLY including players in the lobby if this is a mid-round
+        // antag selection.
         var poolSize = pool?.Count ?? _playerManager.Sessions
             .Count(s => s.State.Status is not SessionStatus.Disconnected and not SessionStatus.Zombie);
 
index 1d1783ed8756d7249b9ce6156bf0b0fa429c2d7c..5b6c891ddfc5c7b594cf603ad68a38144de1b27e 100644 (file)
@@ -14,6 +14,7 @@ using Content.Server.Roles.Jobs;
 using Content.Server.Shuttles.Components;
 using Content.Server.Station.Systems;
 using Content.Shared.Antag;
+using Content.Shared.GameTicking;
 using Content.Shared.Ghost;
 using Content.Shared.Humanoid;
 using Content.Shared.Players;
@@ -25,6 +26,7 @@ using Robust.Shared.Enums;
 using Robust.Shared.Map;
 using Robust.Shared.Player;
 using Robust.Shared.Random;
+using Robust.Shared.Utility;
 
 namespace Content.Server.Antag;
 
@@ -85,10 +87,9 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
                 continue;
 
             if (comp.SelectionsComplete)
-                return;
+                continue;
 
             ChooseAntags((uid, comp), pool);
-            comp.SelectionsComplete = true;
 
             foreach (var session in comp.SelectedSessions)
             {
@@ -106,11 +107,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
             if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn)
                 continue;
 
-            if (comp.SelectionsComplete)
-                continue;
-
-            ChooseAntags((uid, comp));
-            comp.SelectionsComplete = true;
+            ChooseAntags((uid, comp), args.Players);
         }
     }
 
@@ -126,12 +123,18 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
         var query = QueryActiveRules();
         while (query.MoveNext(out var uid, out _, out var antag, out _))
         {
+            // TODO ANTAG
+            // what why aasdiuhasdopiuasdfhksad
+            // stop this insanity please
+            // probability of antag assignment shouldn't depend on the order in which rules are returned by the query.
             if (!RobustRandom.Prob(LateJoinRandomChance))
                 continue;
 
             if (!antag.Definitions.Any(p => p.LateJoinAdditional))
                 continue;
 
+            DebugTools.AssertEqual(antag.SelectionTime, AntagSelectionTime.PostPlayerSpawn);
+
             if (!TryGetNextAvailableDefinition((uid, antag), out var def))
                 continue;
 
@@ -164,43 +167,40 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
     {
         base.Started(uid, component, gameRule, args);
 
-        if (component.SelectionsComplete)
-            return;
-
+        // If the round has not yet started, we defer antag selection until roundstart
         if (GameTicker.RunLevel != GameRunLevel.InRound)
             return;
 
-        if (GameTicker.RunLevel == GameRunLevel.InRound && component.SelectionTime == AntagSelectionTime.PrePlayerSpawn)
+        if (component.SelectionsComplete)
             return;
 
-        ChooseAntags((uid, component));
-        component.SelectionsComplete = true;
-    }
+        var players = _playerManager.Sessions
+            .Where(x => GameTicker.PlayerGameStatuses[x.UserId] == PlayerGameStatus.JoinedGame)
+            .ToList();
 
-    /// <summary>
-    /// Chooses antagonists from the current selection of players
-    /// </summary>
-    public void ChooseAntags(Entity<AntagSelectionComponent> ent)
-    {
-        var sessions = _playerManager.Sessions.ToList();
-        ChooseAntags(ent, sessions);
+        ChooseAntags((uid, component), players);
     }
 
     /// <summary>
     /// Chooses antagonists from the given selection of players
     /// </summary>
-    public void ChooseAntags(Entity<AntagSelectionComponent> ent, List<ICommonSession> pool)
+    public void ChooseAntags(Entity<AntagSelectionComponent> ent, IList<ICommonSession> pool)
     {
+        if (ent.Comp.SelectionsComplete)
+            return;
+
         foreach (var def in ent.Comp.Definitions)
         {
             ChooseAntags(ent, pool, def);
         }
+
+        ent.Comp.SelectionsComplete = true;
     }
 
     /// <summary>
     /// Chooses antagonists from the given selection of players for the given antag definition.
     /// </summary>
-    public void ChooseAntags(Entity<AntagSelectionComponent> ent, List<ICommonSession> pool, AntagSelectionDefinition def)
+    public void ChooseAntags(Entity<AntagSelectionComponent> ent, IList<ICommonSession> pool, AntagSelectionDefinition def)
     {
         var playerPool = GetPlayerPool(ent, pool, def);
         var count = GetTargetAntagCount(ent, playerPool, def);
@@ -324,20 +324,15 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
     /// <summary>
     /// Gets an ordered player pool based on player preferences and the antagonist definition.
     /// </summary>
-    public AntagSelectionPlayerPool GetPlayerPool(Entity<AntagSelectionComponent> ent, List<ICommonSession> sessions, AntagSelectionDefinition def)
+    public AntagSelectionPlayerPool GetPlayerPool(Entity<AntagSelectionComponent> ent, IList<ICommonSession> sessions, AntagSelectionDefinition def)
     {
         var preferredList = new List<ICommonSession>();
         var fallbackList = new List<ICommonSession>();
-        var unwantedList = new List<ICommonSession>();
-        var invalidList = new List<ICommonSession>();
         foreach (var session in sessions)
         {
             if (!IsSessionValid(ent, session, def) ||
                 !IsEntityValid(session.AttachedEntity, def))
-            {
-                invalidList.Add(session);
                 continue;
-            }
 
             var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter;
             if (def.PrefRoles.Count != 0 && pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p)))
@@ -348,13 +343,9 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
             {
                 fallbackList.Add(session);
             }
-            else
-            {
-                unwantedList.Add(session);
-            }
         }
 
-        return new AntagSelectionPlayerPool(new() { preferredList, fallbackList, unwantedList, invalidList });
+        return new AntagSelectionPlayerPool(new() { preferredList, fallbackList });
     }
 
     /// <summary>
@@ -365,14 +356,18 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
         if (session == null)
             return true;
 
-        mind ??= session.GetMind();
-
         if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie)
             return false;
 
         if (ent.Comp.SelectedSessions.Contains(session))
             return false;
 
+        mind ??= session.GetMind();
+
+        // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity.
+        if (mind == null)
+            return true;
+
         //todo: we need some way to check that we're not getting the same role twice. (double picking thieves or zombies through midrounds)
 
         switch (def.MultiAntagSetting)
@@ -401,10 +396,11 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
     /// <summary>
     /// Checks if a given entity (mind/session not included) is valid for a given antagonist.
     /// </summary>
-    private bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def)
+    public bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def)
     {
+        // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity.
         if (entity == null)
-            return false;
+            return true;
 
         if (HasComp<PendingClockInComponent>(entity))
             return false;
index df597e69b2fa5b26c609905db35e7759451e84f4..6eb42b65c03a4933ba18ec5c2ae0334c8f6392bd 100644 (file)
@@ -795,7 +795,7 @@ namespace Content.Server.GameTicking
     }
 
     /// <summary>
-    ///     Event raised after players were assigned jobs by the GameTicker.
+    ///     Event raised after players were assigned jobs by the GameTicker and have been spawned in.
     ///     You can give on-station people special roles by listening to this event.
     /// </summary>
     public sealed class RulePlayerJobsAssignedEvent
index 63c267f154acc09894072475f92f6d97c2cab4fb..cdb53bb4be77b0bc2b9a165967072f3563799f05 100644 (file)
@@ -20,5 +20,7 @@ namespace Content.Server.Preferences.Managers
         PlayerPreferences? GetPreferencesOrNull(NetUserId? userId);
         IEnumerable<KeyValuePair<NetUserId, ICharacterProfile>> GetSelectedProfilesForPlayers(List<NetUserId> userIds);
         bool HavePreferencesLoaded(ICommonSession session);
+
+        Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile);
     }
 }
index 1aad61715bb76b8150d2414101e1e4ff9522e578..e32af589e958ac157d693e80dc64fefbf565f5be 100644 (file)
@@ -29,11 +29,14 @@ namespace Content.Server.Preferences.Managers
         [Dependency] private readonly IServerDbManager _db = default!;
         [Dependency] private readonly IPlayerManager _playerManager = default!;
         [Dependency] private readonly IDependencyCollection _dependencies = default!;
+        [Dependency] private readonly ILogManager _log = default!;
 
         // Cache player prefs on the server so we don't need as much async hell related to them.
         private readonly Dictionary<NetUserId, PlayerPrefData> _cachedPlayerPrefs =
             new();
 
+        private ISawmill _sawmill = default!;
+
         private int MaxCharacterSlots => _cfg.GetCVar(CCVars.GameMaxCharacterSlots);
 
         public void Init()
@@ -42,6 +45,7 @@ namespace Content.Server.Preferences.Managers
             _netManager.RegisterNetMessage<MsgSelectCharacter>(HandleSelectCharacterMessage);
             _netManager.RegisterNetMessage<MsgUpdateCharacter>(HandleUpdateCharacterMessage);
             _netManager.RegisterNetMessage<MsgDeleteCharacter>(HandleDeleteCharacterMessage);
+            _sawmill = _log.GetSawmill("prefs");
         }
 
         private async void HandleSelectCharacterMessage(MsgSelectCharacter message)
@@ -78,27 +82,25 @@ namespace Content.Server.Preferences.Managers
 
         private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message)
         {
-            var slot = message.Slot;
-            var profile = message.Profile;
             var userId = message.MsgChannel.UserId;
 
-            if (profile == null)
-            {
-                Logger.WarningS("prefs",
-                    $"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {slot}.");
-                return;
-            }
+            // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+            if (message.Profile == null)
+                _sawmill.Error($"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {message.Slot}.");
+            else
+                await SetProfile(userId, message.Slot, message.Profile);
+        }
 
+        public async Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile)
+        {
             if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
             {
-                Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
+                _sawmill.Error($"Tried to modify user {userId} preferences before they loaded.");
                 return;
             }
 
             if (slot < 0 || slot >= MaxCharacterSlots)
-            {
                 return;
-            }
 
             var curPrefs = prefsData.Prefs!;
             var session = _playerManager.GetSessionById(userId);
@@ -112,10 +114,8 @@ namespace Content.Server.Preferences.Managers
 
             prefsData.Prefs = new PlayerPreferences(profiles, slot, curPrefs.AdminOOCColor);
 
-            if (ShouldStorePrefs(message.MsgChannel.AuthType))
-            {
-                await _db.SaveCharacterSlotAsync(message.MsgChannel.UserId, message.Profile, message.Slot);
-            }
+            if (ShouldStorePrefs(session.Channel.AuthType))
+                await _db.SaveCharacterSlotAsync(userId, profile, slot);
         }
 
         private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message)
index 02d0b5f58fee933da2516d9758d5c781cf182ebc..f56be97503353db69f4ef4a19891d21870afad98 100644 (file)
@@ -22,6 +22,14 @@ public enum AntagAcceptability
 
 public enum AntagSelectionTime : byte
 {
+    /// <summary>
+    /// Antag roles are assigned before players are assigned jobs and spawned in.
+    /// This prevents antag selection from happening if the round is on-going.
+    /// </summary>
     PrePlayerSpawn,
+
+    /// <summary>
+    /// Antag roles get assigned after players have been assigned jobs and have spawned in.
+    /// </summary>
     PostPlayerSpawn
 }
index 04ac45c4c5f4d939c82992b441e101fdb4bc9e8d..fcf76052785e04beb3249642031dcfa8612e5f36 100644 (file)
@@ -146,8 +146,10 @@ public abstract class SharedJobSystem : EntitySystem
 
     public bool CanBeAntag(ICommonSession player)
     {
+        // If the player does not have any mind associated with them (e.g., has not spawned in or is in the lobby), then
+        // they are eligible to be given an antag role/entity.
         if (_playerSystem.ContentData(player) is not { Mind: { } mindId })
-            return false;
+            return true;
 
         if (!MindTryGetJob(mindId, out _, out var prototype))
             return true;