diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs
index 6e0df92ad42..7fd6c4e542b 100644
--- a/Content.IntegrationTests/PoolManager.cs
+++ b/Content.IntegrationTests/PoolManager.cs
@@ -13,6 +13,11 @@ public static partial class PoolManager
public static readonly ContentPoolManager Instance = new();
public const string TestMap = "Empty";
+ ///
+ /// Designated load bearing station. Sometimes you need a station for a test.
+ ///
+ public const string TestStation = "Saltern";
+
///
/// Runs a server, or a client until a condition is true
///
diff --git a/Content.IntegrationTests/Tests/GameRules/StartEndGameRulesTest.cs b/Content.IntegrationTests/Tests/GameRules/StartEndGameRulesTest.cs
index bda931397b2..d4d30406ae2 100644
--- a/Content.IntegrationTests/Tests/GameRules/StartEndGameRulesTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/StartEndGameRulesTest.cs
@@ -18,7 +18,8 @@ public sealed class StartEndGameRulesTest
await using var pair = await PoolManager.GetServerClient(new PoolSettings
{
Dirty = true,
- DummyTicker = false
+ DummyTicker = false,
+ Map = PoolManager.TestStation
});
var server = pair.Server;
await server.WaitIdleAsync();
diff --git a/Content.IntegrationTests/Tests/Station/EvacShuttleTest.cs b/Content.IntegrationTests/Tests/Station/EvacShuttleTest.cs
index 02552669f7a..f7047578ee4 100644
--- a/Content.IntegrationTests/Tests/Station/EvacShuttleTest.cs
+++ b/Content.IntegrationTests/Tests/Station/EvacShuttleTest.cs
@@ -32,7 +32,7 @@ public sealed class EvacShuttleTest
pair.Server.CfgMan.SetCVar(CCVars.EmergencyShuttleEnabled, true);
pair.Server.CfgMan.SetCVar(CCVars.GameDummyTicker, false);
var gameMap = pair.Server.CfgMan.GetCVar(CCVars.GameMap);
- pair.Server.CfgMan.SetCVar(CCVars.GameMap, "Saltern");
+ pair.Server.CfgMan.SetCVar(CCVars.GameMap, PoolManager.TestStation);
await server.WaitPost(() => ticker.RestartRound());
await pair.RunTicksSync(25);
diff --git a/Content.Server/Antag/AntagMultipleRoleSpawnerSystem.cs b/Content.Server/Antag/AntagMultipleRoleSpawnerSystem.cs
index d59fbc82b4b..2b454105bf6 100644
--- a/Content.Server/Antag/AntagMultipleRoleSpawnerSystem.cs
+++ b/Content.Server/Antag/AntagMultipleRoleSpawnerSystem.cs
@@ -35,6 +35,6 @@ public sealed class AntagMultipleRoleSpawnerSystem : EntitySystem
if (entProtos.Count == 0)
return; // You will just get a normal job
- args.Entity = Spawn(ent.Comp.PickAndTake ? _random.PickAndTake(entProtos) : _random.Pick(entProtos));
+ args.Entity = Spawn(ent.Comp.PickAndTake ? _random.PickAndTake(entProtos) : _random.Pick(entProtos), args.Coords);
}
}
diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs
index 5debd10b6e4..44d6022e76e 100644
--- a/Content.Server/Antag/AntagSelectionSystem.API.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.API.cs
@@ -336,13 +336,14 @@ public sealed partial class AntagSelectionSystem
/// This technically is a gamerule-ent-less way to make an entity an antag.
/// You should almost never be using this.
///
- public void ForceMakeAntag(ICommonSession? player, string defaultRule) where T : Component
+ public void ForceMakeAntag(ICommonSession player, string defaultRule) where T : Component
{
var rule = ForceGetGameRuleEnt(defaultRule);
if (!TryGetNextAvailableDefinition(rule, out var def))
def = rule.Comp.Definitions.Last();
- MakeAntag(rule, player, def.Value);
+
+ MakeSessionAntagonist(rule, player, def.Value);
}
///
diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs
index 5c94942c9ae..604a0619036 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -26,6 +26,7 @@ using Content.Shared.Mind;
using Content.Shared.Players;
using Content.Shared.Roles;
using Content.Shared.Whitelist;
+using JetBrains.Annotations;
using Robust.Server.Audio;
using Robust.Server.GameObjects;
using Robust.Server.Player;
@@ -38,6 +39,14 @@ using Robust.Shared.Utility;
namespace Content.Server.Antag;
+///
+/// Turns players into antags.
+///
+///
+/// Do not ever ever ever spawn and initialize an entity prototype in nullspace then move it to the grid.
+/// I wasted 4 hours refactoring this system specifically to fix that mistake.
+/// Always initialize your entities attached to the entity you're spawning them on, or the correct map at the very least.
+///
public sealed partial class AntagSelectionSystem : GameRuleSystem
{
[Dependency] private readonly AudioSystem _audio = default!;
@@ -87,7 +96,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem(rule, out var select))
return;
- MakeAntag((rule, select), args.Player, def, ignoreSpawner: true);
+ AttachSessionToAntagonist((rule, select), args.Player, def, _transform.GetMapCoordinates(ent));
args.TookRole = true;
_ghostRole.UnregisterGhostRole((ent, Comp(ent)));
}
@@ -176,7 +185,8 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
/// The session to attempt to make antag.
- public void TryMakeLateJoinAntag(ICommonSession session)
+ [PublicAPI]
+ public bool TryMakeLateJoinAntag(ICommonSession session)
{
// TODO: this really doesn't handle multiple latejoin definitions well
// eventually this should probably store the players per definition with some kind of unique identifier.
@@ -208,8 +218,10 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem());
- set.Add(session); // Selection done!
- Log.Debug($"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
- _adminLogger.Add(LogType.AntagSelection, $"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
- }
+ PreSelectSessionForAntagonist(ent, session, def);
}
}
@@ -353,123 +359,181 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
/// Tries to makes a given player into the specified antagonist.
///
- public bool TryMakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false, bool checkPref = true, bool onlyPreSelect = false)
+ public bool TryMakeAntag(Entity ent, ICommonSession session, AntagSelectionDefinition def, bool checkPref = true, bool onlyPreSelect = false)
{
_adminLogger.Add(LogType.AntagSelection, $"Start trying to make {session} become the antagonist: {ToPrettyString(ent)}");
if (checkPref && !ValidAntagPreference(session, def.PrefRoles))
return false;
- if (!IsSessionValid(ent, session, def) || !IsEntityValid(session?.AttachedEntity, def))
+ if (!IsSessionValid(ent, session, def) || !IsEntityValid(session.AttachedEntity, def))
return false;
- if (onlyPreSelect && session != null)
- {
- if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
- ent.Comp.PreSelectedSessions.Add(def, set = new HashSet());
- set.Add(session);
- Log.Debug($"Pre-selected {session!.Name} as antagonist: {ToPrettyString(ent)}");
- _adminLogger.Add(LogType.AntagSelection, $"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
- }
+ if (onlyPreSelect)
+ PreSelectSessionForAntagonist(ent, session, def);
else
- {
- MakeAntag(ent, session, def, ignoreSpawner);
- }
+ MakeSessionAntagonist(ent, session, def);
return true;
}
///
- /// Makes a given player into the specified antagonist.
+ /// Create an antag spawner which can be taken over by a player through the ghost role system.
///
- public void MakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
+ /// Antag rule entity
+ /// Antag selection definition chosen from the entity
+ [PublicAPI]
+ private EntityUid? CreateAntagSpawner(Entity ent, AntagSelectionDefinition def)
{
- EntityUid? antagEnt = null;
- var isSpawner = false;
+ if (def.SpawnerPrototype is not { } proto)
+ return null;
- if (session != null)
+ var spawner = Spawn(def.SpawnerPrototype);
+ if (!TryValidSpawnPosition(ent, spawner))
{
- if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
- ent.Comp.PreSelectedSessions.Add(def, set = new HashSet());
- set.Add(session);
- ent.Comp.AssignedSessions.Add(session);
-
- // we shouldn't be blocking the entity if they're just a ghost or smth.
- if (!HasComp(session.AttachedEntity))
- antagEnt = session.AttachedEntity;
- }
- else if (!ignoreSpawner && def.SpawnerPrototype != null) // don't add spawners if we have a player, dummy.
- {
- antagEnt = Spawn(def.SpawnerPrototype);
- isSpawner = true;
+ Log.Error($"Found no valid positions to place antag spawner {ToPrettyString(spawner)} prototype: {proto}");
+ Del(spawner);
+ return null;
}
- if (!antagEnt.HasValue)
+ if (!TryComp(spawner, out var spawnerComp))
{
- var getEntEv = new AntagSelectEntityEvent(session, ent, def.PrefRoles);
-
- RaiseLocalEvent(ent, ref getEntEv, true);
- antagEnt = getEntEv.Entity;
+ Log.Error($"Antag spawner {spawner} does not have a {nameof(GhostRoleAntagSpawnerComponent)}.");
+ _adminLogger.Add(LogType.AntagSelection, $"Antag spawner {spawner} in gamerule {ToPrettyString(ent)} failed due to not having {nameof(GhostRoleAntagSpawnerComponent)}.");
+ Del(spawner);
+ return null;
}
- if (antagEnt is not { } player)
- {
- Log.Error($"Attempted to make {session} antagonist in gamerule {ToPrettyString(ent)} but there was no valid entity for player.");
- _adminLogger.Add(LogType.AntagSelection, $"Attempted to make {session} antagonist in gamerule {ToPrettyString(ent)} but there was no valid entity for player.");
- if (session != null && ent.Comp.RemoveUponFailedSpawn)
- {
- ent.Comp.AssignedSessions.Remove(session);
- ent.Comp.PreSelectedSessions[def].Remove(session);
- }
+ spawnerComp.Rule = ent;
+ spawnerComp.Definition = def;
+ return spawner;
+ }
- return;
+ ///
+ /// Does antag pre-selection logic, adding a specified player session to the PreSelection list and logging it for admins.
+ ///
+ private void PreSelectSessionForAntagonist(Entity ent, ICommonSession session, AntagSelectionDefinition def)
+ {
+ if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
+ ent.Comp.PreSelectedSessions.Add(def, set = new HashSet());
+ set.Add(session);
+
+ Log.Debug($"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
+ _adminLogger.Add(LogType.AntagSelection, $"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
+ }
+
+ ///
+ /// Creates a new antagonist entity at the specified coordinates, then attaches the specified player to that antagonist.
+ ///
+ private EntityUid? AttachSessionToAntagonist(Entity ent,
+ ICommonSession session,
+ AntagSelectionDefinition def,
+ MapCoordinates coords)
+ {
+ PreSelectSessionForAntagonist(ent, session, def);
+ ent.Comp.AssignedSessions.Add(session);
+ return SpawnNewAntagonist(ent, session, def, coords);
+ }
+
+ ///
+ /// Makes a specified player into a specified antagonist.
+ /// If the player is a ghost or has no attached entity, it will attempt to find a valid spawn position and spawn a new entity.
+ /// Otherwise, it will try to move their current entity to their antag's spawn position (if it exists) and then set them up as antag.
+ ///
+ private EntityUid? MakeSessionAntagonist(Entity ent, ICommonSession session, AntagSelectionDefinition def)
+ {
+ PreSelectSessionForAntagonist(ent, session, def);
+
+ ent.Comp.AssignedSessions.Add(session);
+
+ // If the player has no entity to make an antagonist, make a new entity for them
+ if (HasComp(session.AttachedEntity) || session.AttachedEntity is not { } player)
+ {
+ return SpawnNewAntagonist(ent, session, def);
}
- // TODO: This is really messy because this part runs twice for midround events.
- // Once when the ghostrole spawner is created and once when a player takes it.
- // Therefore any component subscribing to this has to make sure both subscriptions return the same value
- // or the ghost role raffle location preview will be wrong.
+ TryValidSpawnPosition(ent, player, session);
+ InitializeAntag(ent, player, session, def);
+ return player;
+ }
- var getPosEv = new AntagSelectLocationEvent(session, ent, player);
+ ///
+ /// Attempts to create a new antagonist entity and attach a player session to it at a valid spawnpoint.
+ /// Does nothing if it cannot find a valid spawnpoint.
+ ///
+ private EntityUid? SpawnNewAntagonist(Entity ent, ICommonSession session, AntagSelectionDefinition def)
+ {
+ if (GetValidSpawnPosition(ent, session.AttachedEntity, session) is not { } coordinates)
+ {
+ Log.Error($"Was unable to find a valid spawn position for, {session.Name}, gamerule: {ToPrettyString(ent)} when trying to make them into an antagonist.");
+ return null;
+ }
+
+ return SpawnNewAntagonist(ent, session, def, coordinates);
+ }
+
+ ///
+ /// Attempts to create a new antagonist entity at the specified coordinates and attach a player session to it.
+ /// If it cannot spawn an antagonist entity, it does nothing.
+ ///
+ private EntityUid? SpawnNewAntagonist(Entity ent, ICommonSession session, AntagSelectionDefinition def, MapCoordinates coordinates)
+ {
+ var getEntEv = new AntagSelectEntityEvent(session, ent, def.PrefRoles, coordinates);
+
+ RaiseLocalEvent(ent, ref getEntEv, true);
+ if (getEntEv.Entity is not { } antag)
+ {
+ Log.Error($"Tried to make {session.UserId} into an antagonist but was unable to spawn an entity for them. Gamerule {ToPrettyString(ent)}");
+ return null;
+ }
+
+ InitializeAntag(ent, antag, session, def);
+ return antag;
+ }
+
+ ///
+ /// Raises an event to the gamerule to check all valid possible spawning points for this rule.
+ /// Returns a random spawnpoint from a list of valid spawnpoints, or null if there weren't any.
+ ///
+ private MapCoordinates? GetValidSpawnPosition(Entity ent, EntityUid? antag, ICommonSession? session = null)
+ {
+ var getPosEv = new AntagSelectLocationEvent(ent, antag, session);
RaiseLocalEvent(ent, ref getPosEv, true);
- if (getPosEv.Handled)
- {
- var playerXform = Transform(player);
- var pos = RobustRandom.Pick(getPosEv.Coordinates);
- _transform.SetMapCoordinates((player, playerXform), pos);
- }
- // If we want to just do a ghost role spawner, set up data here and then return early.
- // This could probably be an event in the future if we want to be more refined about it.
- if (isSpawner)
- {
- if (!TryComp(player, out var spawnerComp))
- {
- Log.Error($"Antag spawner {player} does not have a GhostRoleAntagSpawnerComponent.");
- _adminLogger.Add(LogType.AntagSelection, $"Antag spawner {player} in gamerule {ToPrettyString(ent)} failed due to not having GhostRoleAntagSpawnerComponent.");
- if (session != null)
- {
- ent.Comp.AssignedSessions.Remove(session);
- ent.Comp.PreSelectedSessions[def].Remove(session);
- }
+ if (!getPosEv.Handled)
+ return null;
- return;
- }
+ return RobustRandom.Pick(getPosEv.Coordinates);
+ }
- spawnerComp.Rule = ent;
- spawnerComp.Definition = def;
- return;
- }
+ ///
+ /// Looks for a valid spawn position for this antagonist type, then moves the antagonist entity to that spawn position.
+ ///
+ private bool TryValidSpawnPosition(Entity ent, EntityUid antag, ICommonSession? session = null)
+ {
+ if (GetValidSpawnPosition(ent, antag, session) is not { } coordinates)
+ return false;
+ var xform = Transform(antag);
+ _transform.SetMapCoordinates((antag, xform), coordinates);
+ return true;
+ }
+
+ ///
+ /// Initializes the antagonist status on the specified entity.
+ /// Adds the needed components, loadouts, items, attaches the player and fires off an event.
+ ///
+ private void InitializeAntag(Entity ent, EntityUid antag, ICommonSession? session, AntagSelectionDefinition def)
+ {
// The following is where we apply components, equipment, and other changes to our antagonist entity.
- EntityManager.AddComponents(player, def.Components);
+ EntityManager.AddComponents(antag, def.Components);
// Equip the entity's RoleLoadout and LoadoutGroup
List> gear = new();
if (def.StartingGear is not null)
gear.Add(def.StartingGear.Value);
- _loadout.Equip(player, gear, def.RoleLoadout);
+ _loadout.Equip(antag, gear, def.RoleLoadout);
if (session != null)
{
@@ -477,22 +541,22 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem(curMind.Value, out var mindComp) ||
- mindComp.OwnedEntity != antagEnt)
+ mindComp.OwnedEntity != antag)
{
- curMind = _mind.CreateMind(session.UserId, Name(antagEnt.Value));
+ curMind = _mind.CreateMind(session.UserId, Name(antag));
_mind.SetUserId(curMind.Value, session.UserId);
}
- _mind.TransferTo(curMind.Value, antagEnt, ghostCheckOverride: true);
+ _mind.TransferTo(curMind.Value, antag, ghostCheckOverride: true);
_role.MindAddRoles(curMind.Value, def.MindRoles, null, true);
- ent.Comp.AssignedMinds.Add((curMind.Value, Name(player)));
+ ent.Comp.AssignedMinds.Add((curMind.Value, Name(antag)));
SendBriefing(session, def.Briefing);
Log.Debug($"Assigned {ToPrettyString(curMind)} as antagonist: {ToPrettyString(ent)}");
_adminLogger.Add(LogType.AntagSelection, $"Assigned {ToPrettyString(curMind)} as antagonist: {ToPrettyString(ent)}");
}
- var afterEv = new AfterAntagEntitySelectedEvent(session, player, ent, def);
+ var afterEv = new AfterAntagEntitySelectedEvent(session, antag, ent, def);
RaiseLocalEvent(ent, ref afterEv, true);
}
@@ -617,14 +681,17 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
+/// TODO: This should really be an interface instead, we're always raising this to the same entity anyways and the values are extremely predictable
[ByRefEvent]
-public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity GameRule, List> AntagRoles)
+public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity GameRule, List> AntagRoles, MapCoordinates Coords)
{
public readonly ICommonSession? Session = Session;
/// list of antag role prototypes associated with a entity. used by the
public readonly List> AntagRoles = AntagRoles;
+ public readonly MapCoordinates Coords = Coords;
+
public bool Handled => Entity != null;
public EntityUid? Entity;
@@ -634,20 +701,20 @@ public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity
[ByRefEvent]
-public record struct AntagSelectLocationEvent(ICommonSession? Session, Entity GameRule, EntityUid Entity)
+public record struct AntagSelectLocationEvent(Entity GameRule, EntityUid? Entity, ICommonSession? Session = null)
{
public readonly ICommonSession? Session = Session;
public bool Handled => Coordinates.Any();
// the entity of the antagonist
- public EntityUid Entity = Entity;
+ public EntityUid? Entity = Entity;
public List Coordinates = new();
}
///
-/// Event raised on a game rule entity after the setup logic for an antag is complete.
+/// Event raised on a game ruleR entity after the setup logic for an antag is complete.
/// Used for applying additional more complex setup logic.
///
[ByRefEvent]
diff --git a/Content.Server/Antag/AntagSpawnerSystem.cs b/Content.Server/Antag/AntagSpawnerSystem.cs
index f8a036749a1..dd0c99cc0e4 100644
--- a/Content.Server/Antag/AntagSpawnerSystem.cs
+++ b/Content.Server/Antag/AntagSpawnerSystem.cs
@@ -16,6 +16,6 @@ public sealed class AntagSpawnerSystem : EntitySystem
private void OnSelectEntity(Entity ent, ref AntagSelectEntityEvent args)
{
- args.Entity = Spawn(ent.Comp.Prototype);
+ args.Entity = Spawn(ent.Comp.Prototype, args.Coords);
}
}
diff --git a/Content.Server/GameTicking/Rules/AntagLoadProfileRuleSystem.cs b/Content.Server/GameTicking/Rules/AntagLoadProfileRuleSystem.cs
index 22916f0c18e..fff3ee8de6f 100644
--- a/Content.Server/GameTicking/Rules/AntagLoadProfileRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/AntagLoadProfileRuleSystem.cs
@@ -36,7 +36,7 @@ public sealed class AntagLoadProfileRuleSystem : GameRuleSystem(HumanoidCharacterProfile.DefaultSpecies);
+ species = _proto.Index(HumanoidCharacterProfile.DefaultSpecies);
}
if (ent.Comp.SpeciesOverride != null
@@ -45,7 +45,7 @@ public sealed class AntagLoadProfileRuleSystem : GameRuleSystem