From 69f34523a36df238336d64780c915b9eb6ab18cd Mon Sep 17 00:00:00 2001 From: Remuchi <72476615+Remuchi@users.noreply.github.com> Date: Sat, 24 Aug 2024 11:45:51 +0700 Subject: [PATCH] [Fix] Antags Refactor Fix (#14) * fix: fix loadouts not spawning items inhands * Prevent SecretRule from picking invalid presets (#27456) * Prevent SecretRule from picking invalid presets * remove lonely semicolon --------- Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> * fix: fix makethief adminverb icon * fix antag selection being evil (#28197) * fix antag selection being evil * fix test * untroll the other tests * remove role timer troll * Allow tests to modify antag preferences * Fix antag selection * Misc test fixes * Add AntagPreferenceTest * Fix lazy mistakes * Test cleanup * Try stop players in lobbies from being assigned mid-round antags * ranting * I am going insane --------- Co-authored-by: deltanedas <@deltanedas:kde.org> Co-authored-by: ElectroJr # Conflicts: # Content.Server/Antag/AntagSelectionSystem.API.cs # Content.Server/Antag/AntagSelectionSystem.cs # Content.Server/Preferences/Managers/ServerPreferencesManager.cs * Fix under-selecting antags (#28327) Fix under selecting antags * fix: antag adminverbs now target actual target * fix: test --------- Co-authored-by: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> Co-authored-by: deltanedas <39013340+deltanedas@users.noreply.github.com> --- .../Pair/TestPair.Helpers.cs | 28 ++++ Content.IntegrationTests/PoolManager.cs | 4 +- .../Tests/GameRules/AntagPreferenceTest.cs | 76 +++++++++ .../Tests/GameRules/NukeOpsTest.cs | 4 + .../Click/InteractionSystemTests.cs | 1 - .../Tests/ResettingEntitySystemTests.cs | 3 - .../Systems/AdminVerbSystem.Antags.cs | 14 +- .../Antag/AntagSelectionSystem.API.cs | 35 +++- Content.Server/Antag/AntagSelectionSystem.cs | 84 +++++----- .../GameTicking/GameTicker.RoundFlow.cs | 24 ++- .../GameTicking/Rules/SecretRuleSystem.cs | 150 +++++++++++++++--- .../Managers/IServerPreferencesManager.cs | 2 + .../Managers/ServerPreferencesManager.cs | 75 ++++----- Content.Shared/Antag/AntagAcceptability.cs | 8 + Content.Shared/Roles/Jobs/SharedJobSystem.cs | 4 +- .../Station/SharedStationSpawningSystem.cs | 7 +- 16 files changed, 391 insertions(+), 128 deletions(-) create mode 100644 Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs index 0ea6d3e2dc..f46b83165f 100644 --- a/Content.IntegrationTests/Pair/TestPair.Helpers.cs +++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Content.Server.Preferences.Managers; +using Content.Shared.Preferences; +using Content.Shared.Roles; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Prototypes; @@ -128,4 +131,29 @@ public List GetPrototypesWithComponent( return list; } + + /// + /// Helper method for enabling or disabling a antag role + /// + public async Task SetAntagPref(ProtoId id, bool value) + { + var prefMan = Server.ResolveDependency(); + + var prefs = prefMan.GetPreferences(Client.User!.Value); + // what even is the point of ICharacterProfile if we always cast it to HumanoidCharacterProfile to make it usable? + var profile = (HumanoidCharacterProfile) prefs.SelectedCharacter; + + Assert.That(profile.AntagPreferences.Any(preference => preference == id), Is.EqualTo(!value)); + var newProfile = profile.WithAntagPreference(id, value); + + await Server.WaitPost(() => + { + prefMan.SetProfile(Client.User.Value, prefs.SelectedCharacterIndex, newProfile).Wait(); + }); + + // And why the fuck does it always create a new preference and profile object instead of just reusing them? + var newPrefs = prefMan.GetPreferences(Client.User.Value); + var newProf = (HumanoidCharacterProfile) newPrefs.SelectedCharacter; + Assert.That(newProf.AntagPreferences.Any(preference => preference == id), Is.EqualTo(value)); + } } diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs index b544fe2854..3f489de649 100644 --- a/Content.IntegrationTests/PoolManager.cs +++ b/Content.IntegrationTests/PoolManager.cs @@ -68,11 +68,11 @@ public static partial class PoolManager options.BeforeStart += () => { + // Server-only systems (i.e., systems that subscribe to events with server-only components) var entSysMan = IoCManager.Resolve(); - entSysMan.LoadExtraSystemType(); - entSysMan.LoadExtraSystemType(); entSysMan.LoadExtraSystemType(); entSysMan.LoadExtraSystemType(); + IoCManager.Resolve().GetSawmill("loc").Level = LogLevel.Error; IoCManager.Resolve() .OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true); diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs new file mode 100644 index 0000000000..662ea3b974 --- /dev/null +++ b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs @@ -0,0 +1,76 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Content.Server.Antag; +using Content.Server.Antag.Components; +using Content.Server.GameTicking; +using Content.Shared.GameTicking; +using Robust.Shared.GameObjects; +using Robust.Shared.Player; +using Robust.Shared.Random; + +namespace Content.IntegrationTests.Tests.GameRules; + +// Once upon a time, players in the lobby weren't ever considered eligible for antag roles. +// Lets not let that happen again. +[TestFixture] +public sealed class AntagPreferenceTest +{ + [Test] + public async Task TestLobbyPlayersValid() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + var server = pair.Server; + var client = pair.Client; + var ticker = server.System(); + var sys = server.System(); + + // Initially in the lobby + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(client.AttachedEntity, Is.Null); + Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay)); + + EntityUid uid = default; + await server.WaitPost(() => uid = server.EntMan.Spawn("Traitor")); + var rule = new Entity(uid, server.EntMan.GetComponent(uid)); + var def = rule.Comp.Definitions.Single(); + + // IsSessionValid & IsEntityValid are preference agnostic and should always be true for players in the lobby. + // Though maybe that will change in the future, but then GetPlayerPool() needs to be updated to reflect that. + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + + // By default, traitor/antag preferences are disabled, so the pool should be empty. + var sessions = new List{pair.Player!}; + var pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(0)); + + // Opt into the traitor role. + await pair.SetAntagPref("Traitor", true); + + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(1)); + pool.TryPickAndTake(pair.Server.ResolveDependency(), out var picked); + Assert.That(picked, Is.EqualTo(pair.Player)); + Assert.That(sessions.Count, Is.EqualTo(1)); + + // opt back out + await pair.SetAntagPref("Traitor", false); + + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(0)); + + await server.WaitPost(() => server.EntMan.DeleteEntity(uid)); + await pair.CleanReturnAsync(); + } +} diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs index f539daee36..62fa93c999 100644 --- a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs +++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs @@ -60,6 +60,9 @@ public async Task TryStopNukeOpsFromConstantlyFailing() Assert.That(client.AttachedEntity, Is.Null); Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay)); + // Opt into the nukies role. + await pair.SetAntagPref("NukeopsCommander", true); + // There are no grids or maps Assert.That(entMan.Count(), Is.Zero); Assert.That(entMan.Count(), Is.Zero); @@ -201,6 +204,7 @@ public async Task TryStopNukeOpsFromConstantlyFailing() //ticker.SetGamePreset((GamePresetPrototype?)null); WD edit server.CfgMan.SetCVar(CCVars.GridFill, false); + await pair.SetAntagPref("NukeopsCommander", false); await pair.CleanReturnAsync(); } } diff --git a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs index 317aa10400..4415eddf37 100644 --- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs +++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs @@ -407,7 +407,6 @@ await server.WaitAssertion(() => await pair.CleanReturnAsync(); } - [Reflect(false)] public sealed class TestInteractionSystem : EntitySystem { public EntityEventHandler? InteractUsingEvent; diff --git a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs index d5c2a9124d..40457f5488 100644 --- a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs +++ b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs @@ -9,7 +9,6 @@ namespace Content.IntegrationTests.Tests [TestOf(typeof(RoundRestartCleanupEvent))] public sealed class ResettingEntitySystemTests { - [Reflect(false)] public sealed class TestRoundRestartCleanupEvent : EntitySystem { public bool HasBeenReset { get; set; } @@ -49,8 +48,6 @@ await server.WaitAssertion(() => system.HasBeenReset = false; - Assert.That(system.HasBeenReset, Is.False); - gameTicker.RestartRound(); Assert.That(system.HasBeenReset); diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs index df77a3a1a7..4103b8a8aa 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs @@ -44,9 +44,11 @@ private void AddAntagVerbs(GetVerbsEvent args) if (!_adminManager.HasAdminFlag(player, AdminFlags.Fun)) return; - if (!HasComp(args.Target)) + if (!HasComp(args.Target) || !TryComp(args.Target, out var targetActor)) return; + var targetPlayer = targetActor.PlayerSession; + Verb traitor = new() { Text = Loc.GetString("admin-verb-text-make-traitor"), @@ -54,7 +56,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"), Act = () => { - _antag.ForceMakeAntag(player, DefaultTraitorRule); + _antag.ForceMakeAntag(targetPlayer, DefaultTraitorRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-traitor"), @@ -83,7 +85,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"), Act = () => { - _antag.ForceMakeAntag(player, DefaultNukeOpRule); + _antag.ForceMakeAntag(targetPlayer, DefaultNukeOpRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-nuclear-operative"), @@ -112,7 +114,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "HeadRevolutionary"), Act = () => { - _antag.ForceMakeAntag(player, DefaultRevsRule); + _antag.ForceMakeAntag(targetPlayer, DefaultRevsRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-head-rev"), @@ -123,10 +125,10 @@ private void AddAntagVerbs(GetVerbsEvent args) { Text = Loc.GetString("admin-verb-text-make-thief"), Category = VerbCategory.Antag, - Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/ihscombat.rsi"), "icon"), + Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/Color/black.rsi"), "icon"), Act = () => { - _antag.ForceMakeAntag(player, DefaultThiefRule); + _antag.ForceMakeAntag(targetPlayer, DefaultThiefRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-thief"), diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs index 470f98fca1..59bf05fe03 100644 --- a/Content.Server/Antag/AntagSelectionSystem.API.cs +++ b/Content.Server/Antag/AntagSelectionSystem.API.cs @@ -7,6 +7,7 @@ using Content.Shared.Mind; using JetBrains.Annotations; using Robust.Shared.Audio; +using Robust.Shared.Enums; using Robust.Shared.Player; namespace Content.Server.Antag; @@ -26,6 +27,11 @@ public bool TryGetNextAvailableDefinition(Entity ent, if (mindCount >= totalTargetCount) return false; + // TODO ANTAG fix this + // If here are two definitions with 1/10 and 10/10 slots filled, this will always return the second definition + // even though it has already met its target + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA I fucking hate game ticker code. + // It needs to track selected minds for each definition independently. foreach (var def in ent.Comp.Definitions) { var target = GetTargetAntagCount(ent, null, def); @@ -46,12 +52,26 @@ public bool TryGetNextAvailableDefinition(Entity ent, /// 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) + public int GetTargetAntagCount(Entity ent, int? playerCount = null) { var count = 0; foreach (var def in ent.Comp.Definitions) { - count += GetTargetAntagCount(ent, pool, def); + count += GetTargetAntagCount(ent, playerCount, def); + } + + return count; + } + + public int GetTotalPlayerCount(IList pool) + { + var count = 0; + foreach (var session in pool) + { + if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie) + continue; + + count++; } return count; @@ -61,9 +81,14 @@ public int GetTargetAntagCount(Entity ent, AntagSelecti /// 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) + public int GetTargetAntagCount(Entity ent, int? playerCount, AntagSelectionDefinition def) { - var poolSize = pool?.Count ?? _playerManager.Sessions.Length; + // TODO ANTAG + // make pool non-nullable + // Review uses and ensure that people are INTENTIONALLY including players in the lobby if this is a mid-round + // antag selection. + var poolSize = playerCount ?? GetTotalPlayerCount(_playerManager.Sessions); + // factor in other definitions' affect on the count. var countOffset = 0; foreach (var otherDef in ent.Comp.Definitions) @@ -278,7 +303,7 @@ public void ForceMakeAntag(ICommonSession? player, string defaultRule) where if (!TryGetNextAvailableDefinition(rule, out var def)) def = rule.Comp.Definitions.Last(); - + MakeAntag(rule, player, def.Value); } diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs index 6bfb7394f5..d74824dd2d 100644 --- a/Content.Server/Antag/AntagSelectionSystem.cs +++ b/Content.Server/Antag/AntagSelectionSystem.cs @@ -13,6 +13,7 @@ using Content.Server.Shuttles.Components; using Content.Server.Station.Systems; using Content.Shared.Antag; +using Content.Shared.GameTicking; using Content.Shared.Ghost; using Content.Shared.Humanoid; using Content.Shared.Players; @@ -24,6 +25,7 @@ using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Random; +using Robust.Shared.Utility; namespace Content.Server.Antag; @@ -82,10 +84,9 @@ private void OnPlayerSpawning(RulePlayerSpawningEvent args) continue; if (comp.SelectionsComplete) - return; + continue; ChooseAntags((uid, comp), pool); - comp.SelectionsComplete = true; foreach (var session in comp.SelectedSessions) { @@ -103,11 +104,7 @@ private void OnJobsAssigned(RulePlayerJobsAssignedEvent args) if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn) continue; - if (comp.SelectionsComplete) - continue; - - ChooseAntags((uid, comp)); - comp.SelectionsComplete = true; + ChooseAntags((uid, comp), args.Players); } } @@ -123,12 +120,18 @@ private void OnSpawnComplete(PlayerSpawnCompleteEvent args) var query = QueryActiveRules(); while (query.MoveNext(out var uid, out _, out var antag, out _)) { + // TODO ANTAG + // what why aasdiuhasdopiuasdfhksad + // stop this insanity please + // probability of antag assignment shouldn't depend on the order in which rules are returned by the query. if (!RobustRandom.Prob(LateJoinRandomChance)) continue; if (!antag.Definitions.Any(p => p.LateJoinAdditional)) continue; + DebugTools.AssertEqual(antag.SelectionTime, AntagSelectionTime.PostPlayerSpawn); + if (!TryGetNextAvailableDefinition((uid, antag), out var def)) continue; @@ -161,46 +164,43 @@ protected override void Started(EntityUid uid, AntagSelectionComponent component { base.Started(uid, component, gameRule, args); - if (component.SelectionsComplete) - return; - + // If the round has not yet started, we defer antag selection until roundstart if (GameTicker.RunLevel != GameRunLevel.InRound) return; - if (GameTicker.RunLevel == GameRunLevel.InRound && component.SelectionTime == AntagSelectionTime.PrePlayerSpawn) + if (component.SelectionsComplete) return; - ChooseAntags((uid, component)); - component.SelectionsComplete = true; - } + var players = _playerManager.Sessions + .Where(x => GameTicker.PlayerGameStatuses[x.UserId] == PlayerGameStatus.JoinedGame) + .ToList(); - /// - /// Chooses antagonists from the current selection of players - /// - public void ChooseAntags(Entity ent) - { - var sessions = _playerManager.Sessions.ToList(); - ChooseAntags(ent, sessions); + ChooseAntags((uid, component), players); } /// /// Chooses antagonists from the given selection of players /// - public void ChooseAntags(Entity ent, List pool) + public void ChooseAntags(Entity ent, IList pool) { + if (ent.Comp.SelectionsComplete) + return; + foreach (var def in ent.Comp.Definitions) { ChooseAntags(ent, pool, def); } + + ent.Comp.SelectionsComplete = true; } /// /// Chooses antagonists from the given selection of players for the given antag definition. /// - public void ChooseAntags(Entity ent, List pool, AntagSelectionDefinition def) + public void ChooseAntags(Entity ent, IList pool, AntagSelectionDefinition def) { var playerPool = GetPlayerPool(ent, pool, def); - var count = GetTargetAntagCount(ent, playerPool, def); + var count = GetTargetAntagCount(ent, GetTotalPlayerCount(pool), def); for (var i = 0; i < count; i++) { @@ -308,11 +308,7 @@ public void MakeAntag(Entity ent, ICommonSession? sessi _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); + SendBriefing(session, def.Briefing); } var afterEv = new AfterAntagEntitySelectedEvent(session, player, ent, def); @@ -322,20 +318,15 @@ public void MakeAntag(Entity ent, ICommonSession? sessi /// /// Gets an ordered player pool based on player preferences and the antagonist definition. /// - public AntagSelectionPlayerPool GetPlayerPool(Entity ent, List sessions, AntagSelectionDefinition def) + public AntagSelectionPlayerPool GetPlayerPool(Entity ent, IList sessions, AntagSelectionDefinition def) { var preferredList = new List(); - var secondBestList = new List(); - var unwantedList = new List(); - var invalidList = new List(); + var fallbackList = new List(); foreach (var session in sessions) { if (!IsSessionValid(ent, session, def) || !IsEntityValid(session.AttachedEntity, def)) - { - invalidList.Add(session); continue; - } var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter; if (def.PrefRoles.Count != 0 && pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p))) @@ -344,15 +335,11 @@ public AntagSelectionPlayerPool GetPlayerPool(Entity en } else if (def.FallbackRoles.Count != 0 && pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p))) { - secondBestList.Add(session); - } - else - { - unwantedList.Add(session); + fallbackList.Add(session); } } - return new AntagSelectionPlayerPool(new() { preferredList, secondBestList, unwantedList, invalidList }); + return new AntagSelectionPlayerPool(new() { preferredList, fallbackList }); } /// @@ -363,14 +350,18 @@ public bool IsSessionValid(Entity ent, ICommonSession? if (session == null) return true; - mind ??= session.GetMind(); - if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie) return false; if (ent.Comp.SelectedSessions.Contains(session)) return false; + mind ??= session.GetMind(); + + // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity. + if (mind == null) + return true; + //todo: we need some way to check that we're not getting the same role twice. (double picking thieves or zombies through midrounds) switch (def.MultiAntagSetting) @@ -399,10 +390,11 @@ public bool IsSessionValid(Entity ent, ICommonSession? /// /// Checks if a given entity (mind/session not included) is valid for a given antagonist. /// - private bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def) + public bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def) { + // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity. if (entity == null) - return false; + return true; if (HasComp(entity)) return false; diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 154244ace1..0fc984abed 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -175,6 +175,26 @@ public IReadOnlyList LoadGameMap(GameMapPrototype map, MapId targetMa return gridUids; } + public int ReadyPlayerCount() + { + var total = 0; + foreach (var (userId, status) in _playerGameStatuses) + { + if (LobbyEnabled && status == PlayerGameStatus.NotReadyToPlay) + continue; + + if (!_playerManager.TryGetSessionById(userId, out _)) + continue; + + if (_banManager.GetRoleBans(userId) == null) + continue; + + total++; + } + + return total; + } + public void StartRound(bool force = false) { #if EXCEPTION_TOLERANCE @@ -230,6 +250,8 @@ public void StartRound(bool force = false) readyPlayerProfiles.Add(userId, profile); } + DebugTools.AssertEqual(readyPlayers.Count, ReadyPlayerCount()); + // Just in case it hasn't been loaded previously we'll try loading it. LoadMaps(); @@ -771,7 +793,7 @@ public RulePlayerSpawningEvent(List playerPool, IReadOnlyDiction } /// - /// Event raised after players were assigned jobs by the GameTicker. + /// Event raised after players were assigned jobs by the GameTicker and have been spawned in. /// You can give on-station people special roles by listening to this event. /// public sealed class RulePlayerJobsAssignedEvent diff --git a/Content.Server/GameTicking/Rules/SecretRuleSystem.cs b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs index d5adb8fdb7..95bf5986a5 100644 --- a/Content.Server/GameTicking/Rules/SecretRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs @@ -1,15 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Content.Server.Administration.Logs; using Content.Server.GameTicking.Components; using Content.Server.Chat.Managers; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules.Components; using Content.Shared.Random; -using Content.Shared.Random.Helpers; using Content.Shared.CCVar; using Content.Shared.Database; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Configuration; +using Robust.Shared.Utility; namespace Content.Server.GameTicking.Rules; @@ -20,11 +22,46 @@ public sealed class SecretRuleSystem : GameRuleSystem [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly IComponentFactory _compFact = default!; + + private string _ruleCompName = default!; + + public override void Initialize() + { + base.Initialize(); + _ruleCompName = _compFact.GetComponentName(typeof(GameRuleComponent)); + } protected override void Added(EntityUid uid, SecretRuleComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args) { base.Added(uid, component, gameRule, args); - PickRule(component); + var weights = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); + + if (!TryPickPreset(weights, out var preset)) + { + Log.Error($"{ToPrettyString(uid)} failed to pick any preset. Removing rule."); + Del(uid); + return; + } + + Log.Info($"Selected {preset.ID} as the secret preset."); + _adminLogger.Add(LogType.EventStarted, $"Selected {preset.ID} as the secret preset."); + _chatManager.SendAdminAnnouncement(Loc.GetString("rule-secret-selected-preset", ("preset", preset.ID))); + + foreach (var rule in preset.Rules) + { + EntityUid ruleEnt; + + // if we're pre-round (i.e. will only be added) + // then just add rules. if we're added in the middle of the round (or at any other point really) + // then we want to start them as well + if (GameTicker.RunLevel <= GameRunLevel.InRound) + ruleEnt = GameTicker.AddGameRule(rule); + else + GameTicker.StartGameRule(rule, out ruleEnt); + + component.AdditionalGameRules.Add(ruleEnt); + } } protected override void Ended(EntityUid uid, SecretRuleComponent component, GameRuleComponent gameRule, GameRuleEndedEvent args) @@ -37,32 +74,101 @@ protected override void Ended(EntityUid uid, SecretRuleComponent component, Game } } - private void PickRule(SecretRuleComponent component) + private bool TryPickPreset(ProtoId weights, [NotNullWhen(true)] out GamePresetPrototype? preset) { - // TODO: This doesn't consider what can't start due to minimum player count, - // but currently there's no way to know anyway as they use cvars. - var presetString = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); - var preset = _prototypeManager.Index(presetString).Pick(_random); - Log.Info($"Selected {preset} for secret."); - _adminLogger.Add(LogType.EventStarted, $"Selected {preset} for secret."); - _chatManager.SendAdminAnnouncement(Loc.GetString("rule-secret-selected-preset", ("preset", preset))); - - var rules = _prototypeManager.Index(preset).Rules; - foreach (var rule in rules) + var options = _prototypeManager.Index(weights).Weights.ShallowClone(); + var players = GameTicker.ReadyPlayerCount(); + + GamePresetPrototype? selectedPreset = null; + var sum = options.Values.Sum(); + while (options.Count > 0) { - EntityUid ruleEnt; + var accumulated = 0f; + var rand = _random.NextFloat(sum); + foreach (var (key, weight) in options) + { + accumulated += weight; + if (accumulated < rand) + continue; - // if we're pre-round (i.e. will only be added) - // then just add rules. if we're added in the middle of the round (or at any other point really) - // then we want to start them as well - if (GameTicker.RunLevel <= GameRunLevel.InRound) - ruleEnt = GameTicker.AddGameRule(rule); - else + if (!_prototypeManager.TryIndex(key, out selectedPreset)) + Log.Error($"Invalid preset {selectedPreset} in secret rule weights: {weights}"); + + options.Remove(key); + sum -= weight; + break; + } + + if (CanPick(selectedPreset, players)) { - GameTicker.StartGameRule(rule, out ruleEnt); + preset = selectedPreset; + return true; } - component.AdditionalGameRules.Add(ruleEnt); + if (selectedPreset != null) + Log.Info($"Excluding {selectedPreset.ID} from secret preset selection."); + } + + preset = null; + return false; + } + + public bool CanPickAny() + { + var secretPresetId = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); + return CanPickAny(secretPresetId); + } + + /// + /// Can any of the given presets be picked, taking into account the currently available player count? + /// + public bool CanPickAny(ProtoId weightedPresets) + { + var ids = _prototypeManager.Index(weightedPresets).Weights.Keys + .Select(x => new ProtoId(x)); + + return CanPickAny(ids); + } + + /// + /// Can any of the given presets be picked, taking into account the currently available player count? + /// + public bool CanPickAny(IEnumerable> protos) + { + var players = GameTicker.ReadyPlayerCount(); + foreach (var id in protos) + { + if (!_prototypeManager.TryIndex(id, out var selectedPreset)) + Log.Error($"Invalid preset {selectedPreset} in secret rule weights: {id}"); + + if (CanPick(selectedPreset, players)) + return true; } + + return false; + } + + /// + /// Can the given preset be picked, taking into account the currently available player count? + /// + private bool CanPick([NotNullWhen(true)] GamePresetPrototype? selected, int players) + { + if (selected == null) + return false; + + foreach (var ruleId in selected.Rules) + { + if (!_prototypeManager.TryIndex(ruleId, out EntityPrototype? rule) + || !rule.TryGetComponent(_ruleCompName, out GameRuleComponent? ruleComp)) + { + Log.Error($"Encountered invalid rule {ruleId} in preset {selected.ID}"); + return false; + } + + if (ruleComp.MinPlayers > players) + return false; + } + + return true; } } diff --git a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs index 1808592ef5..f26c998495 100644 --- a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs @@ -19,5 +19,7 @@ public interface IServerPreferencesManager PlayerPreferences? GetPreferencesOrNull(NetUserId? userId); IEnumerable> GetSelectedProfilesForPlayers(List userIds); bool HavePreferencesLoaded(ICommonSession session); + + Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile); } } diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs index c3efe14be9..95eb9591fb 100644 --- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs @@ -3,17 +3,12 @@ using System.Threading; using System.Threading.Tasks; using Content.Server.Database; -using Content.Server.Humanoid; using Content.Shared.CCVar; -using Content.Shared.Humanoid.Prototypes; using Content.Shared.Preferences; -using Content.Shared.Roles; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; -using Robust.Shared.Prototypes; - namespace Content.Server.Preferences.Managers { @@ -27,12 +22,14 @@ public sealed class ServerPreferencesManager : IServerPreferencesManager [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IPrototypeManager _protos = default!; + [Dependency] private readonly ILogManager _log = default!; // Cache player prefs on the server so we don't need as much async hell related to them. private readonly Dictionary _cachedPlayerPrefs = new(); + private ISawmill _sawmill = default!; + private int MaxCharacterSlots => _cfg.GetCVar(CCVars.GameMaxCharacterSlots); public void Init() @@ -41,6 +38,7 @@ public void Init() _netManager.RegisterNetMessage(HandleSelectCharacterMessage); _netManager.RegisterNetMessage(HandleUpdateCharacterMessage); _netManager.RegisterNetMessage(HandleDeleteCharacterMessage); + _sawmill = _log.GetSawmill("prefs"); } private async void HandleSelectCharacterMessage(MsgSelectCharacter message) @@ -77,27 +75,25 @@ private async void HandleSelectCharacterMessage(MsgSelectCharacter message) private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message) { - var slot = message.Slot; - var profile = message.Profile; var userId = message.MsgChannel.UserId; - if (profile == null) - { - Logger.WarningS("prefs", - $"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {slot}."); - return; - } + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (message.Profile == null) + _sawmill.Error($"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {message.Slot}."); + else + await SetProfile(userId, message.Slot, message.Profile); + } + public async Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile) + { if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded) { - Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded."); + _sawmill.Error($"Tried to modify user {userId} preferences before they loaded."); return; } if (slot < 0 || slot >= MaxCharacterSlots) - { return; - } var curPrefs = prefsData.Prefs!; var session = _playerManager.GetSessionById(userId); @@ -112,10 +108,8 @@ private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message) prefsData.Prefs = new PlayerPreferences(profiles, slot, curPrefs.AdminOOCColor); - if (ShouldStorePrefs(message.MsgChannel.AuthType)) - { - await _db.SaveCharacterSlotAsync(message.MsgChannel.UserId, message.Profile, message.Slot); - } + if (ShouldStorePrefs(session.Channel.AuthType)) + await _db.SaveCharacterSlotAsync(userId, profile, slot); } private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) @@ -142,7 +136,7 @@ private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) if (curPrefs.SelectedCharacterIndex == slot) { // That ! on the end is because Rider doesn't like .NET 5. - var (ns, profile) = curPrefs.Characters.FirstOrDefault(p => p.Key != message.Slot)!; + var (ns, profile) = curPrefs.Characters.FirstOrDefault(p => p.Key != message.Slot); if (profile == null) { // Only slot left, can't delete. @@ -157,16 +151,18 @@ private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) prefsData.Prefs = new PlayerPreferences(arr, nextSlot ?? curPrefs.SelectedCharacterIndex, curPrefs.AdminOOCColor); - if (ShouldStorePrefs(message.MsgChannel.AuthType)) + if (!ShouldStorePrefs(message.MsgChannel.AuthType)) { - if (nextSlot != null) - { - await _db.DeleteSlotAndSetSelectedIndex(userId, slot, nextSlot.Value); - } - else - { - await _db.SaveCharacterSlotAsync(userId, null, slot); - } + return; + } + + if (nextSlot != null) + { + await _db.DeleteSlotAndSetSelectedIndex(userId, slot, nextSlot.Value); + } + else + { + await _db.SaveCharacterSlotAsync(userId, null, slot); } } @@ -200,11 +196,13 @@ async Task LoadPrefs() prefsData.Prefs = prefs; prefsData.PrefsLoaded = true; - var msg = new MsgPreferencesAndSettings(); - msg.Preferences = prefs; - msg.Settings = new GameSettings + var msg = new MsgPreferencesAndSettings { - MaxCharacterSlots = MaxCharacterSlots + Preferences = prefs, + Settings = new GameSettings + { + MaxCharacterSlots = MaxCharacterSlots + } }; _netManager.ServerSendMessage(msg, session.Channel); } @@ -221,7 +219,6 @@ public bool HavePreferencesLoaded(ICommonSession session) return _cachedPlayerPrefs.ContainsKey(session.UserId); } - /// /// Tries to get the preferences from the cache /// @@ -289,10 +286,8 @@ private PlayerPreferences SanitizePreferences(ICommonSession session, PlayerPref { // Clean up preferences in case of changes to the game, // such as removed jobs still being selected. - return new PlayerPreferences(prefs.Characters.Select(p => - { - return new KeyValuePair(p.Key, p.Value.Validated(session, collection)); - }), prefs.SelectedCharacterIndex, prefs.AdminOOCColor); + return new PlayerPreferences(prefs.Characters.Select(p => new KeyValuePair(p.Key, + p.Value.Validated(session, collection))), prefs.SelectedCharacterIndex, prefs.AdminOOCColor); } public IEnumerable> GetSelectedProfilesForPlayers( diff --git a/Content.Shared/Antag/AntagAcceptability.cs b/Content.Shared/Antag/AntagAcceptability.cs index 02d0b5f58f..f56be97503 100644 --- a/Content.Shared/Antag/AntagAcceptability.cs +++ b/Content.Shared/Antag/AntagAcceptability.cs @@ -22,6 +22,14 @@ public enum AntagAcceptability public enum AntagSelectionTime : byte { + /// + /// Antag roles are assigned before players are assigned jobs and spawned in. + /// This prevents antag selection from happening if the round is on-going. + /// PrePlayerSpawn, + + /// + /// Antag roles get assigned after players have been assigned jobs and have spawned in. + /// PostPlayerSpawn } diff --git a/Content.Shared/Roles/Jobs/SharedJobSystem.cs b/Content.Shared/Roles/Jobs/SharedJobSystem.cs index 04ac45c4c5..fcf7605278 100644 --- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs +++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs @@ -146,8 +146,10 @@ public string MindTryGetJobName([NotNullWhen(true)] EntityUid? mindId) public bool CanBeAntag(ICommonSession player) { + // If the player does not have any mind associated with them (e.g., has not spawned in or is in the lobby), then + // they are eligible to be given an antag role/entity. if (_playerSystem.ContentData(player) is not { Mind: { } mindId }) - return false; + return true; if (!MindTryGetJob(mindId, out _, out var prototype)) return true; diff --git a/Content.Shared/Station/SharedStationSpawningSystem.cs b/Content.Shared/Station/SharedStationSpawningSystem.cs index ea0898824b..9e93318103 100644 --- a/Content.Shared/Station/SharedStationSpawningSystem.cs +++ b/Content.Shared/Station/SharedStationSpawningSystem.cs @@ -52,7 +52,6 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG } if (TryComp(entity, out HandsComponent? handsComponent)) - return; { var inhand = startingGear.Inhand; var coords = EntityManager.GetComponent(entity).Coordinates; @@ -61,8 +60,10 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG var inhandEntity = EntityManager.SpawnEntity(prototype, coords); if (_handsSystem.TryGetEmptyHand(entity, out var emptyHand, handsComponent)) + { _handsSystem.TryPickup(entity, inhandEntity, emptyHand, checkActionBlocker: false, handsComp: handsComponent); + } } } @@ -78,7 +79,9 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG continue; foreach (var ent in entProtos) + { ents.Add(Spawn(ent, coords)); + } if (inventoryComp == null || !InventorySystem.TryGetSlotEntity(entity, slot, out var slotEnt, @@ -87,7 +90,9 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG continue; foreach (var ent in ents) + { _storage.Insert(slotEnt.Value, ent, out _, storageComp: storage, playSound: false); + } } } }