diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
index 0ea6d3e2dc..cc83232a06 100644
--- a/Content.IntegrationTests/Pair/TestPair.Helpers.cs
+++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
@@ -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;
}
+
+ ///
+ /// Helper method for enabling or disabling a antag role
+ ///
+ public async Task SetAntagPref(ProtoId id, bool value)
+ {
+ var prefMan = Server.ResolveDependency();
+
+ 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));
+ }
}
diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs
index 25e6c7ef26..3b49ffcf84 100644
--- a/Content.IntegrationTests/PoolManager.cs
+++ b/Content.IntegrationTests/PoolManager.cs
@@ -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();
- entSysMan.LoadExtraSystemType();
- entSysMan.LoadExtraSystemType();
entSysMan.LoadExtraSystemType();
entSysMan.LoadExtraSystemType();
+
IoCManager.Resolve().GetSawmill("loc").Level = LogLevel.Error;
IoCManager.Resolve()
.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
index 0000000000..662ea3b974
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs
@@ -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();
+ var sys = server.System();
+
+ // 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(uid, server.EntMan.GetComponent(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{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(), 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();
+ }
+}
diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
index 5bada98a3a..3a77834b72 100644
--- a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
@@ -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(), Is.Zero);
Assert.That(entMan.Count(), 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();
}
}
diff --git a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
index 317aa10400..4415eddf37 100644
--- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
+++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
@@ -407,7 +407,6 @@ namespace Content.IntegrationTests.Tests.Interaction.Click
await pair.CleanReturnAsync();
}
- [Reflect(false)]
public sealed class TestInteractionSystem : EntitySystem
{
public EntityEventHandler? InteractUsingEvent;
diff --git a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
index d5c2a9124d..40457f5488 100644
--- a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
+++ b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
@@ -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);
diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs
index d8554e3fb2..3b5855cdf9 100644
--- a/Content.Server/Antag/AntagSelectionSystem.API.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.API.cs
@@ -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
///
public int GetTargetAntagCount(Entity 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);
diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs
index 1d1783ed87..5b6c891ddf 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -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 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 GameTicker.PlayerGameStatuses[x.UserId] == PlayerGameStatus.JoinedGame)
+ .ToList();
- ///
- /// Chooses antagonists from the current selection of players
- ///
- public void ChooseAntags(Entity ent)
- {
- var sessions = _playerManager.Sessions.ToList();
- ChooseAntags(ent, sessions);
+ ChooseAntags((uid, component), players);
}
///
/// Chooses antagonists from the given selection of players
///
- public void ChooseAntags(Entity ent, List pool)
+ public void ChooseAntags(Entity ent, IList pool)
{
+ if (ent.Comp.SelectionsComplete)
+ return;
+
foreach (var def in ent.Comp.Definitions)
{
ChooseAntags(ent, pool, def);
}
+
+ ent.Comp.SelectionsComplete = true;
}
///
/// Chooses antagonists from the given selection of players for the given antag definition.
///
- public void ChooseAntags(Entity ent, List pool, AntagSelectionDefinition def)
+ public void ChooseAntags(Entity ent, IList 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
/// Gets an ordered player pool based on player preferences and the antagonist definition.
///
- public AntagSelectionPlayerPool GetPlayerPool(Entity ent, List sessions, AntagSelectionDefinition def)
+ public AntagSelectionPlayerPool GetPlayerPool(Entity ent, IList sessions, AntagSelectionDefinition def)
{
var preferredList = new List();
var fallbackList = new List();
- var unwantedList = new List();
- var invalidList = new List();
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
@@ -365,14 +356,18 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
/// Checks if a given entity (mind/session not included) is valid for a given antagonist.
///
- 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(entity))
return false;
diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs
index df597e69b2..6eb42b65c0 100644
--- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs
+++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs
@@ -795,7 +795,7 @@ namespace Content.Server.GameTicking
}
///
- /// 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.
///
public sealed class RulePlayerJobsAssignedEvent
diff --git a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs
index 63c267f154..cdb53bb4be 100644
--- a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs
@@ -20,5 +20,7 @@ namespace Content.Server.Preferences.Managers
PlayerPreferences? GetPreferencesOrNull(NetUserId? userId);
IEnumerable> GetSelectedProfilesForPlayers(List userIds);
bool HavePreferencesLoaded(ICommonSession session);
+
+ Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile);
}
}
diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
index 1aad61715b..e32af589e9 100644
--- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
@@ -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 _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(HandleSelectCharacterMessage);
_netManager.RegisterNetMessage(HandleUpdateCharacterMessage);
_netManager.RegisterNetMessage(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)
diff --git a/Content.Shared/Antag/AntagAcceptability.cs b/Content.Shared/Antag/AntagAcceptability.cs
index 02d0b5f58f..f56be97503 100644
--- a/Content.Shared/Antag/AntagAcceptability.cs
+++ b/Content.Shared/Antag/AntagAcceptability.cs
@@ -22,6 +22,14 @@ public enum AntagAcceptability
public enum AntagSelectionTime : byte
{
+ ///
+ /// Antag roles are assigned before players are assigned jobs and spawned in.
+ /// This prevents antag selection from happening if the round is on-going.
+ ///
PrePlayerSpawn,
+
+ ///
+ /// Antag roles get assigned after players have been assigned jobs and have spawned in.
+ ///
PostPlayerSpawn
}
diff --git a/Content.Shared/Roles/Jobs/SharedJobSystem.cs b/Content.Shared/Roles/Jobs/SharedJobSystem.cs
index 04ac45c4c5..fcf7605278 100644
--- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs
+++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs
@@ -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;