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