diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
index 0ea6d3e2dcc079..cc83232a066374 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 async Task WaitClientCommand(string cmd, int numTicks = 10)
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 25e6c7ef26f5bc..3b49ffcf847757 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 00000000000000..662ea3b97470fa
--- /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 5bada98a3aa518..3a77834b72596a 100644
--- a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
@@ -58,6 +58,9 @@ public async Task TryStopNukeOpsFromConstantlyFailing()
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 async Task TryStopNukeOpsFromConstantlyFailing()
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 317aa10400b959..4415eddf376d25 100644
--- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
+++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
@@ -407,7 +407,6 @@ public async Task InsideContainerInteractionBlockTest()
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 d5c2a9124dd9b6..40457f54883570 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 @@ public async Task ResettingEntitySystemResetTest()
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 6acd17a35b273f..279703c875406c 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 int GetTargetAntagCount(Entity ent, AntagSelecti
///
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 57df82d6fd9bd7..85d332018bc6e8 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -13,6 +13,7 @@
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;
@@ -24,6 +25,7 @@
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Random;
+using Robust.Shared.Utility;
namespace Content.Server.Antag;
@@ -82,10 +84,9 @@ private void OnPlayerSpawning(RulePlayerSpawningEvent args)
continue;
if (comp.SelectionsComplete)
- return;
+ continue;
ChooseAntags((uid, comp), pool);
- comp.SelectionsComplete = true;
foreach (var session in comp.SelectedSessions)
{
@@ -103,11 +104,7 @@ private void OnJobsAssigned(RulePlayerJobsAssignedEvent args)
if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn)
continue;
- if (comp.SelectionsComplete)
- continue;
-
- ChooseAntags((uid, comp));
- comp.SelectionsComplete = true;
+ ChooseAntags((uid, comp), args.Players);
}
}
@@ -123,12 +120,18 @@ private void OnSpawnComplete(PlayerSpawnCompleteEvent args)
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;
@@ -161,43 +164,40 @@ protected override void Started(EntityUid uid, AntagSelectionComponent component
{
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();
- ///
- /// 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);
@@ -321,20 +321,15 @@ public void MakeAntag(Entity ent, ICommonSession? sessi
///
/// 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)))
@@ -345,13 +340,9 @@ public AntagSelectionPlayerPool GetPlayerPool(Entity en
{
fallbackList.Add(session);
}
- else
- {
- unwantedList.Add(session);
- }
}
- return new AntagSelectionPlayerPool(new() { preferredList, fallbackList, unwantedList, invalidList });
+ return new AntagSelectionPlayerPool(new() { preferredList, fallbackList });
}
///
@@ -362,14 +353,18 @@ public bool IsSessionValid(Entity ent, ICommonSession?
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)
@@ -398,10 +393,11 @@ public bool IsSessionValid(Entity ent, ICommonSession?
///
/// 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 df597e69b2fa5b..6eb42b65c03a49 100644
--- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs
+++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs
@@ -795,7 +795,7 @@ public RulePlayerSpawningEvent(List playerPool, IReadOnlyDiction
}
///
- /// 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 63c267f154acc0..cdb53bb4be77b0 100644
--- a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs
@@ -20,5 +20,7 @@ public interface IServerPreferencesManager
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 1aad61715bb76b..e32af589e958ac 100644
--- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
@@ -29,11 +29,14 @@ public sealed class ServerPreferencesManager : IServerPreferencesManager
[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 @@ public void Init()
_netManager.RegisterNetMessage(HandleSelectCharacterMessage);
_netManager.RegisterNetMessage(HandleUpdateCharacterMessage);
_netManager.RegisterNetMessage(HandleDeleteCharacterMessage);
+ _sawmill = _log.GetSawmill("prefs");
}
private async void HandleSelectCharacterMessage(MsgSelectCharacter message)
@@ -78,27 +82,25 @@ private async void HandleSelectCharacterMessage(MsgSelectCharacter message)
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 @@ private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message)
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 02d0b5f58fee93..f56be97503353d 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 04ac45c4c5f4d9..fcf76052785e04 100644
--- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs
+++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs
@@ -146,8 +146,10 @@ public string MindTryGetJobName([NotNullWhen(true)] EntityUid? mindId)
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;