Cleanup Antag Selection Logic a Lot (#42673)

* commit god

* don't log an error every time we try to turn a nonghost into an antag cause that shit is optional...

* asdsasdsasd

* Saltern is load bearing

* doesn't even break anything

* don't all caps the comment, add more detail

* review

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>

* make them return the antag entity

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
Co-authored-by: Tayrtahn <tayrtahn@gmail.com>
This commit is contained in:
Princess Cheeseballs
2026-02-04 21:35:41 -08:00
committed by GitHub
parent 41f042b8f9
commit c2f986ea8b
9 changed files with 180 additions and 112 deletions

View File

@@ -13,6 +13,11 @@ public static partial class PoolManager
public static readonly ContentPoolManager Instance = new();
public const string TestMap = "Empty";
/// <summary>
/// Designated load bearing station. Sometimes you need a station for a test.
/// </summary>
public const string TestStation = "Saltern";
/// <summary>
/// Runs a server, or a client until a condition is true
/// </summary>

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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.
/// </summary>
public void ForceMakeAntag<T>(ICommonSession? player, string defaultRule) where T : Component
public void ForceMakeAntag<T>(ICommonSession player, string defaultRule) where T : Component
{
var rule = ForceGetGameRuleEnt<T>(defaultRule);
if (!TryGetNextAvailableDefinition(rule, out var def))
def = rule.Comp.Definitions.Last();
MakeAntag(rule, player, def.Value);
MakeSessionAntagonist(rule, player, def.Value);
}
/// <summary>

View File

@@ -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;
/// <summary>
/// Turns players into antags.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelectionComponent>
{
[Dependency] private readonly AudioSystem _audio = default!;
@@ -87,7 +96,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
if (!Exists(rule) || !TryComp<AntagSelectionComponent>(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<GhostRoleComponent>(ent)));
}
@@ -176,7 +185,8 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
/// Attempt to make this player be a late-join antag.
/// </summary>
/// <param name="session">The session to attempt to make antag.</param>
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<AntagSelection
continue;
if (TryMakeAntag((uid, antag), session, def.Value))
break;
return true;
}
return false;
}
protected override void Added(EntityUid uid, AntagSelectionComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args)
@@ -315,15 +327,9 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
}
if (session == null)
MakeAntag(ent, null, def); // This is for spawner antags
CreateAntagSpawner(ent, def); // Create a spawner since there's no session to attach.
else
{
if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
ent.Comp.PreSelectedSessions.Add(def, set = new HashSet<ICommonSession>());
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<AntagSelection
/// <summary>
/// Tries to makes a given player into the specified antagonist.
/// </summary>
public bool TryMakeAntag(Entity<AntagSelectionComponent> ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false, bool checkPref = true, bool onlyPreSelect = false)
public bool TryMakeAntag(Entity<AntagSelectionComponent> 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<ICommonSession>());
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;
}
/// <summary>
/// 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.
/// </summary>
public void MakeAntag(Entity<AntagSelectionComponent> ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
/// <param name="ent">Antag rule entity</param>
/// <param name="def">Antag selection definition chosen from the entity</param>
[PublicAPI]
private EntityUid? CreateAntagSpawner(Entity<AntagSelectionComponent> 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<ICommonSession>());
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<GhostComponent>(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<GhostRoleAntagSpawnerComponent>(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;
/// <summary>
/// Does antag pre-selection logic, adding a specified player session to the PreSelection list and logging it for admins.
/// </summary>
private void PreSelectSessionForAntagonist(Entity<AntagSelectionComponent> ent, ICommonSession session, AntagSelectionDefinition def)
{
if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
ent.Comp.PreSelectedSessions.Add(def, set = new HashSet<ICommonSession>());
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)}");
}
/// <summary>
/// Creates a new antagonist entity at the specified coordinates, then attaches the specified player to that antagonist.
/// </summary>
private EntityUid? AttachSessionToAntagonist(Entity<AntagSelectionComponent> ent,
ICommonSession session,
AntagSelectionDefinition def,
MapCoordinates coords)
{
PreSelectSessionForAntagonist(ent, session, def);
ent.Comp.AssignedSessions.Add(session);
return SpawnNewAntagonist(ent, session, def, coords);
}
/// <summary>
/// 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.
/// </summary>
private EntityUid? MakeSessionAntagonist(Entity<AntagSelectionComponent> 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<GhostComponent>(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);
/// <summary>
/// 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.
/// </summary>
private EntityUid? SpawnNewAntagonist(Entity<AntagSelectionComponent> 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);
}
/// <summary>
/// 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.
/// </summary>
private EntityUid? SpawnNewAntagonist(Entity<AntagSelectionComponent> 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;
}
/// <summary>
/// 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.
/// </summary>
private MapCoordinates? GetValidSpawnPosition(Entity<AntagSelectionComponent> 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<GhostRoleAntagSpawnerComponent>(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;
}
/// <summary>
/// Looks for a valid spawn position for this antagonist type, then moves the antagonist entity to that spawn position.
/// </summary>
private bool TryValidSpawnPosition(Entity<AntagSelectionComponent> 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;
}
/// <summary>
/// Initializes the antagonist status on the specified entity.
/// Adds the needed components, loadouts, items, attaches the player and fires off an event.
/// </summary>
private void InitializeAntag(Entity<AntagSelectionComponent> 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<ProtoId<StartingGearPrototype>> 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<AntagSelection
if (curMind == null ||
!TryComp<MindComponent>(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<AntagSelection
/// Event raised on a game rule entity in order to determine what the antagonist entity will be.
/// Only raised if the selected player's current entity is invalid.
/// </summary>
/// 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<AntagSelectionComponent> GameRule, List<ProtoId<AntagPrototype>> AntagRoles)
public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity<AntagSelectionComponent> GameRule, List<ProtoId<AntagPrototype>> AntagRoles, MapCoordinates Coords)
{
public readonly ICommonSession? Session = Session;
/// list of antag role prototypes associated with a entity. used by the <see cref="AntagMultipleRoleSpawnerComponent"/>
public readonly List<ProtoId<AntagPrototype>> 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<Anta
/// Event raised on a game rule entity to determine the location for the antagonist.
/// </summary>
[ByRefEvent]
public record struct AntagSelectLocationEvent(ICommonSession? Session, Entity<AntagSelectionComponent> GameRule, EntityUid Entity)
public record struct AntagSelectLocationEvent(Entity<AntagSelectionComponent> 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<MapCoordinates> Coordinates = new();
}
/// <summary>
/// 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.
/// </summary>
[ByRefEvent]

View File

@@ -16,6 +16,6 @@ public sealed class AntagSpawnerSystem : EntitySystem
private void OnSelectEntity(Entity<AntagSpawnerComponent> ent, ref AntagSelectEntityEvent args)
{
args.Entity = Spawn(ent.Comp.Prototype);
args.Entity = Spawn(ent.Comp.Prototype, args.Coords);
}
}

View File

@@ -36,7 +36,7 @@ public sealed class AntagLoadProfileRuleSystem : GameRuleSystem<AntagLoadProfile
if (profile?.Species is not { } speciesId || !_proto.Resolve(speciesId, out var species))
{
species = _proto.Index<SpeciesPrototype>(HumanoidCharacterProfile.DefaultSpecies);
species = _proto.Index(HumanoidCharacterProfile.DefaultSpecies);
}
if (ent.Comp.SpeciesOverride != null
@@ -45,7 +45,7 @@ public sealed class AntagLoadProfileRuleSystem : GameRuleSystem<AntagLoadProfile
species = _proto.Index(ent.Comp.SpeciesOverride.Value);
}
args.Entity = Spawn(species.Prototype);
args.Entity = Spawn(species.Prototype, args.Coords);
if (profile?.WithSpecies(species.ID) is { } humanoidProfile)
{
_visualBody.ApplyProfileTo(args.Entity.Value, humanoidProfile);

View File

@@ -5,13 +5,9 @@
components:
- type: GridSpawnPointWhitelist
whitelist:
components:
- Xenoborg
tags:
- XenoborgGhostrole
blacklist:
components:
- MothershipCore
tags:
- MothershipCoreGhostrole
- type: SpawnPoint
@@ -28,8 +24,6 @@
components:
- type: GridSpawnPointWhitelist
whitelist:
components:
- MothershipCore
tags:
- MothershipCoreGhostrole
- type: SpawnPoint