diff --git a/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs b/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
index de51b2fb192..8512107b69d 100644
--- a/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
+++ b/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
@@ -58,7 +58,7 @@ await _pair.Server.WaitPost(() =>
for (var i = 0; i < N; i++)
{
_entity = server.EntMan.SpawnAttachedTo(Mob, _coords);
- _spawnSys.EquipStartingGear(_entity, _gear, null);
+ _spawnSys.EquipStartingGear(_entity, _gear);
server.EntMan.DeleteEntity(_entity);
}
});
diff --git a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
index 8087d1833e6..867dcbc2692 100644
--- a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
+++ b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
@@ -118,8 +118,11 @@ private void SetLayerData(
/// This should not be used if the entity is owned by the server. The server will otherwise
/// override this with the appearance data it sends over.
///
- public override void LoadProfile(EntityUid uid, HumanoidCharacterProfile profile, HumanoidAppearanceComponent? humanoid = null)
+ public override void LoadProfile(EntityUid uid, HumanoidCharacterProfile? profile, HumanoidAppearanceComponent? humanoid = null)
{
+ if (profile == null)
+ return;
+
if (!Resolve(uid, ref humanoid))
{
return;
diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
new file mode 100644
index 00000000000..f539daee367
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
@@ -0,0 +1,208 @@
+/* WD edit
+
+#nullable enable
+using System.Linq;
+using Content.Server.Body.Components;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Presets;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Mind;
+using Content.Server.NPC.Systems;
+using Content.Server.Pinpointer;
+using Content.Server.Roles;
+using Content.Server.Shuttles.Components;
+using Content.Server.Station.Components;
+using Content.Shared.CCVar;
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+using Content.Shared.GameTicking;
+using Content.Shared.Hands.Components;
+using Content.Shared.Inventory;
+using Content.Shared.NukeOps;
+using Robust.Server.GameObjects;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map.Components;
+
+namespace Content.IntegrationTests.Tests.GameRules;
+
+[TestFixture]
+public sealed class NukeOpsTest
+{
+ ///
+ /// Check that a nuke ops game mode can start without issue. I.e., that the nuke station and such all get loaded.
+ ///
+ [Test]
+ public async Task TryStopNukeOpsFromConstantlyFailing()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Dirty = true,
+ DummyTicker = false,
+ Connected = true,
+ InLobby = true
+ });
+
+ var server = pair.Server;
+ var client = pair.Client;
+ var entMan = server.EntMan;
+ var mapSys = server.System();
+ var ticker = server.System();
+ var mindSys = server.System();
+ var roleSys = server.System();
+ var invSys = server.System();
+ var factionSys = server.System();
+
+ Assert.That(server.CfgMan.GetCVar(CCVars.GridFill), Is.False);
+ server.CfgMan.SetCVar(CCVars.GridFill, true);
+
+ // 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));
+
+ // There are no grids or maps
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+
+ // And no nukie related components
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+
+ // Ready up and start nukeops
+ await pair.WaitClientCommand("toggleready True");
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.ReadyToPlay));
+ await pair.WaitCommand("forcepreset Nukeops");
+ await pair.RunTicksSync(10);
+
+ // Game should have started
+ Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.InRound));
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.JoinedGame));
+ Assert.That(client.EntMan.EntityExists(client.AttachedEntity));
+ var player = pair.Player!.AttachedEntity!.Value;
+ Assert.That(entMan.EntityExists(player));
+
+ // Maps now exist
+ Assert.That(entMan.Count(), Is.GreaterThan(0));
+ Assert.That(entMan.Count(), Is.GreaterThan(0));
+ Assert.That(entMan.Count(), Is.EqualTo(2)); // The main station & nukie station
+ Assert.That(entMan.Count(), Is.GreaterThan(3)); // Each station has at least 1 grid, plus some shuttles
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+
+ // And we now have nukie related components
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+
+ // The player entity should be the nukie commander
+ var mind = mindSys.GetMind(player)!.Value;
+ Assert.That(entMan.HasComponent(player));
+ Assert.That(roleSys.MindIsAntagonist(mind));
+ Assert.That(roleSys.MindHasRole(mind));
+
+ Assert.That(factionSys.IsMember(player, "Syndicate"), Is.True);
+ Assert.That(factionSys.IsMember(player, "NanoTrasen"), Is.False);
+
+ var roles = roleSys.MindGetAllRoles(mind);
+ var cmdRoles = roles.Where(x => x.Prototype == "NukeopsCommander" && x.Component is NukeopsRoleComponent);
+ Assert.That(cmdRoles.Count(), Is.EqualTo(1));
+
+ // The game rule exists, and all the stations/shuttles/maps are properly initialized
+ var rule = entMan.AllComponents().Single().Component;
+ var mapRule = entMan.AllComponents().Single().Component;
+ foreach (var grid in mapRule.MapGrids)
+ {
+ Assert.That(entMan.EntityExists(grid));
+ Assert.That(entMan.HasComponent(grid));
+ Assert.That(entMan.HasComponent(grid));
+ }
+ Assert.That(entMan.EntityExists(rule.TargetStation));
+
+ Assert.That(entMan.HasComponent(rule.TargetStation));
+
+ var nukieShuttlEnt = entMan.AllComponents().FirstOrDefault().Uid;
+ Assert.That(entMan.EntityExists(nukieShuttlEnt));
+
+ EntityUid? nukieStationEnt = null;
+ foreach (var grid in mapRule.MapGrids)
+ {
+ if (entMan.HasComponent(grid))
+ {
+ nukieStationEnt = grid;
+ break;
+ }
+ }
+
+ Assert.That(entMan.EntityExists(nukieStationEnt));
+ var nukieStation = entMan.GetComponent(nukieStationEnt!.Value);
+
+ Assert.That(entMan.EntityExists(nukieStation.Station));
+ Assert.That(nukieStation.Station, Is.Not.EqualTo(rule.TargetStation));
+
+ Assert.That(server.MapMan.MapExists(mapRule.Map));
+ var nukieMap = mapSys.GetMap(mapRule.Map!.Value);
+
+ var targetStation = entMan.GetComponent(rule.TargetStation!.Value);
+ var targetGrid = targetStation.Grids.First();
+ var targetMap = entMan.GetComponent(targetGrid).MapUid!.Value;
+ Assert.That(targetMap, Is.Not.EqualTo(nukieMap));
+
+ Assert.That(entMan.GetComponent(player).MapUid, Is.EqualTo(nukieMap));
+ Assert.That(entMan.GetComponent(nukieStationEnt.Value).MapUid, Is.EqualTo(nukieMap));
+ Assert.That(entMan.GetComponent(nukieShuttlEnt).MapUid, Is.EqualTo(nukieMap));
+
+ // The maps are all map-initialized, including the player
+ // Yes, this is necessary as this has repeatedly been broken somehow.
+ Assert.That(mapSys.IsInitialized(nukieMap));
+ Assert.That(mapSys.IsInitialized(targetMap));
+ Assert.That(mapSys.IsPaused(nukieMap), Is.False);
+ Assert.That(mapSys.IsPaused(targetMap), Is.False);
+
+ EntityLifeStage LifeStage(EntityUid? uid) => entMan.GetComponent(uid!.Value).EntityLifeStage;
+ Assert.That(LifeStage(player), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieMap), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(targetMap), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieStationEnt.Value), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieShuttlEnt), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(rule.TargetStation), Is.GreaterThan(EntityLifeStage.Initialized));
+
+ // Make sure the player has hands. We've had fucking disarmed nukies before.
+ Assert.That(entMan.HasComponent(player));
+ Assert.That(entMan.GetComponent(player).Hands.Count, Is.GreaterThan(0));
+
+ // While we're at it, lets make sure they aren't naked. I don't know how many inventory slots all mobs will be
+ // likely to have in the future. But nukies should probably have at least 3 slots with something in them.
+ var enumerator = invSys.GetSlotEnumerator(player);
+ int total = 0;
+ while (enumerator.NextItem(out _))
+ {
+ total++;
+ }
+ Assert.That(total, Is.GreaterThan(3));
+
+ // Finally lets check the nukie commander passed basic training and figured out how to breathe.
+ var totalSeconds = 30;
+ var totalTicks = (int) Math.Ceiling(totalSeconds / server.Timing.TickPeriod.TotalSeconds);
+ int increment = 5;
+ var resp = entMan.GetComponent(player);
+ var damage = entMan.GetComponent(player);
+ for (var tick = 0; tick < totalTicks; tick += increment)
+ {
+ await pair.RunTicksSync(increment);
+ Assert.That(resp.SuffocationCycles, Is.LessThanOrEqualTo(resp.SuffocationCycleThreshold));
+ Assert.That(damage.TotalDamage, Is.EqualTo(FixedPoint2.Zero));
+ }
+
+ //ticker.SetGamePreset((GamePresetPrototype?)null); WD edit
+ server.CfgMan.SetCVar(CCVars.GridFill, false);
+ await pair.CleanReturnAsync();
+ }
+}
+
+*/
diff --git a/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs b/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
index 1e3f9c9854f..ffaff3b8ded 100644
--- a/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
@@ -1,5 +1,6 @@
using Content.Server.GameTicking;
using Content.Server.GameTicking.Commands;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
using Content.Server.GameTicking.Rules.Components;
using Content.Shared.CCVar;
diff --git a/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs b/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
index 0f665a63de0..5d7ae8efbf4 100644
--- a/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
@@ -17,6 +17,7 @@ public async Task TestSecretStarts()
var server = pair.Server;
await server.WaitIdleAsync();
+ var entMan = server.ResolveDependency();
var gameTicker = server.ResolveDependency().GetEntitySystem();
await server.WaitAssertion(() =>
@@ -32,10 +33,7 @@ await server.WaitAssertion(() =>
await server.WaitAssertion(() =>
{
- foreach (var rule in gameTicker.GetAddedGameRules())
- {
- Assert.That(gameTicker.GetActiveGameRules(), Does.Contain(rule));
- }
+ Assert.That(gameTicker.GetAddedGameRules().Count(), Is.GreaterThan(1), $"No additional rules started by secret rule.");
// End all rules
gameTicker.ClearGameRules();
diff --git a/Content.Server/Administration/ServerApi.cs b/Content.Server/Administration/ServerApi.cs
index 6f10ef9b479..04fd38598fb 100644
--- a/Content.Server/Administration/ServerApi.cs
+++ b/Content.Server/Administration/ServerApi.cs
@@ -8,6 +8,7 @@
using System.Threading.Tasks;
using Content.Server.Administration.Systems;
using Content.Server.GameTicking;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Presets;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Maps;
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
index 9849d2df79c..df77a3a1a78 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
@@ -1,23 +1,37 @@
-using Content.Server.GameTicking.Rules;
+using Content.Server.Administration.Commands;
+using Content.Server.Antag;
+using Content.Server.GameTicking.Rules.Components;
using Content.Server.Zombies;
using Content.Shared.Administration;
using Content.Shared.Database;
-using Content.Shared.Humanoid;
using Content.Shared.Mind.Components;
+using Content.Shared.Roles;
using Content.Shared.Verbs;
using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Systems;
public sealed partial class AdminVerbSystem
{
+ [Dependency] private readonly AntagSelectionSystem _antag = default!;
[Dependency] private readonly ZombieSystem _zombie = default!;
- [Dependency] private readonly ThiefRuleSystem _thief = default!;
- [Dependency] private readonly TraitorRuleSystem _traitorRule = default!;
- [Dependency] private readonly NukeopsRuleSystem _nukeopsRule = default!;
- [Dependency] private readonly PiratesRuleSystem _piratesRule = default!;
- [Dependency] private readonly RevolutionaryRuleSystem _revolutionaryRule = default!;
+
+ [ValidatePrototypeId]
+ private const string DefaultTraitorRule = "Traitor";
+
+ [ValidatePrototypeId]
+ private const string DefaultNukeOpRule = "LoneOpsSpawn";
+
+ [ValidatePrototypeId]
+ private const string DefaultRevsRule = "Revolutionary";
+
+ [ValidatePrototypeId]
+ private const string DefaultThiefRule = "Thief";
+
+ [ValidatePrototypeId]
+ private const string PirateGearId = "PirateGear";
// All antag verbs have names so invokeverb works.
private void AddAntagVerbs(GetVerbsEvent args)
@@ -40,9 +54,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"),
Act = () =>
{
- // if its a monkey or mouse or something dont give uplink or objectives
- var isHuman = HasComp(args.Target);
- _traitorRule.MakeTraitorAdmin(args.Target, giveUplink: isHuman, giveObjectives: isHuman);
+ _antag.ForceMakeAntag(player, DefaultTraitorRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-traitor"),
@@ -71,7 +83,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"),
Act = () =>
{
- _nukeopsRule.MakeLoneNukie(args.Target);
+ _antag.ForceMakeAntag(player, DefaultNukeOpRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-nuclear-operative"),
@@ -85,14 +97,14 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hats/pirate.rsi"), "icon"),
Act = () =>
{
- _piratesRule.MakePirate(args.Target);
+ // pirates just get an outfit because they don't really have logic associated with them
+ SetOutfitCommand.SetOutfit(args.Target, PirateGearId, EntityManager);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-pirate"),
};
args.Verbs.Add(pirate);
- //todo come here at some point dear lort.
Verb headRev = new()
{
Text = Loc.GetString("admin-verb-text-make-head-rev"),
@@ -100,7 +112,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "HeadRevolutionary"),
Act = () =>
{
- _revolutionaryRule.OnHeadRevAdmin(args.Target);
+ _antag.ForceMakeAntag(player, DefaultRevsRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-head-rev"),
@@ -114,7 +126,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/ihscombat.rsi"), "icon"),
Act = () =>
{
- _thief.AdminMakeThief(args.Target, false); //Midround add pacified is bad
+ _antag.ForceMakeAntag(player, DefaultThiefRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-thief"),
diff --git a/Content.Server/Antag/AntagSelectionPlayerPool.cs b/Content.Server/Antag/AntagSelectionPlayerPool.cs
new file mode 100644
index 00000000000..87873e96d1a
--- /dev/null
+++ b/Content.Server/Antag/AntagSelectionPlayerPool.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.Server.Antag;
+
+public sealed class AntagSelectionPlayerPool (List> orderedPools)
+{
+ public bool TryPickAndTake(IRobustRandom random, [NotNullWhen(true)] out ICommonSession? session)
+ {
+ session = null;
+
+ foreach (var pool in orderedPools)
+ {
+ if (pool.Count == 0)
+ continue;
+
+ session = random.PickAndTake(pool);
+ break;
+ }
+
+ return session != null;
+ }
+
+ public int Count => orderedPools.Sum(p => p.Count);
+}
diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs
new file mode 100644
index 00000000000..470f98fca1d
--- /dev/null
+++ b/Content.Server/Antag/AntagSelectionSystem.API.cs
@@ -0,0 +1,303 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Server.Antag.Components;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Objectives;
+using Content.Shared.Chat;
+using Content.Shared.Mind;
+using JetBrains.Annotations;
+using Robust.Shared.Audio;
+using Robust.Shared.Player;
+
+namespace Content.Server.Antag;
+
+public sealed partial class AntagSelectionSystem
+{
+ ///
+ /// Tries to get the next non-filled definition based on the current amount of selected minds and other factors.
+ ///
+ public bool TryGetNextAvailableDefinition(Entity ent,
+ [NotNullWhen(true)] out AntagSelectionDefinition? definition)
+ {
+ definition = null;
+
+ var totalTargetCount = GetTargetAntagCount(ent);
+ var mindCount = ent.Comp.SelectedMinds.Count;
+ if (mindCount >= totalTargetCount)
+ return false;
+
+ foreach (var def in ent.Comp.Definitions)
+ {
+ var target = GetTargetAntagCount(ent, null, def);
+
+ if (mindCount < target)
+ {
+ definition = def;
+ return true;
+ }
+
+ mindCount -= target;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the number of antagonists that should be present for a given rule based on the provided pool.
+ /// A null pool will simply use the player count.
+ ///
+ public int GetTargetAntagCount(Entity ent, AntagSelectionPlayerPool? pool = null)
+ {
+ var count = 0;
+ foreach (var def in ent.Comp.Definitions)
+ {
+ count += GetTargetAntagCount(ent, pool, def);
+ }
+
+ return count;
+ }
+
+ ///
+ /// Gets the number of antagonists that should be present for a given antag definition based on the provided pool.
+ /// A null pool will simply use the player count.
+ ///
+ public int GetTargetAntagCount(Entity ent, AntagSelectionPlayerPool? pool, AntagSelectionDefinition def)
+ {
+ var poolSize = pool?.Count ?? _playerManager.Sessions.Length;
+ // factor in other definitions' affect on the count.
+ var countOffset = 0;
+ foreach (var otherDef in ent.Comp.Definitions)
+ {
+ countOffset += Math.Clamp(poolSize / otherDef.PlayerRatio, otherDef.Min, otherDef.Max) * otherDef.PlayerRatio;
+ }
+ // make sure we don't double-count the current selection
+ countOffset -= Math.Clamp((poolSize + countOffset) / def.PlayerRatio, def.Min, def.Max) * def.PlayerRatio;
+
+ return Math.Clamp((poolSize - countOffset) / def.PlayerRatio, def.Min, def.Max);
+ }
+
+ ///
+ /// Returns identifiable information for all antagonists to be used in a round end summary.
+ ///
+ ///
+ /// A list containing, in order, the antag's mind, the session data, and the original name stored as a string.
+ ///
+ public List<(EntityUid, SessionData, string)> GetAntagIdentifiers(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new List<(EntityUid, SessionData, string)>();
+
+ var output = new List<(EntityUid, SessionData, string)>();
+ foreach (var (mind, name) in ent.Comp.SelectedMinds)
+ {
+ if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
+ continue;
+
+ if (!_playerManager.TryGetPlayerData(mindComp.OriginalOwnerUserId.Value, out var data))
+ continue;
+
+ output.Add((mind, data, name));
+ }
+ return output;
+ }
+
+ ///
+ /// Returns all the minds of antagonists.
+ ///
+ public List> GetAntagMinds(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new();
+
+ var output = new List>();
+ foreach (var (mind, _) in ent.Comp.SelectedMinds)
+ {
+ if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
+ continue;
+
+ output.Add((mind, mindComp));
+ }
+ return output;
+ }
+
+ ///
+ /// Helper specifically for
+ ///
+ public List GetAntagMindEntityUids(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new();
+
+ return ent.Comp.SelectedMinds.Select(p => p.Item1).ToList();
+ }
+
+ ///
+ /// Returns all the antagonists for this rule who are currently alive
+ ///
+ public IEnumerable GetAliveAntags(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ yield break;
+
+ var minds = GetAntagMinds(ent);
+ foreach (var mind in minds)
+ {
+ if (_mind.IsCharacterDeadIc(mind))
+ continue;
+
+ if (mind.Comp.OriginalOwnedEntity != null)
+ yield return GetEntity(mind.Comp.OriginalOwnedEntity.Value);
+ }
+ }
+
+ ///
+ /// Returns the number of alive antagonists for this rule.
+ ///
+ public int GetAliveAntagCount(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return 0;
+
+ var numbah = 0;
+ var minds = GetAntagMinds(ent);
+ foreach (var mind in minds)
+ {
+ if (_mind.IsCharacterDeadIc(mind))
+ continue;
+
+ numbah++;
+ }
+
+ return numbah;
+ }
+
+ ///
+ /// Returns if there are any remaining antagonists alive for this rule.
+ ///
+ public bool AnyAliveAntags(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return false;
+
+ return GetAliveAntags(ent).Any();
+ }
+
+ ///
+ /// Checks if all the antagonists for this rule are alive.
+ ///
+ public bool AllAntagsAlive(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return false;
+
+ return GetAliveAntagCount(ent) == ent.Comp.SelectedMinds.Count;
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a player entity
+ ///
+ /// The entity chosen to be antag
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ public void SendBriefing(EntityUid entity, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ {
+ if (!_mind.TryGetMind(entity, out _, out var mindComponent))
+ return;
+
+ if (mindComponent.Session == null)
+ return;
+
+ SendBriefing(mindComponent.Session, briefing, briefingColor, briefingSound);
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a list of sessions
+ ///
+ /// The sessions that will be sent the briefing
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ [PublicAPI]
+ public void SendBriefing(List sessions, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ {
+ foreach (var session in sessions)
+ {
+ SendBriefing(session, briefing, briefingColor, briefingSound);
+ }
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a session
+ ///
+ /// The player chosen to be an antag
+ /// The briefing data
+ public void SendBriefing(
+ ICommonSession? session,
+ BriefingData? data)
+ {
+ if (session == null || data == null)
+ return;
+
+ var text = data.Value.Text == null ? string.Empty : Loc.GetString(data.Value.Text);
+ SendBriefing(session, text, data.Value.Color, data.Value.Sound);
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a session
+ ///
+ /// The player chosen to be an antag
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ public void SendBriefing(
+ ICommonSession? session,
+ string briefing,
+ Color? briefingColor,
+ SoundSpecifier? briefingSound)
+ {
+ if (session == null)
+ return;
+
+ _audio.PlayGlobal(briefingSound, session);
+ if (!string.IsNullOrEmpty(briefing))
+ {
+ var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", briefing));
+ _chat.ChatMessageToOne(ChatChannel.Server, briefing, wrappedMessage, default, false, session.Channel,
+ briefingColor);
+ }
+ }
+
+ ///
+ /// 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
+ {
+ var rule = ForceGetGameRuleEnt(defaultRule);
+
+ if (!TryGetNextAvailableDefinition(rule, out var def))
+ def = rule.Comp.Definitions.Last();
+
+ MakeAntag(rule, player, def.Value);
+ }
+
+ ///
+ /// Tries to grab one of the weird specific antag gamerule ents or starts a new one.
+ /// This is gross code but also most of this is pretty gross to begin with.
+ ///
+ public Entity ForceGetGameRuleEnt(string id) where T : Component
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out _, out var comp))
+ {
+ return (uid, comp);
+ }
+ var ruleEnt = GameTicker.AddGameRule(id);
+ RemComp(ruleEnt);
+ var antag = Comp(ruleEnt);
+ antag.SelectionsComplete = true; // don't do normal selection.
+ GameTicker.StartGameRule(ruleEnt);
+ return (ruleEnt, antag);
+ }
+}
diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs
index b11c562df5a..6bfb7394f5b 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -1,347 +1,461 @@
+using System.Linq;
+using Content.Server.Antag.Components;
+using Content.Server.Chat.Managers;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
-using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Ghost.Roles;
+using Content.Server.Ghost.Roles.Components;
using Content.Server.Mind;
using Content.Server.Preferences.Managers;
+using Content.Server.Roles;
using Content.Server.Roles.Jobs;
using Content.Server.Shuttles.Components;
+using Content.Server.Station.Systems;
using Content.Shared.Antag;
+using Content.Shared.Ghost;
using Content.Shared.Humanoid;
using Content.Shared.Players;
using Content.Shared.Preferences;
-using Content.Shared.Roles;
using Robust.Server.Audio;
-using Robust.Shared.Audio;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
using Robust.Shared.Player;
-using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using System.Linq;
-using Content.Shared.Chat;
-using Robust.Shared.Enums;
namespace Content.Server.Antag;
-public sealed class AntagSelectionSystem : GameRuleSystem
+public sealed partial class AntagSelectionSystem : GameRuleSystem
{
- [Dependency] private readonly IServerPreferencesManager _prefs = default!;
- [Dependency] private readonly AudioSystem _audioSystem = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IServerPreferencesManager _pref = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly GhostRoleSystem _ghostRole = default!;
[Dependency] private readonly JobSystem _jobs = default!;
- [Dependency] private readonly MindSystem _mindSystem = default!;
- [Dependency] private readonly SharedRoleSystem _roleSystem = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly RoleSystem _role = default!;
+ [Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
- #region Eligible Player Selection
- ///
- /// Get all players that are eligible for an antag role
- ///
- /// All sessions from which to select eligible players
- /// The prototype to get eligible players for
- /// Should jobs that prohibit antag roles (ie Heads, Sec, Interns) be included
- /// Should players already selected as antags be eligible
- /// Should we ignore if the player has enabled this specific role
- /// A custom condition that each player is tested against, if it returns true the player is excluded from eligibility
- /// List of all player entities that match the requirements
- public List GetEligiblePlayers(IEnumerable playerSessions,
- ProtoId antagPrototype,
- bool includeAllJobs = false,
- AntagAcceptability acceptableAntags = AntagAcceptability.NotExclusive,
- bool ignorePreferences = false,
- bool allowNonHumanoids = false,
- Func? customExcludeCondition = null)
+ // arbitrary random number to give late joining some mild interest.
+ public const float LateJoinRandomChance = 0.5f;
+
+ ///
+ public override void Initialize()
{
- var eligiblePlayers = new List();
+ base.Initialize();
- foreach (var player in playerSessions)
- {
- if (IsPlayerEligible(player, antagPrototype, includeAllJobs, acceptableAntags, ignorePreferences, allowNonHumanoids, customExcludeCondition))
- eligiblePlayers.Add(player.AttachedEntity!.Value);
- }
+ SubscribeLocalEvent(OnTakeGhostRole);
- return eligiblePlayers;
+ SubscribeLocalEvent(OnPlayerSpawning);
+ SubscribeLocalEvent(OnJobsAssigned);
+ SubscribeLocalEvent(OnSpawnComplete);
}
- ///
- /// Get all sessions that are eligible for an antag role, can be run prior to sessions being attached to an entity
- /// This does not exclude sessions that have already been chosen as antags - that must be handled manually
- ///
- /// All sessions from which to select eligible players
- /// The prototype to get eligible players for
- /// Should we ignore if the player has enabled this specific role
- /// List of all player sessions that match the requirements
- public List GetEligibleSessions(IEnumerable playerSessions, ProtoId antagPrototype, bool ignorePreferences = false)
+ private void OnTakeGhostRole(Entity ent, ref TakeGhostRoleEvent args)
{
- var eligibleSessions = new List();
+ if (args.TookRole)
+ return;
- foreach (var session in playerSessions)
+ if (ent.Comp.Rule is not { } rule || ent.Comp.Definition is not { } def)
+ return;
+
+ if (!Exists(rule) || !TryComp(rule, out var select))
+ return;
+
+ MakeAntag((rule, select), args.Player, def, ignoreSpawner: true);
+ args.TookRole = true;
+ _ghostRole.UnregisterGhostRole((ent, Comp(ent)));
+ }
+
+ private void OnPlayerSpawning(RulePlayerSpawningEvent args)
+ {
+ var pool = args.PlayerPool;
+
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var comp, out _))
{
- if (IsSessionEligible(session, antagPrototype, ignorePreferences))
- eligibleSessions.Add(session);
+ if (comp.SelectionTime != AntagSelectionTime.PrePlayerSpawn)
+ continue;
+
+ if (comp.SelectionsComplete)
+ return;
+
+ ChooseAntags((uid, comp), pool);
+ comp.SelectionsComplete = true;
+
+ foreach (var session in comp.SelectedSessions)
+ {
+ args.PlayerPool.Remove(session);
+ GameTicker.PlayerJoinGame(session);
+ }
}
+ }
+
+ private void OnJobsAssigned(RulePlayerJobsAssignedEvent args)
+ {
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var comp, out _))
+ {
+ if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn)
+ continue;
+
+ if (comp.SelectionsComplete)
+ continue;
- return eligibleSessions;
+ ChooseAntags((uid, comp));
+ comp.SelectionsComplete = true;
+ }
}
- ///
- /// Test eligibility of the player for a specific antag role
- ///
- /// The player session to test
- /// The prototype to get eligible players for
- /// Should jobs that prohibit antag roles (ie Heads, Sec, Interns) be included
- /// Should players already selected as antags be eligible
- /// Should we ignore if the player has enabled this specific role
- /// A function, accepting an EntityUid and returning bool. Each player is tested against this, returning truw will exclude the player from eligibility
- /// True if the player session matches the requirements, false otherwise
- public bool IsPlayerEligible(ICommonSession session,
- ProtoId antagPrototype,
- bool includeAllJobs = false,
- AntagAcceptability acceptableAntags = AntagAcceptability.NotExclusive,
- bool ignorePreferences = false,
- bool allowNonHumanoids = false,
- Func? customExcludeCondition = null)
+ private void OnSpawnComplete(PlayerSpawnCompleteEvent args)
{
- if (!IsSessionEligible(session, antagPrototype, ignorePreferences))
- return false;
+ if (!args.LateJoin)
+ return;
- //Ensure the player has a mind
- if (session.GetMind() is not { } playerMind)
- return false;
+ // 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.
+ // something to figure out later.
- //Ensure the player has an attached entity
- if (session.AttachedEntity is not { } playerEntity)
- return false;
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var antag, out _))
+ {
+ if (!RobustRandom.Prob(LateJoinRandomChance))
+ continue;
- //Ignore latejoined players, ie those on the arrivals station
- if (HasComp(playerEntity))
- return false;
+ if (!antag.Definitions.Any(p => p.LateJoinAdditional))
+ continue;
- //Exclude jobs that cannot be antag, unless explicitly allowed
- if (!includeAllJobs && !_jobs.CanBeAntag(session))
- return false;
+ if (!TryGetNextAvailableDefinition((uid, antag), out var def))
+ continue;
- //Check if the entity is already an antag
- switch (acceptableAntags)
+ if (TryMakeAntag((uid, antag), args.Player, def.Value))
+ break;
+ }
+ }
+
+ protected override void Added(EntityUid uid, AntagSelectionComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args)
+ {
+ base.Added(uid, component, gameRule, args);
+
+ for (var i = 0; i < component.Definitions.Count; i++)
{
- //If we dont want to select any antag roles
- case AntagAcceptability.None:
- {
- if (_roleSystem.MindIsAntagonist(playerMind))
- return false;
- break;
- }
- //If we dont want to select exclusive antag roles
- case AntagAcceptability.NotExclusive:
- {
- if (_roleSystem.MindIsExclusiveAntagonist(playerMind))
- return false;
- break;
- }
+ var def = component.Definitions[i];
+
+ if (def.MinRange != null)
+ {
+ def.Min = def.MinRange.Value.Next(RobustRandom);
+ }
+
+ if (def.MaxRange != null)
+ {
+ def.Max = def.MaxRange.Value.Next(RobustRandom);
+ }
}
+ }
- //Unless explictly allowed, ignore non humanoids (eg pets)
- if (!allowNonHumanoids && !HasComp(playerEntity))
- return false;
+ protected override void Started(EntityUid uid, AntagSelectionComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
+ {
+ base.Started(uid, component, gameRule, args);
- //If a custom condition was provided, test it and exclude the player if it returns true
- if (customExcludeCondition != null && customExcludeCondition(playerEntity))
- return false;
+ if (component.SelectionsComplete)
+ return;
+ if (GameTicker.RunLevel != GameRunLevel.InRound)
+ return;
- return true;
+ if (GameTicker.RunLevel == GameRunLevel.InRound && component.SelectionTime == AntagSelectionTime.PrePlayerSpawn)
+ return;
+
+ ChooseAntags((uid, component));
+ component.SelectionsComplete = true;
}
///
- /// Check if the session is eligible for a role, can be run prior to the session being attached to an entity
+ /// Chooses antagonists from the current selection of players
///
- /// Player session to check
- /// Which antag prototype to check for
- /// Ignore if the player has enabled this antag
- /// True if the session matches the requirements, false otherwise
- public bool IsSessionEligible(ICommonSession session, ProtoId antagPrototype, bool ignorePreferences = false)
+ public void ChooseAntags(Entity ent)
{
- //Exclude disconnected or zombie sessions
- //No point giving antag roles to them
- if (session.Status == SessionStatus.Disconnected ||
- session.Status == SessionStatus.Zombie)
- return false;
-
- //Check the player has this antag preference selected
- //Unless we are ignoring preferences, in which case add them anyway
- var pref = (HumanoidCharacterProfile) _prefs.GetPreferences(session.UserId).SelectedCharacter;
- if (!pref.AntagPreferences.Contains(antagPrototype.Id) && !ignorePreferences)
- return false;
-
- return true;
+ var sessions = _playerManager.Sessions.ToList();
+ ChooseAntags(ent, sessions);
}
- #endregion
///
- /// Helper method to calculate the number of antags to select based upon the number of players
+ /// Chooses antagonists from the given selection of players
///
- /// How many players there are on the server
- /// How many players should there be for an additional antag
- /// Maximum number of antags allowed
- /// The number of antags that should be chosen
- public int CalculateAntagCount(int playerCount, int playersPerAntag, int maxAntags)
+ public void ChooseAntags(Entity ent, List pool)
{
- return Math.Clamp(playerCount / playersPerAntag, 1, maxAntags);
+ foreach (var def in ent.Comp.Definitions)
+ {
+ ChooseAntags(ent, pool, def);
+ }
}
- #region Antag Selection
///
- /// Selects a set number of entities from several lists, prioritising the first list till its empty, then second list etc
+ /// Chooses antagonists from the given selection of players for the given antag definition.
///
- /// Array of lists, which are chosen from in order until the correct number of items are selected
- /// How many items to select
- /// Up to the specified count of elements from all provided lists
- public List ChooseAntags(int count, params List[] eligiblePlayerLists)
+ public void ChooseAntags(Entity ent, List pool, AntagSelectionDefinition def)
{
- var chosenPlayers = new List();
- foreach (var playerList in eligiblePlayerLists)
+ var playerPool = GetPlayerPool(ent, pool, def);
+ var count = GetTargetAntagCount(ent, playerPool, def);
+
+ for (var i = 0; i < count; i++)
{
- //Remove all chosen players from this list, to prevent duplicates
- foreach (var chosenPlayer in chosenPlayers)
+ var session = (ICommonSession?) null;
+ if (def.PickPlayer)
{
- playerList.Remove(chosenPlayer);
- }
+ if (!playerPool.TryPickAndTake(RobustRandom, out session))
+ break;
- //If we have reached the desired number of players, skip
- if (chosenPlayers.Count >= count)
- continue;
+ if (ent.Comp.SelectedSessions.Contains(session))
+ continue;
+ }
- //Pick and choose a random number of players from this list
- chosenPlayers.AddRange(ChooseAntags(count - chosenPlayers.Count, playerList));
+ MakeAntag(ent, session, def);
}
- return chosenPlayers;
}
+
///
- /// Helper method to choose antags from a list
+ /// Tries to makes a given player into the specified antagonist.
///
- /// List of eligible players
- /// How many to choose
- /// Up to the specified count of elements from the provided list
- public List ChooseAntags(int count, List eligiblePlayers)
+ public bool TryMakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
{
- var chosenPlayers = new List();
-
- for (var i = 0; i < count; i++)
+ if (!IsSessionValid(ent, session, def) ||
+ !IsEntityValid(session?.AttachedEntity, def))
{
- if (eligiblePlayers.Count == 0)
- break;
-
- chosenPlayers.Add(RobustRandom.PickAndTake(eligiblePlayers));
+ return false;
}
- return chosenPlayers;
+ MakeAntag(ent, session, def, ignoreSpawner);
+ return true;
}
///
- /// Selects a set number of sessions from several lists, prioritising the first list till its empty, then second list etc
+ /// Makes a given player into the specified antagonist.
///
- /// Array of lists, which are chosen from in order until the correct number of items are selected
- /// How many items to select
- /// Up to the specified count of elements from all provided lists
- public List ChooseAntags(int count, params List[] eligiblePlayerLists)
+ public void MakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
{
- var chosenPlayers = new List();
- foreach (var playerList in eligiblePlayerLists)
+ var antagEnt = (EntityUid?) null;
+ var isSpawner = false;
+
+ if (session != null)
+ {
+ ent.Comp.SelectedSessions.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;
+ }
+
+ if (!antagEnt.HasValue)
{
- //Remove all chosen players from this list, to prevent duplicates
- foreach (var chosenPlayer in chosenPlayers)
+ var getEntEv = new AntagSelectEntityEvent(session, ent);
+ RaiseLocalEvent(ent, ref getEntEv, true);
+
+ if (!getEntEv.Handled)
{
- playerList.Remove(chosenPlayer);
+ throw new InvalidOperationException($"Attempted to make {session} antagonist in gamerule {ToPrettyString(ent)} but there was no valid entity for player.");
}
- //If we have reached the desired number of players, skip
- if (chosenPlayers.Count >= count)
- continue;
+ antagEnt = getEntEv.Entity;
+ }
+
+ if (antagEnt is not { } player)
+ return;
- //Pick and choose a random number of players from this list
- chosenPlayers.AddRange(ChooseAntags(count - chosenPlayers.Count, playerList));
+ var getPosEv = new AntagSelectLocationEvent(session, ent);
+ RaiseLocalEvent(ent, ref getPosEv, true);
+ if (getPosEv.Handled)
+ {
+ var playerXform = Transform(player);
+ var pos = RobustRandom.Pick(getPosEv.Coordinates);
+ _transform.SetMapCoordinates((player, playerXform), pos);
}
- return chosenPlayers;
- }
- ///
- /// Helper method to choose sessions from a list
- ///
- /// List of eligible sessions
- /// How many to choose
- /// Up to the specified count of elements from the provided list
- public List ChooseAntags(int count, List eligiblePlayers)
- {
- var chosenPlayers = new List();
- for (int i = 0; i < count; i++)
+ if (isSpawner)
{
- if (eligiblePlayers.Count == 0)
- break;
+ if (!TryComp(player, out var spawnerComp))
+ {
+ Log.Error("Antag spawner with GhostRoleAntagSpawnerComponent.");
+ return;
+ }
+
+ spawnerComp.Rule = ent;
+ spawnerComp.Definition = def;
+ return;
+ }
- chosenPlayers.Add(RobustRandom.PickAndTake(eligiblePlayers));
+ EntityManager.AddComponents(player, def.Components);
+ _stationSpawning.EquipStartingGear(player, def.StartingGear);
+
+ if (session != null)
+ {
+ var curMind = session.GetMind();
+ if (curMind == null)
+ {
+ curMind = _mind.CreateMind(session.UserId, Name(antagEnt.Value));
+ _mind.SetUserId(curMind.Value, session.UserId);
+ }
+
+ _mind.TransferTo(curMind.Value, antagEnt, ghostCheckOverride: true);
+ _role.MindAddRoles(curMind.Value, def.MindComponents);
+ ent.Comp.SelectedMinds.Add((curMind.Value, Name(player)));
+ }
+
+ if (def.Briefing is { } briefing)
+ {
+ SendBriefing(session, briefing);
}
- return chosenPlayers;
+ var afterEv = new AfterAntagEntitySelectedEvent(session, player, ent, def);
+ RaiseLocalEvent(ent, ref afterEv, true);
}
- #endregion
- #region Briefings
///
- /// Helper method to send the briefing text and sound to a list of entities
+ /// Gets an ordered player pool based on player preferences and the antagonist definition.
///
- /// The players chosen to be antags
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
- public void SendBriefing(List entities, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ public AntagSelectionPlayerPool GetPlayerPool(Entity ent, List sessions, AntagSelectionDefinition def)
{
- foreach (var entity in entities)
+ var preferredList = new List();
+ var secondBestList = new List();
+ var unwantedList = new List();
+ var invalidList = new List();
+ foreach (var session in sessions)
{
- SendBriefing(entity, briefing, briefingColor, briefingSound);
+ 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)))
+ {
+ preferredList.Add(session);
+ }
+ else if (def.FallbackRoles.Count != 0 && pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p)))
+ {
+ secondBestList.Add(session);
+ }
+ else
+ {
+ unwantedList.Add(session);
+ }
}
+
+ return new AntagSelectionPlayerPool(new() { preferredList, secondBestList, unwantedList, invalidList });
}
///
- /// Helper method to send the briefing text and sound to a player entity
+ /// Checks if a given session is valid for an antagonist.
///
- /// The entity chosen to be antag
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
- public void SendBriefing(EntityUid entity, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ public bool IsSessionValid(Entity ent, ICommonSession? session, AntagSelectionDefinition def, EntityUid? mind = null)
{
- if (!_mindSystem.TryGetMind(entity, out _, out var mindComponent))
- return;
+ if (session == null)
+ return true;
- if (mindComponent.Session == null)
- return;
+ mind ??= session.GetMind();
- SendBriefing(mindComponent.Session, briefing, briefingColor, briefingSound);
- }
+ if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie)
+ return false;
- ///
- /// Helper method to send the briefing text and sound to a list of sessions
- ///
- ///
- ///
- ///
- ///
+ if (ent.Comp.SelectedSessions.Contains(session))
+ return false;
- public void SendBriefing(List sessions, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
- {
- foreach (var session in sessions)
+ //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)
{
- SendBriefing(session, briefing, briefingColor, briefingSound);
+ case AntagAcceptability.None:
+ {
+ if (_role.MindIsAntagonist(mind))
+ return false;
+ break;
+ }
+ case AntagAcceptability.NotExclusive:
+ {
+ if (_role.MindIsExclusiveAntagonist(mind))
+ return false;
+ break;
+ }
}
+
+ // todo: expand this to allow for more fine antag-selection logic for game rules.
+ if (!_jobs.CanBeAntag(session))
+ return false;
+
+ return true;
}
+
///
- /// Helper method to send the briefing text and sound to a session
+ /// Checks if a given entity (mind/session not included) is valid for a given antagonist.
///
- /// The player chosen to be an antag
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
-
- public void SendBriefing(ICommonSession session, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ private bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def)
{
- _audioSystem.PlayGlobal(briefingSound, session);
- var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", briefing));
- ChatManager.ChatMessageToOne(ChatChannel.Server, briefing, wrappedMessage, default, false, session.Channel, briefingColor);
+ if (entity == null)
+ return false;
+
+ if (HasComp(entity))
+ return false;
+
+ if (!def.AllowNonHumans && !HasComp(entity))
+ return false;
+
+ if (def.Whitelist != null)
+ {
+ if (!def.Whitelist.IsValid(entity.Value, EntityManager))
+ return false;
+ }
+
+ if (def.Blacklist != null)
+ {
+ if (def.Blacklist.IsValid(entity.Value, EntityManager))
+ return false;
+ }
+
+ return true;
}
- #endregion
}
+
+///
+/// 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.
+///
+[ByRefEvent]
+public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity GameRule)
+{
+ public readonly ICommonSession? Session = Session;
+
+ public bool Handled => Entity != null;
+
+ public EntityUid? Entity;
+}
+
+///
+/// Event raised on a game rule entity to determine the location for the antagonist.
+///
+[ByRefEvent]
+public record struct AntagSelectLocationEvent(ICommonSession? Session, Entity GameRule)
+{
+ public readonly ICommonSession? Session = Session;
+
+ public bool Handled => Coordinates.Any();
+
+ public List Coordinates = new();
+}
+
+///
+/// Event raised on a game rule entity after the setup logic for an antag is complete.
+/// Used for applying additional more complex setup logic.
+///
+[ByRefEvent]
+public readonly record struct AfterAntagEntitySelectedEvent(ICommonSession? Session, EntityUid EntityUid, Entity GameRule, AntagSelectionDefinition Def);
diff --git a/Content.Server/Antag/Components/AntagSelectionComponent.cs b/Content.Server/Antag/Components/AntagSelectionComponent.cs
new file mode 100644
index 00000000000..096be14049a
--- /dev/null
+++ b/Content.Server/Antag/Components/AntagSelectionComponent.cs
@@ -0,0 +1,189 @@
+using Content.Server.Administration.Systems;
+using Content.Server.Destructible.Thresholds;
+using Content.Shared.Antag;
+using Content.Shared.Roles;
+using Content.Shared.Storage;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Antag.Components;
+
+[RegisterComponent, Access(typeof(AntagSelectionSystem), typeof(AdminVerbSystem))]
+public sealed partial class AntagSelectionComponent : Component
+{
+ ///
+ /// Has the primary selection of antagonists finished yet?
+ ///
+ [DataField]
+ public bool SelectionsComplete;
+
+ ///
+ /// The definitions for the antagonists
+ ///
+ [DataField]
+ public List Definitions = new();
+
+ ///
+ /// The minds and original names of the players selected to be antagonists.
+ ///
+ [DataField]
+ public List<(EntityUid, string)> SelectedMinds = new();
+
+ ///
+ /// When the antag selection will occur.
+ ///
+ [DataField]
+ public AntagSelectionTime SelectionTime = AntagSelectionTime.PostPlayerSpawn;
+
+ ///
+ /// Cached sessions of players who are chosen. Used so we don't have to rebuild the pool multiple times in a tick.
+ /// Is not serialized.
+ ///
+ public HashSet SelectedSessions = new();
+}
+
+[DataDefinition]
+public partial struct AntagSelectionDefinition()
+{
+ ///
+ /// A list of antagonist roles that are used for selecting which players will be antagonists.
+ ///
+ [DataField]
+ public List> PrefRoles = new();
+
+ ///
+ /// Fallback for . Useful if you need multiple role preferences for a team antagonist.
+ ///
+ [DataField]
+ public List> FallbackRoles = new();
+
+ ///
+ /// Should we allow people who already have an antagonist role?
+ ///
+ [DataField]
+ public AntagAcceptability MultiAntagSetting = AntagAcceptability.None;
+
+ ///
+ /// The minimum number of this antag.
+ ///
+ [DataField]
+ public int Min = 1;
+
+ ///
+ /// The maximum number of this antag.
+ ///
+ [DataField]
+ public int Max = 1;
+
+ ///
+ /// A range used to randomly select
+ ///
+ [DataField]
+ public MinMax? MinRange;
+
+ ///
+ /// A range used to randomly select
+ ///
+ [DataField]
+ public MinMax? MaxRange;
+
+ ///
+ /// a player to antag ratio: used to determine the amount of antags that will be present.
+ ///
+ [DataField]
+ public int PlayerRatio = 10;
+
+ ///
+ /// Whether or not players should be picked to inhabit this antag or not.
+ ///
+ [DataField]
+ public bool PickPlayer = true;
+
+ ///
+ /// If true, players that latejoin into a round have a chance of being converted into antagonists.
+ ///
+ [DataField]
+ public bool LateJoinAdditional = false;
+
+ //todo: find out how to do this with minimal boilerplate: filler department, maybe?
+ //public HashSet> JobBlacklist = new()
+
+ ///
+ /// Mostly just here for legacy compatibility and reducing boilerplate
+ ///
+ [DataField]
+ public bool AllowNonHumans = false;
+
+ ///
+ /// A whitelist for selecting which players can become this antag.
+ ///
+ [DataField]
+ public EntityWhitelist? Whitelist;
+
+ ///
+ /// A blacklist for selecting which players can become this antag.
+ ///
+ [DataField]
+ public EntityWhitelist? Blacklist;
+
+ ///
+ /// Components added to the player.
+ ///
+ [DataField]
+ public ComponentRegistry Components = new();
+
+ ///
+ /// Components added to the player's mind.
+ ///
+ [DataField]
+ public ComponentRegistry MindComponents = new();
+
+ ///
+ /// A set of starting gear that's equipped to the player.
+ ///
+ [DataField]
+ public ProtoId? StartingGear;
+
+ ///
+ /// A briefing shown to the player.
+ ///
+ [DataField]
+ public BriefingData? Briefing;
+
+ ///
+ /// A spawner used to defer the selection of this particular definition.
+ ///
+ ///
+ /// Not the cleanest way of doing this code but it's just an odd specific behavior.
+ /// Sue me.
+ ///
+ [DataField]
+ public EntProtoId? SpawnerPrototype;
+}
+
+///
+/// Contains data used to generate a briefing.
+///
+[DataDefinition]
+public partial struct BriefingData
+{
+ ///
+ /// The text shown
+ ///
+ [DataField]
+ public LocId? Text;
+
+ ///
+ /// The color of the text.
+ ///
+ [DataField]
+ public Color? Color;
+
+ ///
+ /// The sound played.
+ ///
+ [DataField]
+ public SoundSpecifier? Sound;
+}
diff --git a/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs b/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs
new file mode 100644
index 00000000000..fcaa4d42672
--- /dev/null
+++ b/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs
@@ -0,0 +1,14 @@
+namespace Content.Server.Antag.Components;
+
+///
+/// Ghost role spawner that creates an antag for the associated gamerule.
+///
+[RegisterComponent, Access(typeof(AntagSelectionSystem))]
+public sealed partial class GhostRoleAntagSpawnerComponent : Component
+{
+ [DataField]
+ public EntityUid? Rule;
+
+ [DataField]
+ public AntagSelectionDefinition? Definition;
+}
diff --git a/Content.Server/Antag/MobReplacementRuleSystem.cs b/Content.Server/Antag/MobReplacementRuleSystem.cs
index ba09c84bce4..18837b5a7c8 100644
--- a/Content.Server/Antag/MobReplacementRuleSystem.cs
+++ b/Content.Server/Antag/MobReplacementRuleSystem.cs
@@ -1,45 +1,16 @@
-using System.Numerics;
-using Content.Server.Advertise.Components;
-using Content.Server.Advertise.EntitySystems;
using Content.Server.Antag.Mimic;
-using Content.Server.Chat.Systems;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
using Content.Server.GameTicking.Rules.Components;
-using Content.Server.NPC.Systems;
-using Content.Server.Station.Systems;
-using Content.Server.GameTicking;
using Content.Shared.VendingMachines;
using Robust.Shared.Map;
-using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using Robust.Server.GameObjects;
-using Robust.Shared.Physics.Systems;
-using System.Linq;
-using Robust.Shared.Physics;
-using Content.Shared.Movement.Components;
-using Content.Shared.Damage;
-using Content.Server.NPC.HTN;
-using Content.Server.NPC;
-using Content.Shared.Weapons.Melee;
-using Content.Server.Power.Components;
-using Content.Shared.CombatMode;
namespace Content.Server.Antag;
public sealed class MobReplacementRuleSystem : GameRuleSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
- [Dependency] private readonly StationSystem _station = default!;
- [Dependency] private readonly GameTicker _gameTicker = default!;
- [Dependency] private readonly ChatSystem _chat = default!;
- [Dependency] private readonly IPrototypeManager _prototype = default!;
- [Dependency] private readonly IComponentFactory _componentFactory = default!;
- [Dependency] private readonly SharedPhysicsSystem _physics = default!;
- [Dependency] private readonly NpcFactionSystem _npcFaction = default!;
- [Dependency] private readonly NPCSystem _npc = default!;
- [Dependency] private readonly SharedTransformSystem _transform = default!;
- [Dependency] private readonly AdvertiseSystem _advertise = default!;
-
protected override void Started(EntityUid uid, MobReplacementRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
{
@@ -47,133 +18,21 @@ protected override void Started(EntityUid uid, MobReplacementRuleComponent compo
var query = AllEntityQuery();
var spawns = new List<(EntityUid Entity, EntityCoordinates Coordinates)>();
- var stations = _gameTicker.GetSpawnableStations();
while (query.MoveNext(out var vendingUid, out _, out var xform))
{
- var ownerStation = _station.GetOwningStation(vendingUid);
-
- if (ownerStation == null
- || ownerStation != stations[0])
- continue;
-
- // Make sure that we aren't running this on something that is already a mimic
- if (HasComp(vendingUid))
+ if (!_random.Prob(component.Chance))
continue;
spawns.Add((vendingUid, xform.Coordinates));
}
- if (spawns == null)
+ foreach (var entity in spawns)
{
- //WTF THE STATION DOESN'T EXIST! WE MUST BE IN A TEST! QUICK, PUT A MIMIC AT 0,0!!!
- Spawn(component.Proto, new EntityCoordinates(uid, new Vector2(0, 0)));
- }
- else
- {
- // This is intentionally not clamped. If a server host wants to replace every vending machine in the entire station with a mimic, who am I to stop them?
- var k = MathF.MaxMagnitude(component.NumberToReplace, 1);
- while (k > 0 && spawns != null && spawns.Count > 0)
- {
- if (k > 1)
- {
- var spawnLocation = _random.PickAndTake(spawns);
- BuildAMimicWorkshop(spawnLocation.Entity, component);
- }
- else
- {
- BuildAMimicWorkshop(spawns[0].Entity, component);
- }
-
- if (k == MathF.MaxMagnitude(component.NumberToReplace, 1)
- && component.DoAnnouncement)
- _chat.DispatchStationAnnouncement(stations[0], Loc.GetString("station-event-rampant-intelligence-announcement"), playDefaultSound: true,
- colorOverride: Color.Red, sender: "Central Command");
-
- k--;
- }
- }
- }
-
- ///
- /// It's like Build a Bear, but MURDER
- ///
- ///
- public void BuildAMimicWorkshop(EntityUid uid, MobReplacementRuleComponent component)
- {
- var metaData = MetaData(uid);
- var vendorPrototype = metaData.EntityPrototype;
- var mimicProto = _prototype.Index(component.Proto);
-
- var vendorComponents = vendorPrototype?.Components.Keys
- .Where(n => n != "Transform" && n != "MetaData")
- .Select(name => (name, _componentFactory.GetRegistration(name).Type))
- .ToList() ?? new List<(string name, Type type)>();
-
- var mimicComponents = mimicProto?.Components.Keys
- .Where(n => n != "Transform" && n != "MetaData")
- .Select(name => (name, _componentFactory.GetRegistration(name).Type))
- .ToList() ?? new List<(string name, Type type)>();
+ var coordinates = entity.Coordinates;
+ Del(entity.Entity);
- foreach (var name in mimicComponents.Except(vendorComponents))
- {
- var newComponent = _componentFactory.GetComponent(name.name);
- EntityManager.AddComponent(uid, newComponent);
+ Spawn(component.Proto, coordinates);
}
-
- var xform = Transform(uid);
- if (xform.Anchored)
- _transform.Unanchor(uid, xform);
-
- SetupMimicNPC(uid, component);
-
- if (TryComp(uid, out var vendor)
- && component.VendorModify)
- SetupMimicVendor(uid, component, vendor);
- }
- ///
- /// This handles getting the entity ready to be a hostile NPC
- ///
- ///
- ///
- private void SetupMimicNPC(EntityUid uid, MobReplacementRuleComponent component)
- {
- _physics.SetBodyType(uid, BodyType.KinematicController);
- _npcFaction.AddFaction(uid, "SimpleHostile");
-
- var melee = EnsureComp(uid);
- melee.Angle = 0;
- DamageSpecifier dspec = new()
- {
- DamageDict = new()
- {
- { "Blunt", component.MimicMeleeDamage }
- }
- };
- melee.Damage = dspec;
-
- var movementSpeed = EnsureComp(uid);
- (movementSpeed.BaseSprintSpeed, movementSpeed.BaseWalkSpeed) = (component.MimicMoveSpeed, component.MimicMoveSpeed);
-
- var htn = EnsureComp(uid);
- htn.RootTask = new HTNCompoundTask() { Task = component.MimicAIType };
- htn.Blackboard.SetValue(NPCBlackboard.NavSmash, component.MimicSmashGlass);
- _npc.WakeNPC(uid, htn);
- }
-
- ///
- /// Handling specific interactions with vending machines
- ///
- ///
- ///
- ///
- private void SetupMimicVendor(EntityUid uid, MobReplacementRuleComponent mimicComponent, AdvertiseComponent vendorComponent)
- {
- vendorComponent.MinimumWait = 5;
- vendorComponent.MaximumWait = 15;
- _advertise.SayAdvertisement(uid, vendorComponent);
-
- if (TryComp(uid, out var aPC))
- aPC.NeedsPower = false;
}
}
diff --git a/Content.Server/DeltaV/ParadoxAnomaly/Systems/ParadoxAnomalySystem.cs b/Content.Server/DeltaV/ParadoxAnomaly/Systems/ParadoxAnomalySystem.cs
index 62d994dac34..abdc9500202 100644
--- a/Content.Server/DeltaV/ParadoxAnomaly/Systems/ParadoxAnomalySystem.cs
+++ b/Content.Server/DeltaV/ParadoxAnomaly/Systems/ParadoxAnomalySystem.cs
@@ -144,7 +144,7 @@ private bool TrySpawnParadoxAnomaly(string rule, [NotNullWhen(true)] out EntityU
if (job.StartingGear != null && _proto.TryIndex(job.StartingGear, out var gear))
{
- _stationSpawning.EquipStartingGear(spawned, gear, profile);
+ _stationSpawning.EquipStartingGear(spawned, gear);
_stationSpawning.EquipIdCard(spawned,
profile.Name,
job,
diff --git a/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs b/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs
index ec9ec770313..6849c508a1f 100644
--- a/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs
+++ b/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs
@@ -1,4 +1,5 @@
using System.Linq;
+using Content.Server.GameTicking.Components;
using Robust.Shared.Random;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.NPC.Components;
diff --git a/Content.Server/DeltaV/StationEvents/Events/PirateRadioSpawnRule.cs b/Content.Server/DeltaV/StationEvents/Events/PirateRadioSpawnRule.cs
index ba042d89662..c5d199164b4 100644
--- a/Content.Server/DeltaV/StationEvents/Events/PirateRadioSpawnRule.cs
+++ b/Content.Server/DeltaV/StationEvents/Events/PirateRadioSpawnRule.cs
@@ -19,6 +19,7 @@
using Content.Shared.Salvage;
using Content.Shared.Random.Helpers;
using System.Linq;
+using Content.Server.GameTicking.Components;
using Content.Shared.CCVar;
namespace Content.Server.StationEvents.Events;
diff --git a/Content.Server/Destructible/Thresholds/MinMax.cs b/Content.Server/Destructible/Thresholds/MinMax.cs
index b438e7c0e8d..c44864183ab 100644
--- a/Content.Server/Destructible/Thresholds/MinMax.cs
+++ b/Content.Server/Destructible/Thresholds/MinMax.cs
@@ -1,4 +1,6 @@
-namespace Content.Server.Destructible.Thresholds
+using Robust.Shared.Random;
+
+namespace Content.Server.Destructible.Thresholds
{
[Serializable]
[DataDefinition]
@@ -9,5 +11,16 @@ public partial struct MinMax
[DataField("max")]
public int Max;
+
+ public MinMax(int min, int max)
+ {
+ Min = min;
+ Max = max;
+ }
+
+ public int Next(IRobustRandom random)
+ {
+ return random.Next(Min, Max + 1);
+ }
}
}
diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs
index a04f274491c..48a65973491 100644
--- a/Content.Server/Entry/EntryPoint.cs
+++ b/Content.Server/Entry/EntryPoint.cs
@@ -5,9 +5,9 @@
using Content.Server.Afk;
using Content.Server.Chat.Managers;
using Content.Server.Connection;
-using Content.Server.DiscordAuth;
using Content.Server.JoinQueue;
using Content.Server.Database;
+using Content.Server.DiscordAuth;
using Content.Server.EUI;
using Content.Server.GameTicking;
using Content.Server.GhostKick;
diff --git a/Content.Server/GameTicking/Rules/Components/ActiveGameRuleComponent.cs b/Content.Server/GameTicking/Components/ActiveGameRuleComponent.cs
similarity index 84%
rename from Content.Server/GameTicking/Rules/Components/ActiveGameRuleComponent.cs
rename to Content.Server/GameTicking/Components/ActiveGameRuleComponent.cs
index 956768bdd99..b9e6fa5d4b8 100644
--- a/Content.Server/GameTicking/Rules/Components/ActiveGameRuleComponent.cs
+++ b/Content.Server/GameTicking/Components/ActiveGameRuleComponent.cs
@@ -1,4 +1,4 @@
-namespace Content.Server.GameTicking.Rules.Components;
+namespace Content.Server.GameTicking.Components;
///
/// Added to game rules before and removed before .
diff --git a/Content.Server/GameTicking/Components/DelayedStartRuleComponent.cs b/Content.Server/GameTicking/Components/DelayedStartRuleComponent.cs
new file mode 100644
index 00000000000..de4be83627d
--- /dev/null
+++ b/Content.Server/GameTicking/Components/DelayedStartRuleComponent.cs
@@ -0,0 +1,16 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.GameTicking.Components;
+
+///
+/// Generic component used to track a gamerule that's start has been delayed.
+///
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class DelayedStartRuleComponent : Component
+{
+ ///
+ /// The time at which the rule will start properly.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+ public TimeSpan RuleStartTime;
+}
diff --git a/Content.Server/GameTicking/Rules/Components/EndedGameRuleComponent.cs b/Content.Server/GameTicking/Components/EndedGameRuleComponent.cs
similarity index 81%
rename from Content.Server/GameTicking/Rules/Components/EndedGameRuleComponent.cs
rename to Content.Server/GameTicking/Components/EndedGameRuleComponent.cs
index 4484abd4d0b..3234bfff3a0 100644
--- a/Content.Server/GameTicking/Rules/Components/EndedGameRuleComponent.cs
+++ b/Content.Server/GameTicking/Components/EndedGameRuleComponent.cs
@@ -1,4 +1,4 @@
-namespace Content.Server.GameTicking.Rules.Components;
+namespace Content.Server.GameTicking.Components;
///
/// Added to game rules before .
diff --git a/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs b/Content.Server/GameTicking/Components/GameRuleComponent.cs
similarity index 83%
rename from Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs
rename to Content.Server/GameTicking/Components/GameRuleComponent.cs
index 6309b974020..1e6c3f0ab1d 100644
--- a/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs
+++ b/Content.Server/GameTicking/Components/GameRuleComponent.cs
@@ -1,6 +1,7 @@
+using Content.Server.Destructible.Thresholds;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
-namespace Content.Server.GameTicking.Rules.Components;
+namespace Content.Server.GameTicking.Components;
///
/// Component attached to all gamerule entities.
@@ -20,6 +21,12 @@ public sealed partial class GameRuleComponent : Component
///
[DataField]
public int MinPlayers;
+
+ ///
+ /// A delay for when the rule the is started and when the starting logic actually runs.
+ ///
+ [DataField]
+ public MinMax? Delay;
}
///
diff --git a/Content.Server/GameTicking/GameTicker.GameRule.cs b/Content.Server/GameTicking/GameTicker.GameRule.cs
index 4ebe946af4a..f52a3cb296d 100644
--- a/Content.Server/GameTicking/GameTicker.GameRule.cs
+++ b/Content.Server/GameTicking/GameTicker.GameRule.cs
@@ -1,6 +1,6 @@
using System.Linq;
using Content.Server.Administration;
-using Content.Server.GameTicking.Rules.Components;
+using Content.Server.GameTicking.Components;
using Content.Shared.Administration;
using Content.Shared.Database;
using Content.Shared.Prototypes;
@@ -102,6 +102,22 @@ public bool StartGameRule(EntityUid ruleEntity, GameRuleComponent? ruleData = nu
if (MetaData(ruleEntity).EntityPrototype?.ID is not { } id) // you really fucked up
return false;
+ // If we already have it, then we just skip the delay as it has already happened.
+ if (!RemComp(ruleEntity) && ruleData.Delay != null)
+ {
+ var delayTime = TimeSpan.FromSeconds(ruleData.Delay.Value.Next(_robustRandom));
+
+ if (delayTime > TimeSpan.Zero)
+ {
+ _sawmill.Info($"Queued start for game rule {ToPrettyString(ruleEntity)} with delay {delayTime}");
+ _adminLogger.Add(LogType.EventStarted, $"Queued start for game rule {ToPrettyString(ruleEntity)} with delay {delayTime}");
+
+ var delayed = EnsureComp(ruleEntity);
+ delayed.RuleStartTime = _gameTiming.CurTime + (delayTime);
+ return true;
+ }
+ }
+
_allPreviousGameRules.Add((RoundDuration(), id));
_sawmill.Info($"Started game rule {ToPrettyString(ruleEntity)}");
_adminLogger.Add(LogType.EventStarted, $"Started game rule {ToPrettyString(ruleEntity)}");
@@ -255,6 +271,18 @@ public IEnumerable GetAllGameRulePrototypes()
}
}
+ private void UpdateGameRules()
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var delay, out var rule))
+ {
+ if (_gameTiming.CurTime < delay.RuleStartTime)
+ continue;
+
+ StartGameRule(uid, rule);
+ }
+ }
+
#region Command Implementations
[AdminCommand(AdminFlags.Fun)]
@@ -323,38 +351,3 @@ private void ClearGameRulesCommand(IConsoleShell shell, string argstr, string[]
#endregion
}
-
-/*
-///
-/// Raised broadcast when a game rule is selected, but not started yet.
-///
-public sealed class GameRuleAddedEvent
-{
- public GameRulePrototype Rule { get; }
-
- public GameRuleAddedEvent(GameRulePrototype rule)
- {
- Rule = rule;
- }
-}
-
-public sealed class GameRuleStartedEvent
-{
- public GameRulePrototype Rule { get; }
-
- public GameRuleStartedEvent(GameRulePrototype rule)
- {
- Rule = rule;
- }
-}
-
-public sealed class GameRuleEndedEvent
-{
- public GameRulePrototype Rule { get; }
-
- public GameRuleEndedEvent(GameRulePrototype rule)
- {
- Rule = rule;
- }
-}
-*/
diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs
index efda3df0ca1..fa23312268f 100644
--- a/Content.Server/GameTicking/GameTicker.cs
+++ b/Content.Server/GameTicking/GameTicker.cs
@@ -133,6 +133,7 @@ public override void Update(float frameTime)
return;
base.Update(frameTime);
UpdateRoundFlow(frameTime);
+ UpdateGameRules();
}
}
}
diff --git a/Content.Server/GameTicking/Rules/Components/LoadMapRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/LoadMapRuleComponent.cs
new file mode 100644
index 00000000000..463aecbff54
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/Components/LoadMapRuleComponent.cs
@@ -0,0 +1,29 @@
+using Content.Server.Maps;
+using Content.Shared.Whitelist;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Server.GameTicking.Rules.Components;
+
+///
+/// This is used for a game rule that loads a map when activated.
+///
+[RegisterComponent]
+public sealed partial class LoadMapRuleComponent : Component
+{
+ [DataField]
+ public MapId? Map;
+
+ [DataField]
+ public ProtoId? GameMap ;
+
+ [DataField]
+ public ResPath? MapPath;
+
+ [DataField]
+ public List MapGrids = new();
+
+ [DataField]
+ public EntityWhitelist? SpawnerWhitelist;
+}
diff --git a/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
index e6966c1e377..fa352eb320b 100644
--- a/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
@@ -8,7 +8,7 @@ namespace Content.Server.GameTicking.Rules.Components;
///
/// Stores some configuration used by the ninja system.
-/// Objectives and roundend summary are handled by .
+/// Objectives and roundend summary are handled by .
///
[RegisterComponent, Access(typeof(SpaceNinjaSystem))]
public sealed partial class NinjaRuleComponent : Component
diff --git a/Content.Server/GameTicking/Rules/Components/NukeOperativeSpawnerComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeOperativeSpawnerComponent.cs
index e02d90c18bf..bb1b7c87460 100644
--- a/Content.Server/GameTicking/Rules/Components/NukeOperativeSpawnerComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NukeOperativeSpawnerComponent.cs
@@ -1,6 +1,3 @@
-using Content.Shared.Roles;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
-
namespace Content.Server.GameTicking.Rules.Components;
///
@@ -9,11 +6,5 @@ namespace Content.Server.GameTicking.Rules.Components;
/// TODO: Remove once systems can request spawns from the ghost role system directly.
///
[RegisterComponent]
-public sealed partial class NukeOperativeSpawnerComponent : Component
-{
- [DataField("name", required:true)]
- public string OperativeName = default!;
+public sealed partial class NukeOperativeSpawnerComponent : Component;
- [DataField]
- public NukeopSpawnPreset SpawnDetails = default!;
-}
diff --git a/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs
index 358b157cdf3..3d097cd7c79 100644
--- a/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs
@@ -6,4 +6,6 @@
[RegisterComponent]
public sealed partial class NukeOpsShuttleComponent : Component
{
+ [DataField]
+ public EntityUid AssociatedRule;
}
diff --git a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
index 8efd61b4694..f64947e286e 100644
--- a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
@@ -1,10 +1,9 @@
using Content.Server.Maps;
using Content.Server.NPC.Components;
using Content.Server.RoundEnd;
-using Content.Server.StationEvents.Events;
using Content.Shared.Dataset;
using Content.Shared.Roles;
-using Robust.Shared.Map;
+using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
@@ -14,18 +13,9 @@
namespace Content.Server.GameTicking.Rules.Components;
-[RegisterComponent, Access(typeof(NukeopsRuleSystem), typeof(LoneOpsSpawnRule))]
+[RegisterComponent, Access(typeof(NukeopsRuleSystem))]
public sealed partial class NukeopsRuleComponent : Component
{
- ///
- /// This INCLUDES the operatives. So a value of 3 is satisfied by 2 players & 1 operative
- ///
- [DataField]
- public int PlayersPerOperative = 10;
-
- [DataField]
- public int MaxOps = 5;
-
///
/// What will happen if all of the nuclear operatives will die. Used by LoneOpsSpawn event.
///
@@ -56,12 +46,6 @@ public sealed partial class NukeopsRuleComponent : Component
[DataField]
public TimeSpan EvacShuttleTime = TimeSpan.FromMinutes(3);
- ///
- /// Whether or not to spawn the nuclear operative outpost. Used by LoneOpsSpawn event.
- ///
- [DataField]
- public bool SpawnOutpost = true;
-
///
/// Whether or not nukie left their outpost
///
@@ -84,7 +68,7 @@ public sealed partial class NukeopsRuleComponent : Component
/// This amount of TC will be given to each nukie
///
[DataField]
- public int WarTCAmountPerNukie = 40;
+ public int WarTcAmountPerNukie = 40;
///
/// Delay between war declaration and nuke ops arrival on station map. Gives crew time to prepare
@@ -98,49 +82,23 @@ public sealed partial class NukeopsRuleComponent : Component
[DataField]
public int WarDeclarationMinOps = 4;
- [DataField]
- public EntProtoId SpawnPointProto = "SpawnPointNukies";
-
- [DataField]
- public EntProtoId GhostSpawnPointProto = "SpawnPointGhostNukeOperative";
-
- [DataField]
- public string OperationName = "Test Operation";
-
- [DataField]
- public ProtoId OutpostMapPrototype = "NukieOutpost";
-
[DataField]
public WinType WinType = WinType.Neutral;
[DataField]
public List WinConditions = new ();
- public MapId? NukiePlanet;
-
- // TODO: use components, don't just cache entity UIDs
- // There have been (and probably still are) bugs where these refer to deleted entities from old rounds.
- public EntityUid? NukieOutpost;
- public EntityUid? NukieShuttle;
- public EntityUid? TargetStation;
-
- ///
- /// Data to be used in for an operative once the Mind has been added.
- ///
- [DataField]
- public Dictionary OperativeMindPendingData = new();
-
- [DataField(required: true)]
- public ProtoId Faction = default!;
-
[DataField]
- public NukeopSpawnPreset CommanderSpawnDetails = new() { AntagRoleProto = "NukeopsCommander", GearProto = "SyndicateCommanderGearFull", NamePrefix = "nukeops-role-commander", NameList = "SyndicateNamesElite" };
+ public EntityUid? TargetStation;
[DataField]
- public NukeopSpawnPreset AgentSpawnDetails = new() { AntagRoleProto = "NukeopsMedic", GearProto = "SyndicateOperativeMedicFull", NamePrefix = "nukeops-role-agent", NameList = "SyndicateNamesNormal" };
+ public ProtoId Faction = "Syndicate";
+ ///
+ /// Path to antagonist alert sound.
+ ///
[DataField]
- public NukeopSpawnPreset OperativeSpawnDetails = new();
+ public SoundSpecifier GreetSoundNotification = new SoundPathSpecifier("/Audio/Ambience/Antag/nukeops_start.ogg");
}
///
diff --git a/Content.Server/GameTicking/Rules/Components/PiratesRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/PiratesRuleComponent.cs
deleted file mode 100644
index 1d03b41d773..00000000000
--- a/Content.Server/GameTicking/Rules/Components/PiratesRuleComponent.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using Robust.Shared.Audio;
-
-namespace Content.Server.GameTicking.Rules.Components;
-
-[RegisterComponent, Access(typeof(PiratesRuleSystem))]
-public sealed partial class PiratesRuleComponent : Component
-{
- [ViewVariables]
- public List Pirates = new();
- [ViewVariables]
- public EntityUid PirateShip = EntityUid.Invalid;
- [ViewVariables]
- public HashSet InitialItems = new();
- [ViewVariables]
- public double InitialShipValue;
-
- ///
- /// Path to antagonist alert sound.
- ///
- [DataField("pirateAlertSound")]
- public SoundSpecifier PirateAlertSound = new SoundPathSpecifier(
- "/Audio/Ambience/Antag/pirate_start.ogg",
- AudioParams.Default.WithVolume(4));
-}
diff --git a/Content.Server/GameTicking/Rules/Components/RevolutionaryRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/RevolutionaryRuleComponent.cs
index 2ce3f1f9a66..3b19bbffb6a 100644
--- a/Content.Server/GameTicking/Rules/Components/RevolutionaryRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/RevolutionaryRuleComponent.cs
@@ -22,43 +22,6 @@ public sealed partial class RevolutionaryRuleComponent : Component
[DataField]
public TimeSpan TimerWait = TimeSpan.FromSeconds(20);
- ///
- /// Stores players minds
- ///
- [DataField]
- public Dictionary HeadRevs = new();
-
- [DataField]
- public ProtoId HeadRevPrototypeId = "HeadRev";
-
- ///
- /// Min players needed for Revolutionary gamemode to start.
- ///
- [DataField, ViewVariables(VVAccess.ReadWrite)]
- public int MinPlayers = 15;
-
- ///
- /// Max Head Revs allowed during selection.
- ///
- [DataField, ViewVariables(VVAccess.ReadWrite)]
- public int MaxHeadRevs = 3;
-
- ///
- /// The amount of Head Revs that will spawn per this amount of players.
- ///
- [DataField, ViewVariables(VVAccess.ReadWrite)]
- public int PlayersPerHeadRev = 15;
-
- ///
- /// The gear head revolutionaries are given on spawn.
- ///
- [DataField]
- public List StartingGear = new()
- {
- "Flash",
- "ClothingEyesGlassesSunglasses"
- };
-
///
/// The time it takes after the last head is killed for the shuttle to arrive.
///
diff --git a/Content.Server/GameTicking/Rules/Components/ThiefRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ThiefRuleComponent.cs
index 9dfd6e6627c..01a078625ae 100644
--- a/Content.Server/GameTicking/Rules/Components/ThiefRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/ThiefRuleComponent.cs
@@ -1,12 +1,11 @@
using Content.Shared.Random;
-using Content.Shared.Roles;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
namespace Content.Server.GameTicking.Rules.Components;
///
-/// Stores data for .
+/// Stores data for .
///
[RegisterComponent, Access(typeof(ThiefRuleSystem))]
public sealed partial class ThiefRuleComponent : Component
@@ -23,42 +22,9 @@ public sealed partial class ThiefRuleComponent : Component
[DataField]
public float BigObjectiveChance = 0.7f;
- ///
- /// Add a Pacified comp to thieves
- ///
- [DataField]
- public bool PacifistThieves = true;
-
- [DataField]
- public ProtoId ThiefPrototypeId = "Thief";
-
[DataField]
public float MaxObjectiveDifficulty = 2.5f;
[DataField]
public int MaxStealObjectives = 10;
-
- ///
- /// Things that will be given to thieves
- ///
- [DataField]
- public List StarterItems = new() { "ToolboxThief", "ClothingHandsChameleonThief" };
-
- ///
- /// All Thieves created by this rule
- ///
- [DataField]
- public List ThievesMinds = new();
-
- ///
- /// Max Thiefs created by rule on roundstart
- ///
- [DataField]
- public int MaxAllowThief = 3;
-
- ///
- /// Sound played when making the player a thief via antag control or ghost role
- ///
- [DataField]
- public SoundSpecifier? GreetingSound = new SoundPathSpecifier("/Audio/Misc/thief_greeting.ogg");
}
diff --git a/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
index 62619db76a2..dd359969b6f 100644
--- a/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
@@ -57,4 +57,19 @@ public enum SelectionState
///
[DataField]
public SoundSpecifier GreetSoundNotification = new SoundPathSpecifier("/Audio/Ambience/Antag/traitor_start.ogg");
+
+ ///
+ /// The amount of codewords that are selected.
+ ///
+ [DataField]
+ public int CodewordCount = 4;
+
+ ///
+ /// The amount of TC traitors start with.
+ ///
+ [DataField]
+ public int StartingBalance = 20;
+
+ [DataField]
+ public int MaxDifficulty = 5;
}
diff --git a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
index 4fe91e3a5f5..59d1940eafe 100644
--- a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
@@ -8,12 +8,6 @@ namespace Content.Server.GameTicking.Rules.Components;
[RegisterComponent, Access(typeof(ZombieRuleSystem))]
public sealed partial class ZombieRuleComponent : Component
{
- [DataField]
- public Dictionary InitialInfectedNames = new();
-
- [DataField]
- public ProtoId PatientZeroPrototypeId = "InitialInfected";
-
///
/// When the round will next check for round end.
///
@@ -26,61 +20,9 @@ public sealed partial class ZombieRuleComponent : Component
[DataField]
public TimeSpan EndCheckDelay = TimeSpan.FromSeconds(30);
- ///
- /// The time at which the initial infected will be chosen.
- ///
- [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
- public TimeSpan? StartTime;
-
- ///
- /// The minimum amount of time after the round starts that the initial infected will be chosen.
- ///
- [DataField]
- public TimeSpan MinStartDelay = TimeSpan.FromMinutes(10);
-
- ///
- /// The maximum amount of time after the round starts that the initial infected will be chosen.
- ///
- [DataField]
- public TimeSpan MaxStartDelay = TimeSpan.FromMinutes(15);
-
- ///
- /// The sound that plays when someone becomes an initial infected.
- /// todo: this should have a unique sound instead of reusing the zombie one.
- ///
- [DataField]
- public SoundSpecifier InitialInfectedSound = new SoundPathSpecifier("/Audio/Ambience/Antag/zombie_start.ogg");
-
- ///
- /// The minimum amount of time initial infected have before they start taking infection damage.
- ///
- [DataField]
- public TimeSpan MinInitialInfectedGrace = TimeSpan.FromMinutes(12.5f);
-
- ///
- /// The maximum amount of time initial infected have before they start taking damage.
- ///
- [DataField]
- public TimeSpan MaxInitialInfectedGrace = TimeSpan.FromMinutes(15f);
-
- ///
- /// How many players for each initial infected.
- ///
- [DataField]
- public int PlayersPerInfected = 10;
-
- ///
- /// The maximum number of initial infected.
- ///
- [DataField]
- public int MaxInitialInfected = 6;
-
///
/// After this amount of the crew become zombies, the shuttle will be automatically called.
///
[DataField]
public float ZombieShuttleCallPercentage = 0.7f;
-
- [DataField]
- public EntProtoId ZombifySelfActionPrototype = "ActionTurnUndead";
}
diff --git a/Content.Server/GameTicking/Rules/DeathMatchRuleSystem.cs b/Content.Server/GameTicking/Rules/DeathMatchRuleSystem.cs
index 82ac755592e..78b8a8a85c8 100644
--- a/Content.Server/GameTicking/Rules/DeathMatchRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/DeathMatchRuleSystem.cs
@@ -1,5 +1,6 @@
using System.Linq;
using Content.Server.Administration.Commands;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.KillTracking;
using Content.Server.Mind;
@@ -33,7 +34,6 @@ public override void Initialize()
SubscribeLocalEvent(OnSpawnComplete);
SubscribeLocalEvent(OnKillReported);
SubscribeLocalEvent(OnPointChanged);
- SubscribeLocalEvent(OnRoundEndTextAppend);
}
private void OnBeforeSpawn(PlayerBeforeSpawnEvent ev)
@@ -113,21 +113,17 @@ private void OnPointChanged(EntityUid uid, DeathMatchRuleComponent component, re
_roundEnd.EndRound(component.RestartDelay);
}
- private void OnRoundEndTextAppend(RoundEndTextAppendEvent ev)
+ protected override void AppendRoundEndText(EntityUid uid, DeathMatchRuleComponent component, GameRuleComponent gameRule, ref RoundEndTextAppendEvent args)
{
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var uid, out var dm, out var point, out var rule))
- {
- if (!GameTicker.IsGameRuleAdded(uid, rule))
- continue;
+ if (!TryComp(uid, out var point))
+ return;
- if (dm.Victor != null && _player.TryGetPlayerData(dm.Victor.Value, out var data))
- {
- ev.AddLine(Loc.GetString("point-scoreboard-winner", ("player", data.UserName)));
- ev.AddLine("");
- }
- ev.AddLine(Loc.GetString("point-scoreboard-header"));
- ev.AddLine(new FormattedMessage(point.Scoreboard).ToMarkup());
+ if (component.Victor != null && _player.TryGetPlayerData(component.Victor.Value, out var data))
+ {
+ args.AddLine(Loc.GetString("point-scoreboard-winner", ("player", data.UserName)));
+ args.AddLine("");
}
+ args.AddLine(Loc.GetString("point-scoreboard-header"));
+ args.AddLine(new FormattedMessage(point.Scoreboard).ToMarkup());
}
}
diff --git a/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs b/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
index a60a2bfe22f..27a9edbad71 100644
--- a/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
+++ b/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Station.Components;
using Robust.Shared.Collections;
@@ -15,29 +16,12 @@ protected EntityQueryEnumerator Q
return EntityQueryEnumerator();
}
- protected bool TryRoundStartAttempt(RoundStartAttemptEvent ev, string localizedPresetName)
+ ///
+ /// Queries all gamerules, regardless of if they're active or not.
+ ///
+ protected EntityQueryEnumerator QueryAllRules()
{
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out _, out _, out _, out var gameRule))
- {
- var minPlayers = gameRule.MinPlayers;
- if (!ev.Forced && ev.Players.Length < minPlayers)
- {
- ChatManager.SendAdminAnnouncement(Loc.GetString("preset-not-enough-ready-players",
- ("readyPlayersCount", ev.Players.Length), ("minimumPlayers", minPlayers),
- ("presetName", localizedPresetName)));
- ev.Cancel();
- continue;
- }
-
- if (ev.Players.Length == 0)
- {
- ChatManager.DispatchServerAnnouncement(Loc.GetString("preset-no-one-ready"));
- ev.Cancel();
- }
- }
-
- return !ev.Cancelled;
+ return EntityQueryEnumerator();
}
///
diff --git a/Content.Server/GameTicking/Rules/GameRuleSystem.cs b/Content.Server/GameTicking/Rules/GameRuleSystem.cs
index 363c2ad7f75..c167ae7b6c7 100644
--- a/Content.Server/GameTicking/Rules/GameRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/GameRuleSystem.cs
@@ -1,6 +1,6 @@
using Content.Server.Atmos.EntitySystems;
using Content.Server.Chat.Managers;
-using Content.Server.GameTicking.Rules.Components;
+using Content.Server.GameTicking.Components;
using Robust.Server.GameObjects;
using Robust.Shared.Random;
using Robust.Shared.Timing;
@@ -22,9 +22,31 @@ public override void Initialize()
{
base.Initialize();
+ SubscribeLocalEvent(OnStartAttempt);
SubscribeLocalEvent(OnGameRuleAdded);
SubscribeLocalEvent(OnGameRuleStarted);
SubscribeLocalEvent(OnGameRuleEnded);
+ SubscribeLocalEvent(OnRoundEndTextAppend);
+ }
+
+ private void OnStartAttempt(RoundStartAttemptEvent args)
+ {
+ if (args.Forced || args.Cancelled)
+ return;
+
+ var query = QueryAllRules();
+ while (query.MoveNext(out var uid, out _, out var gameRule))
+ {
+ var minPlayers = gameRule.MinPlayers;
+ if (args.Players.Length >= minPlayers)
+ continue;
+
+ ChatManager.SendAdminAnnouncement(Loc.GetString("preset-not-enough-ready-players",
+ ("readyPlayersCount", args.Players.Length),
+ ("minimumPlayers", minPlayers),
+ ("presetName", ToPrettyString(uid))));
+ args.Cancel();
+ }
}
private void OnGameRuleAdded(EntityUid uid, T component, ref GameRuleAddedEvent args)
@@ -48,6 +70,12 @@ private void OnGameRuleEnded(EntityUid uid, T component, ref GameRuleEndedEvent
Ended(uid, component, ruleData, args);
}
+ private void OnRoundEndTextAppend(Entity ent, ref RoundEndTextAppendEvent args)
+ {
+ if (!TryComp(ent, out var ruleData))
+ return;
+ AppendRoundEndText(ent, ent, ruleData, ref args);
+ }
///
/// Called when the gamerule is added
@@ -73,6 +101,14 @@ protected virtual void Ended(EntityUid uid, T component, GameRuleComponent gameR
}
+ ///
+ /// Called at the end of a round when text needs to be added for a game rule.
+ ///
+ protected virtual void AppendRoundEndText(EntityUid uid, T component, GameRuleComponent gameRule, ref RoundEndTextAppendEvent args)
+ {
+
+ }
+
///
/// Called on an active gamerule entity in the Update function
///
diff --git a/Content.Server/GameTicking/Rules/InactivityTimeRestartRuleSystem.cs b/Content.Server/GameTicking/Rules/InactivityTimeRestartRuleSystem.cs
index b775b7af564..01fa387595c 100644
--- a/Content.Server/GameTicking/Rules/InactivityTimeRestartRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/InactivityTimeRestartRuleSystem.cs
@@ -1,5 +1,6 @@
using System.Threading;
using Content.Server.Chat.Managers;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules.Components;
using Robust.Server.Player;
using Robust.Shared.Player;
diff --git a/Content.Server/GameTicking/Rules/KillCalloutRuleSystem.cs b/Content.Server/GameTicking/Rules/KillCalloutRuleSystem.cs
index 01fd97d9a79..3da55e30c9e 100644
--- a/Content.Server/GameTicking/Rules/KillCalloutRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/KillCalloutRuleSystem.cs
@@ -1,4 +1,5 @@
using Content.Server.Chat.Managers;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.KillTracking;
using Content.Shared.Chat;
diff --git a/Content.Server/GameTicking/Rules/LoadMapRuleSystem.cs b/Content.Server/GameTicking/Rules/LoadMapRuleSystem.cs
new file mode 100644
index 00000000000..aba9ed9e583
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/LoadMapRuleSystem.cs
@@ -0,0 +1,80 @@
+using Content.Server.Antag;
+using Content.Server.GameTicking.Components;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Spawners.Components;
+using Robust.Server.GameObjects;
+using Robust.Server.Maps;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.GameTicking.Rules;
+
+public sealed class LoadMapRuleSystem : GameRuleSystem
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly MapSystem _map = default!;
+ [Dependency] private readonly MapLoaderSystem _mapLoader = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSelectLocation);
+ SubscribeLocalEvent(OnGridSplit);
+ }
+
+ private void OnGridSplit(ref GridSplitEvent args)
+ {
+ var rule = QueryActiveRules();
+ while (rule.MoveNext(out _, out var mapComp, out _))
+ {
+ if (!mapComp.MapGrids.Contains(args.Grid))
+ continue;
+
+ mapComp.MapGrids.AddRange(args.NewGrids);
+ break;
+ }
+ }
+
+ protected override void Added(EntityUid uid, LoadMapRuleComponent comp, GameRuleComponent rule, GameRuleAddedEvent args)
+ {
+ if (comp.Map != null)
+ return;
+
+ _map.CreateMap(out var mapId);
+ comp.Map = mapId;
+
+ if (comp.GameMap != null)
+ {
+ var gameMap = _prototypeManager.Index(comp.GameMap.Value);
+ comp.MapGrids.AddRange(GameTicker.LoadGameMap(gameMap, comp.Map.Value, new MapLoadOptions()));
+ }
+ else if (comp.MapPath != null)
+ {
+ if (_mapLoader.TryLoad(comp.Map.Value, comp.MapPath.Value.ToString(), out var roots, new MapLoadOptions { LoadMap = true }))
+ comp.MapGrids.AddRange(roots);
+ }
+ else
+ {
+ Log.Error($"No valid map prototype or map path associated with the rule {ToPrettyString(uid)}");
+ }
+ }
+
+ private void OnSelectLocation(Entity ent, ref AntagSelectLocationEvent args)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out _, out var xform))
+ {
+ if (xform.MapID != ent.Comp.Map)
+ continue;
+
+ if (xform.GridUid == null || !ent.Comp.MapGrids.Contains(xform.GridUid.Value))
+ continue;
+
+ if (ent.Comp.SpawnerWhitelist != null && !ent.Comp.SpawnerWhitelist.IsValid(uid, EntityManager))
+ continue;
+
+ args.Coordinates.Add(_transform.GetMapCoordinates(xform));
+ }
+ }
+}
diff --git a/Content.Server/GameTicking/Rules/MaxTimeRestartRuleSystem.cs b/Content.Server/GameTicking/Rules/MaxTimeRestartRuleSystem.cs
index e792a004df5..ee3a025533a 100644
--- a/Content.Server/GameTicking/Rules/MaxTimeRestartRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/MaxTimeRestartRuleSystem.cs
@@ -1,5 +1,6 @@
using System.Threading;
using Content.Server.Chat.Managers;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules.Components;
using Timer = Robust.Shared.Timing.Timer;
diff --git a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
index 46040e29450..d06b9fb899c 100644
--- a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
@@ -1,77 +1,51 @@
-using Content.Server.Administration.Commands;
-using Content.Server.Administration.Managers;
using Content.Server.Antag;
using Content.Server.Communications;
using Content.Server.GameTicking.Rules.Components;
-using Content.Server.Ghost.Roles.Components;
-using Content.Server.Ghost.Roles.Events;
using Content.Server.Humanoid;
-using Content.Server.Mind;
-using Content.Server.NPC.Components;
-using Content.Server.NPC.Systems;
using Content.Server.Nuke;
using Content.Server.NukeOps;
using Content.Server.Popups;
using Content.Server.Preferences.Managers;
-using Content.Server.RandomMetadata;
using Content.Server.Roles;
using Content.Server.RoundEnd;
using Content.Server.Shuttles.Events;
using Content.Server.Shuttles.Systems;
-using Content.Server.Spawners.Components;
using Content.Server.Station.Components;
-using Content.Server.Station.Systems;
using Content.Server.Store.Components;
using Content.Server.Store.Systems;
-using Content.Shared.CCVar;
-using Content.Shared.Dataset;
using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Prototypes;
-using Content.Shared.Mind.Components;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
using Content.Shared.Nuke;
using Content.Shared.NukeOps;
using Content.Shared.Preferences;
-using Content.Shared.Roles;
using Content.Shared.Store;
using Content.Shared.Tag;
using Content.Shared.Zombies;
-using Robust.Server.Player;
-using Robust.Shared.Configuration;
using Robust.Shared.Map;
-using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Utility;
using System.Linq;
+using Content.Server.GameTicking.Components;
+using Content.Server.NPC.Components;
+using Content.Server.NPC.Systems;
namespace Content.Server.GameTicking.Rules;
public sealed class NukeopsRuleSystem : GameRuleSystem
{
- [Dependency] private readonly IMapManager _mapManager = default!;
- [Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IServerPreferencesManager _prefs = default!;
- [Dependency] private readonly IAdminManager _adminManager = default!;
- [Dependency] private readonly IConfigurationManager _cfg = default!;
- [Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly EmergencyShuttleSystem _emergency = default!;
[Dependency] private readonly HumanoidAppearanceSystem _humanoid = default!;
- [Dependency] private readonly MetaDataSystem _metaData = default!;
- [Dependency] private readonly RandomMetadataSystem _randomMetadata = default!;
- [Dependency] private readonly MindSystem _mind = default!;
[Dependency] private readonly NpcFactionSystem _npcFaction = default!;
+ [Dependency] private readonly AntagSelectionSystem _antag = default!;
[Dependency] private readonly PopupSystem _popupSystem = default!;
[Dependency] private readonly RoundEndSystem _roundEndSystem = default!;
- [Dependency] private readonly SharedRoleSystem _roles = default!;
- [Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
[Dependency] private readonly StoreSystem _store = default!;
[Dependency] private readonly TagSystem _tag = default!;
- [Dependency] private readonly AntagSelectionSystem _antagSelection = default!;
-
- private ISawmill _sawmill = default!;
[ValidatePrototypeId]
private const string TelecrystalCurrencyPrototype = "Telecrystal";
@@ -79,141 +53,67 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
[ValidatePrototypeId]
private const string NukeOpsUplinkTagPrototype = "NukeOpsUplink";
- [ValidatePrototypeId]
- public const string NukeopsId = "Nukeops";
-
- [ValidatePrototypeId]
- private const string OperationPrefixDataset = "operationPrefix";
-
- [ValidatePrototypeId]
- private const string OperationSuffixDataset = "operationSuffix";
-
public override void Initialize()
{
base.Initialize();
- _sawmill = _logManager.GetSawmill("NukeOps");
-
- SubscribeLocalEvent(OnStartAttempt);
- SubscribeLocalEvent(OnPlayersSpawning);
- SubscribeLocalEvent(OnRoundEndText);
SubscribeLocalEvent(OnNukeExploded);
SubscribeLocalEvent(OnRunLevelChanged);
SubscribeLocalEvent(OnNukeDisarm);
SubscribeLocalEvent(OnComponentRemove);
SubscribeLocalEvent(OnMobStateChanged);
- SubscribeLocalEvent(OnPlayersGhostSpawning);
- SubscribeLocalEvent(OnMindAdded);
SubscribeLocalEvent(OnOperativeZombified);
+ SubscribeLocalEvent(OnMapInit);
+
SubscribeLocalEvent(OnShuttleFTLAttempt);
SubscribeLocalEvent(OnWarDeclared);
SubscribeLocalEvent(OnShuttleCallAttempt);
+
+ SubscribeLocalEvent(OnAntagSelectEntity);
+ SubscribeLocalEvent(OnAfterAntagEntSelected);
}
protected override void Started(EntityUid uid, NukeopsRuleComponent component, GameRuleComponent gameRule,
GameRuleStartedEvent args)
{
- base.Started(uid, component, gameRule, args);
-
- if (GameTicker.RunLevel == GameRunLevel.InRound)
- SpawnOperativesForGhostRoles(uid, component);
- }
-
- #region Event Handlers
-
- private void OnStartAttempt(RoundStartAttemptEvent ev)
- {
- TryRoundStartAttempt(ev, Loc.GetString("nukeops-title"));
- }
-
- private void OnPlayersSpawning(RulePlayerSpawningEvent ev)
- {
- var query = QueryActiveRules();
- while (query.MoveNext(out var uid, out _, out var nukeops, out _))
+ var eligible = new List>();
+ var eligibleQuery = EntityQueryEnumerator();
+ while (eligibleQuery.MoveNext(out var eligibleUid, out var eligibleComp, out var member))
{
- if (!SpawnMap((uid, nukeops)))
- {
- _sawmill.Info("Failed to load map for nukeops");
- continue;
- }
-
- //Handle there being nobody readied up
- if (ev.PlayerPool.Count == 0)
+ if (!_npcFaction.IsFactionHostile(component.Faction, eligibleUid, member))
continue;
- var commanderEligible = _antagSelection.GetEligibleSessions(ev.PlayerPool, nukeops.CommanderSpawnDetails.AntagRoleProto);
- var agentEligible = _antagSelection.GetEligibleSessions(ev.PlayerPool, nukeops.AgentSpawnDetails.AntagRoleProto);
- var operativeEligible = _antagSelection.GetEligibleSessions(ev.PlayerPool, nukeops.OperativeSpawnDetails.AntagRoleProto);
- //Calculate how large the nukeops team needs to be
- var nukiesToSelect = _antagSelection.CalculateAntagCount(_playerManager.PlayerCount, nukeops.PlayersPerOperative, nukeops.MaxOps);
-
- //Select Nukies
- //Select Commander, priority : commanderEligible, agentEligible, operativeEligible, all players
- var selectedCommander = _antagSelection.ChooseAntags(1, commanderEligible, agentEligible, operativeEligible, ev.PlayerPool).FirstOrDefault();
- //Select Agent, priority : agentEligible, operativeEligible, all players
- var selectedAgent = _antagSelection.ChooseAntags(1, agentEligible, operativeEligible, ev.PlayerPool).FirstOrDefault();
- //Select Operatives, priority : operativeEligible, all players
- var selectedOperatives = _antagSelection.ChooseAntags(nukiesToSelect - 2, operativeEligible, ev.PlayerPool);
-
- //Create the team!
- //If the session is null, they will be spawned as ghost roles (provided the cvar is set)
- var operatives = new List { new NukieSpawn(selectedCommander, nukeops.CommanderSpawnDetails) };
- if (nukiesToSelect > 1)
- operatives.Add(new NukieSpawn(selectedAgent, nukeops.AgentSpawnDetails));
-
- for (var i = 0; i < nukiesToSelect - 2; i++)
- {
- //Use up all available sessions first, then spawn the rest as ghost roles (if enabled)
- if (selectedOperatives.Count > i)
- {
- operatives.Add(new NukieSpawn(selectedOperatives[i], nukeops.OperativeSpawnDetails));
- }
- else
- {
- operatives.Add(new NukieSpawn(null, nukeops.OperativeSpawnDetails));
- }
- }
-
- SpawnOperatives(operatives, _cfg.GetCVar(CCVars.NukeopsSpawnGhostRoles), nukeops);
+ eligible.Add((eligibleUid, eligibleComp, member));
+ }
- foreach (var nukieSpawn in operatives)
- {
- if (nukieSpawn.Session == null)
- continue;
+ if (eligible.Count == 0)
+ return;
- GameTicker.PlayerJoinGame(nukieSpawn.Session);
- }
- }
+ component.TargetStation = RobustRandom.Pick(eligible);
}
- private void OnRoundEndText(RoundEndTextAppendEvent ev)
+ #region Event Handlers
+ protected override void AppendRoundEndText(EntityUid uid, NukeopsRuleComponent component, GameRuleComponent gameRule,
+ ref RoundEndTextAppendEvent args)
{
- var ruleQuery = QueryActiveRules();
- while (ruleQuery.MoveNext(out _, out _, out var nukeops, out _))
- {
- var winText = Loc.GetString($"nukeops-{nukeops.WinType.ToString().ToLower()}");
- ev.AddLine(winText);
+ var winText = Loc.GetString($"nukeops-{component.WinType.ToString().ToLower()}");
+ args.AddLine(winText);
- foreach (var cond in nukeops.WinConditions)
- {
- var text = Loc.GetString($"nukeops-cond-{cond.ToString().ToLower()}");
- ev.AddLine(text);
- }
+ foreach (var cond in component.WinConditions)
+ {
+ var text = Loc.GetString($"nukeops-cond-{cond.ToString().ToLower()}");
+ args.AddLine(text);
}
- ev.AddLine(Loc.GetString("nukeops-list-start"));
+ args.AddLine(Loc.GetString("nukeops-list-start"));
- var nukiesQuery = EntityQueryEnumerator();
- while (nukiesQuery.MoveNext(out var nukeopsUid, out _, out var mindContainer))
- {
- if (!_mind.TryGetMind(nukeopsUid, out _, out var mind, mindContainer))
- continue;
+ var antags =_antag.GetAntagIdentifiers(uid);
- ev.AddLine(mind.Session != null
- ? Loc.GetString("nukeops-list-name-user", ("name", Name(nukeopsUid)), ("user", mind.Session.Name))
- : Loc.GetString("nukeops-list-name", ("name", Name(nukeopsUid))));
+ foreach (var (_, sessionData, name) in antags)
+ {
+ args.AddLine(Loc.GetString("nukeops-list-name-user", ("name", name), ("user", sessionData.UserName)));
}
}
@@ -224,10 +124,10 @@ private void OnNukeExploded(NukeExplodedEvent ev)
{
if (ev.OwningStation != null)
{
- if (ev.OwningStation == nukeops.NukieOutpost)
+ if (ev.OwningStation == GetOutpost(uid))
{
nukeops.WinConditions.Add(WinCondition.NukeExplodedOnNukieOutpost);
- SetWinType(uid, WinType.CrewMajor, nukeops);
+ SetWinType((uid, nukeops), WinType.CrewMajor);
continue;
}
@@ -242,7 +142,7 @@ private void OnNukeExploded(NukeExplodedEvent ev)
}
nukeops.WinConditions.Add(WinCondition.NukeExplodedOnCorrectStation);
- SetWinType(uid, WinType.OpsMajor, nukeops);
+ SetWinType((uid, nukeops), WinType.OpsMajor);
correctStation = true;
}
@@ -263,19 +163,85 @@ private void OnNukeExploded(NukeExplodedEvent ev)
private void OnRunLevelChanged(GameRunLevelChangedEvent ev)
{
+ if (ev.New is not GameRunLevel.PostRound)
+ return;
+
var query = QueryActiveRules();
while (query.MoveNext(out var uid, out _, out var nukeops, out _))
{
- switch (ev.New)
+ OnRoundEnd((uid, nukeops));
+ }
+ }
+
+ private void OnRoundEnd(Entity ent)
+ {
+ // If the win condition was set to operative/crew major win, ignore.
+ if (ent.Comp.WinType == WinType.OpsMajor || ent.Comp.WinType == WinType.CrewMajor)
+ return;
+
+ var nukeQuery = AllEntityQuery();
+ var centcomms = _emergency.GetCentcommMaps();
+
+ while (nukeQuery.MoveNext(out var nuke, out var nukeTransform))
+ {
+ if (nuke.Status != NukeStatus.ARMED)
+ continue;
+
+ // UH OH
+ if (nukeTransform.MapUid != null && centcomms.Contains(nukeTransform.MapUid.Value))
{
- case GameRunLevel.InRound:
- OnRoundStart(uid, nukeops);
- break;
- case GameRunLevel.PostRound:
- OnRoundEnd(uid, nukeops);
- break;
+ ent.Comp.WinConditions.Add(WinCondition.NukeActiveAtCentCom);
+ SetWinType((ent, ent), WinType.OpsMajor);
+ return;
}
+
+ if (nukeTransform.GridUid == null || ent.Comp.TargetStation == null)
+ continue;
+
+ if (!TryComp(ent.Comp.TargetStation.Value, out StationDataComponent? data))
+ continue;
+
+ foreach (var grid in data.Grids)
+ {
+ if (grid != nukeTransform.GridUid)
+ continue;
+
+ ent.Comp.WinConditions.Add(WinCondition.NukeActiveInStation);
+ SetWinType(ent, WinType.OpsMajor);
+ return;
+ }
+ }
+
+ if (_antag.AllAntagsAlive(ent.Owner))
+ {
+ SetWinType(ent, WinType.OpsMinor);
+ ent.Comp.WinConditions.Add(WinCondition.AllNukiesAlive);
+ return;
}
+
+ ent.Comp.WinConditions.Add(_antag.AnyAliveAntags(ent.Owner)
+ ? WinCondition.SomeNukiesAlive
+ : WinCondition.AllNukiesDead);
+
+ var diskAtCentCom = false;
+ var diskQuery = AllEntityQuery();
+ while (diskQuery.MoveNext(out _, out var transform))
+ {
+ diskAtCentCom = transform.MapUid != null && centcomms.Contains(transform.MapUid.Value);
+
+ // TODO: The target station should be stored, and the nuke disk should store its original station.
+ // This is fine for now, because we can assume a single station in base SS14.
+ break;
+ }
+
+ // If the disk is currently at Central Command, the crew wins - just slightly.
+ // This also implies that some nuclear operatives have died.
+ SetWinType(ent, diskAtCentCom
+ ? WinType.CrewMinor
+ : WinType.OpsMinor);
+ ent.Comp.WinConditions.Add(diskAtCentCom
+ ? WinCondition.NukeDiskOnCentCom
+ : WinCondition.NukeDiskNotOnCentCom);
}
private void OnNukeDisarm(NukeDisarmSuccessEvent ev)
@@ -294,66 +260,31 @@ private void OnMobStateChanged(EntityUid uid, NukeOperativeComponent component,
CheckRoundShouldEnd();
}
- private void OnPlayersGhostSpawning(EntityUid uid, NukeOperativeComponent component, GhostRoleSpawnerUsedEvent args)
+ private void OnOperativeZombified(EntityUid uid, NukeOperativeComponent component, ref EntityZombifiedEvent args)
{
- var spawner = args.Spawner;
-
- if (!TryComp(spawner, out var nukeOpSpawner))
- return;
-
- HumanoidCharacterProfile? profile = null;
- if (TryComp(args.Spawned, out ActorComponent? actor))
- profile = _prefs.GetPreferences(actor.PlayerSession.UserId).SelectedCharacter as HumanoidCharacterProfile;
-
- // TODO: this is kinda awful for multi-nukies
- foreach (var nukeops in EntityQuery())
- {
- SetupOperativeEntity(uid, nukeOpSpawner.OperativeName, nukeOpSpawner.SpawnDetails, profile);
-
- nukeops.OperativeMindPendingData.Add(uid, nukeOpSpawner.SpawnDetails.AntagRoleProto);
- }
+ RemCompDeferred(uid, component);
}
- private void OnMindAdded(EntityUid uid, NukeOperativeComponent component, MindAddedMessage args)
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
{
- if (!_mind.TryGetMind(uid, out var mindId, out var mind))
- return;
+ var map = Transform(ent).MapID;
- var query = QueryActiveRules();
- while (query.MoveNext(out _, out _, out var nukeops, out _))
+ var rules = EntityQueryEnumerator();
+ while (rules.MoveNext(out var uid, out _, out var mapRule))
{
- if (nukeops.OperativeMindPendingData.TryGetValue(uid, out var role) || !nukeops.SpawnOutpost ||
- nukeops.RoundEndBehavior == RoundEndBehavior.Nothing)
- {
- role ??= nukeops.OperativeSpawnDetails.AntagRoleProto;
- _roles.MindAddRole(mindId, new NukeopsRoleComponent { PrototypeId = role });
- nukeops.OperativeMindPendingData.Remove(uid);
- }
-
- if (mind.Session is not { } playerSession)
- return;
-
- if (GameTicker.RunLevel != GameRunLevel.InRound)
- return;
-
- if (nukeops.TargetStation != null && !string.IsNullOrEmpty(Name(nukeops.TargetStation.Value)))
- {
- NotifyNukie(playerSession, component, nukeops);
- }
+ if (map != mapRule.Map)
+ continue;
+ ent.Comp.AssociatedRule = uid;
+ break;
}
}
- private void OnOperativeZombified(EntityUid uid, NukeOperativeComponent component, ref EntityZombifiedEvent args)
- {
- RemCompDeferred(uid, component);
- }
-
private void OnShuttleFTLAttempt(ref ConsoleFTLAttemptEvent ev)
{
var query = QueryActiveRules();
- while (query.MoveNext(out _, out _, out var nukeops, out _))
+ while (query.MoveNext(out var uid, out _, out var nukeops, out _))
{
- if (ev.Uid != nukeops.NukieShuttle)
+ if (ev.Uid != GetShuttle((uid, nukeops)))
continue;
if (nukeops.WarDeclaredTime != null)
@@ -397,12 +328,12 @@ private void OnWarDeclared(ref WarDeclaredEvent ev)
{
// TODO: this is VERY awful for multi-nukies
var query = QueryActiveRules();
- while (query.MoveNext(out _, out _, out var nukeops, out _))
+ while (query.MoveNext(out var uid, out _, out var nukeops, out _))
{
if (nukeops.WarDeclaredTime != null)
continue;
- if (Transform(ev.DeclaratorEntity).MapID != nukeops.NukiePlanet)
+ if (TryComp(uid, out var mapComp) && Transform(ev.DeclaratorEntity).MapID != mapComp.Map)
continue;
var newStatus = GetWarCondition(nukeops, ev.Status);
@@ -413,7 +344,7 @@ private void OnWarDeclared(ref WarDeclaredEvent ev)
var timeRemain = nukeops.WarNukieArriveDelay + Timing.CurTime;
ev.DeclaratorEntity.Comp.ShuttleDisabledTime = timeRemain;
- DistributeExtraTc(nukeops);
+ DistributeExtraTc((uid, nukeops));
}
}
}
@@ -440,7 +371,7 @@ public WarConditionStatus GetWarCondition(NukeopsRuleComponent nukieRule, WarCon
return WarConditionStatus.YesWar;
}
- private void DistributeExtraTc(NukeopsRuleComponent nukieRule)
+ private void DistributeExtraTc(Entity nukieRule)
{
var enumerator = EntityQueryEnumerator();
while (enumerator.MoveNext(out var uid, out var component))
@@ -448,161 +379,22 @@ private void DistributeExtraTc(NukeopsRuleComponent nukieRule)
if (!_tag.HasTag(uid, NukeOpsUplinkTagPrototype))
continue;
- if (!nukieRule.NukieOutpost.HasValue)
+ if (GetOutpost(nukieRule.Owner) is not { } outpost)
continue;
- if (Transform(uid).MapID != Transform(nukieRule.NukieOutpost.Value).MapID) // Will receive bonus TC only on their start outpost
+ if (Transform(uid).MapID != Transform(outpost).MapID) // Will receive bonus TC only on their start outpost
continue;
- _store.TryAddCurrency(new () { { TelecrystalCurrencyPrototype, nukieRule.WarTCAmountPerNukie } }, uid, component);
+ _store.TryAddCurrency(new () { { TelecrystalCurrencyPrototype, nukieRule.Comp.WarTcAmountPerNukie } }, uid, component);
var msg = Loc.GetString("store-currency-war-boost-given", ("target", uid));
_popupSystem.PopupEntity(msg, uid);
}
}
- private void OnRoundStart(EntityUid uid, NukeopsRuleComponent? component = null)
+ private void SetWinType(Entity ent, WinType type, bool endRound = true)
{
- if (!Resolve(uid, ref component))
- return;
-
- // TODO: This needs to try and target a Nanotrasen station. At the very least,
- // we can only currently guarantee that NT stations are the only station to
- // exist in the base game.
-
- var eligible = new List>();
- var eligibleQuery = EntityQueryEnumerator();
- while (eligibleQuery.MoveNext(out var eligibleUid, out var eligibleComp, out var member))
- {
- if (!_npcFaction.IsFactionHostile(component.Faction, eligibleUid, member))
- continue;
-
- eligible.Add((eligibleUid, eligibleComp, member));
- }
-
- if (eligible.Count == 0)
- return;
-
- component.TargetStation = RobustRandom.Pick(eligible);
- component.OperationName = _randomMetadata.GetRandomFromSegments([OperationPrefixDataset, OperationSuffixDataset], " ");
-
- var filter = Filter.Empty();
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out _, out var nukeops, out var actor))
- {
- NotifyNukie(actor.PlayerSession, nukeops, component);
- filter.AddPlayer(actor.PlayerSession);
- }
- }
-
- private void OnRoundEnd(EntityUid uid, NukeopsRuleComponent? component = null)
- {
- if (!Resolve(uid, ref component))
- return;
-
- // If the win condition was set to operative/crew major win, ignore.
- if (component.WinType == WinType.OpsMajor || component.WinType == WinType.CrewMajor)
- return;
-
- var nukeQuery = AllEntityQuery();
- var centcomms = _emergency.GetCentcommMaps();
-
- while (nukeQuery.MoveNext(out var nuke, out var nukeTransform))
- {
- if (nuke.Status != NukeStatus.ARMED)
- continue;
-
- // UH OH
- if (nukeTransform.MapUid != null && centcomms.Contains(nukeTransform.MapUid.Value))
- {
- component.WinConditions.Add(WinCondition.NukeActiveAtCentCom);
- SetWinType(uid, WinType.OpsMajor, component);
- return;
- }
-
- if (nukeTransform.GridUid == null || component.TargetStation == null)
- continue;
-
- if (!TryComp(component.TargetStation.Value, out StationDataComponent? data))
- continue;
-
- foreach (var grid in data.Grids)
- {
- if (grid != nukeTransform.GridUid)
- continue;
-
- component.WinConditions.Add(WinCondition.NukeActiveInStation);
- SetWinType(uid, WinType.OpsMajor, component);
- return;
- }
- }
-
- var allAlive = true;
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var nukeopsUid, out _, out var mindContainer, out var mobState))
- {
- // mind got deleted somehow so ignore it
- if (!_mind.TryGetMind(nukeopsUid, out _, out var mind, mindContainer))
- continue;
-
- // check if player got gibbed or ghosted or something - count as dead
- if (mind.OwnedEntity != null &&
- // if the player somehow isn't a mob anymore that also counts as dead
- // have to be alive, not crit or dead
- mobState.CurrentState is MobState.Alive)
- {
- continue;
- }
-
- allAlive = false;
- break;
- }
-
- // If all nuke ops were alive at the end of the round,
- // the nuke ops win. This is to prevent people from
- // running away the moment nuke ops appear.
- if (allAlive)
- {
- SetWinType(uid, WinType.OpsMinor, component);
- component.WinConditions.Add(WinCondition.AllNukiesAlive);
- return;
- }
-
- component.WinConditions.Add(WinCondition.SomeNukiesAlive);
-
- var diskAtCentCom = false;
- var diskQuery = AllEntityQuery();
-
- while (diskQuery.MoveNext(out _, out var transform))
- {
- diskAtCentCom = transform.MapUid != null && centcomms.Contains(transform.MapUid.Value);
-
- // TODO: The target station should be stored, and the nuke disk should store its original station.
- // This is fine for now, because we can assume a single station in base SS14.
- break;
- }
-
- // If the disk is currently at Central Command, the crew wins - just slightly.
- // This also implies that some nuclear operatives have died.
- if (diskAtCentCom)
- {
- SetWinType(uid, WinType.CrewMinor, component);
- component.WinConditions.Add(WinCondition.NukeDiskOnCentCom);
- }
- // Otherwise, the nuke ops win.
- else
- {
- SetWinType(uid, WinType.OpsMinor, component);
- component.WinConditions.Add(WinCondition.NukeDiskNotOnCentCom);
- }
- }
-
- private void SetWinType(EntityUid uid, WinType type, NukeopsRuleComponent? component = null, bool endRound = true)
- {
- if (!Resolve(uid, ref component))
- return;
-
- component.WinType = type;
+ ent.Comp.WinType = type;
if (endRound && (type == WinType.CrewMajor || type == WinType.OpsMajor))
_roundEndSystem.EndRound();
@@ -613,243 +405,130 @@ private void CheckRoundShouldEnd()
var query = QueryActiveRules();
while (query.MoveNext(out var uid, out _, out var nukeops, out _))
{
- if (nukeops.RoundEndBehavior == RoundEndBehavior.Nothing || nukeops.WinType == WinType.CrewMajor || nukeops.WinType == WinType.OpsMajor)
- continue;
-
- // If there are any nuclear bombs that are active, immediately return. We're not over yet.
- var armed = false;
- foreach (var nuke in EntityQuery())
- {
- if (nuke.Status == NukeStatus.ARMED)
- {
- armed = true;
- break;
- }
- }
- if (armed)
- continue;
-
- MapId? shuttleMapId = Exists(nukeops.NukieShuttle)
- ? Transform(nukeops.NukieShuttle.Value).MapID
- : null;
-
- MapId? targetStationMap = null;
- if (nukeops.TargetStation != null && TryComp(nukeops.TargetStation, out StationDataComponent? data))
- {
- var grid = data.Grids.FirstOrNull();
- targetStationMap = grid != null
- ? Transform(grid.Value).MapID
- : null;
- }
-
- // Check if there are nuke operatives still alive on the same map as the shuttle,
- // or on the same map as the station.
- // If there are, the round can continue.
- var operatives = EntityQuery(true);
- var operativesAlive = operatives
- .Where(ent =>
- ent.Item3.MapID == shuttleMapId
- || ent.Item3.MapID == targetStationMap)
- .Any(ent => ent.Item2.CurrentState == MobState.Alive && ent.Item1.Running);
-
- if (operativesAlive)
- continue; // There are living operatives than can access the shuttle, or are still on the station's map.
-
- // Check that there are spawns available and that they can access the shuttle.
- var spawnsAvailable = EntityQuery(true).Any();
- if (spawnsAvailable && shuttleMapId == nukeops.NukiePlanet)
- continue; // Ghost spawns can still access the shuttle. Continue the round.
-
- // The shuttle is inaccessible to both living nuke operatives and yet to spawn nuke operatives,
- // and there are no nuclear operatives on the target station's map.
- nukeops.WinConditions.Add(spawnsAvailable
- ? WinCondition.NukiesAbandoned
- : WinCondition.AllNukiesDead);
-
- SetWinType(uid, WinType.CrewMajor, nukeops, false);
- _roundEndSystem.DoRoundEndBehavior(
- nukeops.RoundEndBehavior, nukeops.EvacShuttleTime, nukeops.RoundEndTextSender, nukeops.RoundEndTextShuttleCall, nukeops.RoundEndTextAnnouncement);
-
- // prevent it called multiple times
- nukeops.RoundEndBehavior = RoundEndBehavior.Nothing;
- }
- }
-
- private bool SpawnMap(Entity ent)
- {
- if (!ent.Comp.SpawnOutpost
- || ent.Comp.NukiePlanet != null)
- return true;
-
- ent.Comp.NukiePlanet = _mapManager.CreateMap();
- var gameMap = _prototypeManager.Index(ent.Comp.OutpostMapPrototype);
- ent.Comp.NukieOutpost = GameTicker.LoadGameMap(gameMap, ent.Comp.NukiePlanet.Value, null)[0];
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var grid, out _, out var shuttleTransform))
- {
- if (shuttleTransform.MapID != ent.Comp.NukiePlanet)
- continue;
-
- ent.Comp.NukieShuttle = grid;
- break;
+ CheckRoundShouldEnd((uid, nukeops));
}
-
- return true;
}
- ///
- /// Adds missing nuke operative components, equips starting gear and renames the entity.
- ///
- private void SetupOperativeEntity(EntityUid mob, string name, NukeopSpawnPreset spawnDetails, HumanoidCharacterProfile? profile)
+ private void CheckRoundShouldEnd(Entity ent)
{
- _metaData.SetEntityName(mob, name);
- EnsureComp(mob);
-
- if (profile != null)
- _humanoid.LoadProfile(mob, profile);
-
- var gear = _prototypeManager.Index(spawnDetails.GearProto);
- _stationSpawning.EquipStartingGear(mob, gear, profile);
+ var nukeops = ent.Comp;
- _npcFaction.RemoveFaction(mob, "NanoTrasen", false);
- _npcFaction.AddFaction(mob, "Syndicate");
- }
-
- private void SpawnOperatives(List sessions, bool spawnGhostRoles, NukeopsRuleComponent component)
- {
- if (component.NukieOutpost is not { Valid: true } outpostUid)
+ if (nukeops.RoundEndBehavior == RoundEndBehavior.Nothing || nukeops.WinType == WinType.CrewMajor || nukeops.WinType == WinType.OpsMajor)
return;
- var spawns = new List();
- foreach (var (_, meta, xform) in EntityQuery(true))
- {
- if (meta.EntityPrototype?.ID != component.SpawnPointProto.Id)
- continue;
-
- if (xform.ParentUid != component.NukieOutpost)
- continue;
-
- spawns.Add(xform.Coordinates);
- break;
- }
- //Fallback, spawn at the centre of the map
- if (spawns.Count == 0)
+ // If there are any nuclear bombs that are active, immediately return. We're not over yet.
+ foreach (var nuke in EntityQuery