diff --git a/Content.Client/Administration/UI/BanPanel/BanPanel.xaml.cs b/Content.Client/Administration/UI/BanPanel/BanPanel.xaml.cs index c7aed30c1c..d20c741673 100644 --- a/Content.Client/Administration/UI/BanPanel/BanPanel.xaml.cs +++ b/Content.Client/Administration/UI/BanPanel/BanPanel.xaml.cs @@ -24,7 +24,7 @@ namespace Content.Client.Administration.UI.BanPanel; [GenerateTypedNameReferences] public sealed partial class BanPanel : DefaultWindow { - public event Action? BanSubmitted; + public event Action? BanSubmitted; public event Action? PlayerChanged; private string? PlayerUsername { get; set; } private (IPAddress, int)? IpAddress { get; set; } @@ -37,8 +37,8 @@ public sealed partial class BanPanel : DefaultWindow // This is less efficient than just holding a reference to the root control and enumerating children, but you // have to know how the controls are nested, which makes the code more complicated. // Role group name -> the role buttons themselves. - private readonly Dictionary> _roleCheckboxes = new(); - private readonly ISawmill _banpanelSawmill; + private readonly Dictionary> _roleCheckboxes = new(); + private readonly ISawmill _banPanelSawmill; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; @@ -79,7 +79,7 @@ public sealed partial class BanPanel : DefaultWindow { RobustXamlLoader.Load(this); IoCManager.InjectDependencies(this); - _banpanelSawmill = _logManager.GetSawmill("admin.banpanel"); + _banPanelSawmill = _logManager.GetSawmill("admin.banpanel"); PlayerList.OnSelectionChanged += OnPlayerSelectionChanged; PlayerNameLine.OnFocusExit += _ => OnPlayerNameChanged(); PlayerCheckbox.OnPressed += _ => @@ -110,7 +110,7 @@ public sealed partial class BanPanel : DefaultWindow TypeOption.SelectId(args.Id); OnTypeChanged(); }; - LastConnCheckbox.OnPressed += args => + LastConnCheckbox.OnPressed += _ => { IpLine.ModulateSelfOverride = null; HwidLine.ModulateSelfOverride = null; @@ -164,7 +164,7 @@ public sealed partial class BanPanel : DefaultWindow var antagRoles = _protoMan.EnumeratePrototypes() .OrderBy(x => x.ID); - CreateRoleGroup("Antagonist", Color.Red, antagRoles); + CreateRoleGroup(AntagPrototype.GroupName, AntagPrototype.GroupColor, antagRoles); } /// @@ -236,14 +236,14 @@ public sealed partial class BanPanel : DefaultWindow { foreach (var role in _roleCheckboxes[groupName]) { - role.Pressed = args.Pressed; + role.Item1.Pressed = args.Pressed; } if (args.Pressed) { if (!Enum.TryParse(_cfg.GetCVar(CCVars.DepartmentBanDefaultSeverity), true, out NoteSeverity newSeverity)) { - _banpanelSawmill + _banPanelSawmill .Warning("Departmental role ban severity could not be parsed from config!"); return; } @@ -255,14 +255,14 @@ public sealed partial class BanPanel : DefaultWindow { foreach (var button in roleButtons) { - if (button.Pressed) + if (button.Item1.Pressed) return; } } if (!Enum.TryParse(_cfg.GetCVar(CCVars.RoleBanDefaultSeverity), true, out NoteSeverity newSeverity)) { - _banpanelSawmill + _banPanelSawmill .Warning("Role ban severity could not be parsed from config!"); return; } @@ -294,7 +294,7 @@ public sealed partial class BanPanel : DefaultWindow } /// - /// Adds a checkbutton specifically for one "role" in a "group" + /// Adds a check button specifically for one "role" in a "group" /// E.g. it would add the Chief Medical Officer "role" into the "Medical" group. /// private void AddRoleCheckbox(string group, string role, GridContainer roleGroupInnerContainer, Button roleGroupCheckbox) @@ -302,23 +302,36 @@ public sealed partial class BanPanel : DefaultWindow var roleCheckboxContainer = new BoxContainer(); var roleCheckButton = new Button { - Name = $"{role}RoleCheckbox", + Name = role, Text = role, ToggleMode = true, }; roleCheckButton.OnToggled += args => { // Checks the role group checkbox if all the children are pressed - if (args.Pressed && _roleCheckboxes[group].All(e => e.Pressed)) + if (args.Pressed && _roleCheckboxes[group].All(e => e.Item1.Pressed)) roleGroupCheckbox.Pressed = args.Pressed; else roleGroupCheckbox.Pressed = false; }; + IPrototype rolePrototype; + + if (_protoMan.TryIndex(role, out var jobPrototype)) + rolePrototype = jobPrototype; + else if (_protoMan.TryIndex(role, out var antagPrototype)) + rolePrototype = antagPrototype; + else + { + _banPanelSawmill.Error($"Adding a role checkbox for role {role}: role is not a JobPrototype or AntagPrototype."); + + return; + } + // This is adding the icon before the role name // TODO: This should not be using raw strings for prototypes as it means it won't be validated at all. - // I know the ban manager is doing the same thing, but that should not leak into UI code. - if (_protoMan.TryIndex(role, out var jobPrototype) && _protoMan.Resolve(jobPrototype.Icon, out var iconProto)) + // // I know the ban manager is doing the same thing, but that should not leak into UI code. + if (jobPrototype is not null && _protoMan.TryIndex(jobPrototype.Icon, out var iconProto)) { var jobIconTexture = new TextureRect { @@ -335,7 +348,7 @@ public sealed partial class BanPanel : DefaultWindow roleGroupInnerContainer.AddChild(roleCheckboxContainer); _roleCheckboxes.TryAdd(group, []); - _roleCheckboxes[group].Add(roleCheckButton); + _roleCheckboxes[group].Add((roleCheckButton, rolePrototype)); } public void UpdateBanFlag(bool newFlag) @@ -488,7 +501,7 @@ public sealed partial class BanPanel : DefaultWindow newSeverity = serverSeverity; else { - _banpanelSawmill + _banPanelSawmill .Warning("Server ban severity could not be parsed from config!"); } @@ -501,7 +514,7 @@ public sealed partial class BanPanel : DefaultWindow } else { - _banpanelSawmill + _banPanelSawmill .Warning("Role ban severity could not be parsed from config!"); } break; @@ -546,34 +559,51 @@ public sealed partial class BanPanel : DefaultWindow private void SubmitButtonOnOnPressed(BaseButton.ButtonEventArgs obj) { - string[]? roles = null; + ProtoId[]? jobs = null; + ProtoId[]? antags = null; + if (TypeOption.SelectedId == (int) Types.Role) { - var rolesList = new List(); + var jobList = new List>(); + var antagList = new List>(); + if (_roleCheckboxes.Count == 0) throw new DebugAssertException("RoleCheckboxes was empty"); foreach (var button in _roleCheckboxes.Values.SelectMany(departmentButtons => departmentButtons)) { - if (button is { Pressed: true, Text: not null }) + if (button.Item1 is { Pressed: true, Name: not null }) { - rolesList.Add(button.Text); + switch (button.Item2) + { + case JobPrototype: + jobList.Add(button.Item2.ID); + + break; + case AntagPrototype: + antagList.Add(button.Item2.ID); + + break; + } } } - if (rolesList.Count == 0) + if (jobList.Count + antagList.Count == 0) { Tabs.CurrentTab = (int) TabNumbers.Roles; + return; } - roles = rolesList.ToArray(); + jobs = jobList.ToArray(); + antags = antagList.ToArray(); } if (TypeOption.SelectedId == (int) Types.None) { TypeOption.ModulateSelfOverride = Color.Red; Tabs.CurrentTab = (int) TabNumbers.BasicInfo; + return; } @@ -585,6 +615,7 @@ public sealed partial class BanPanel : DefaultWindow ReasonTextEdit.GrabKeyboardFocus(); ReasonTextEdit.ModulateSelfOverride = Color.Red; ReasonTextEdit.OnKeyBindDown += ResetTextEditor; + return; } @@ -593,6 +624,7 @@ public sealed partial class BanPanel : DefaultWindow ButtonResetOn = _gameTiming.CurTime.Add(TimeSpan.FromSeconds(3)); SubmitButton.ModulateSelfOverride = Color.Red; SubmitButton.Text = Loc.GetString("ban-panel-confirm"); + return; } @@ -601,7 +633,22 @@ public sealed partial class BanPanel : DefaultWindow var useLastHwid = HwidCheckbox.Pressed && LastConnCheckbox.Pressed && Hwid is null; var severity = (NoteSeverity) SeverityOption.SelectedId; var erase = EraseCheckbox.Pressed; - BanSubmitted?.Invoke(player, IpAddress, useLastIp, Hwid, useLastHwid, (uint) (TimeEntered * Multiplier), reason, severity, roles, erase); + + var ban = new Ban( + player, + IpAddress, + useLastIp, + Hwid, + useLastHwid, + (uint)(TimeEntered * Multiplier), + reason, + severity, + jobs, + antags, + erase + ); + + BanSubmitted?.Invoke(ban); } protected override void FrameUpdate(FrameEventArgs args) diff --git a/Content.Client/Administration/UI/BanPanel/BanPanelEui.cs b/Content.Client/Administration/UI/BanPanel/BanPanelEui.cs index 940a55e010..ac17576361 100644 --- a/Content.Client/Administration/UI/BanPanel/BanPanelEui.cs +++ b/Content.Client/Administration/UI/BanPanel/BanPanelEui.cs @@ -14,8 +14,7 @@ public sealed class BanPanelEui : BaseEui { BanPanel = new BanPanel(); BanPanel.OnClose += () => SendMessage(new CloseEuiMessage()); - BanPanel.BanSubmitted += (player, ip, useLastIp, hwid, useLastHwid, minutes, reason, severity, roles, erase) - => SendMessage(new BanPanelEuiStateMsg.CreateBanRequest(player, ip, useLastIp, hwid, useLastHwid, minutes, reason, severity, roles, erase)); + BanPanel.BanSubmitted += ban => SendMessage(new BanPanelEuiStateMsg.CreateBanRequest(ban)); BanPanel.PlayerChanged += player => SendMessage(new BanPanelEuiStateMsg.GetPlayerInfoRequest(player)); } diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs index dfdfece979..609b633fe4 100644 --- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs +++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs @@ -660,8 +660,10 @@ namespace Content.Client.Lobby.UI selector.Setup(items, title, 250, description, guides: antag.Guides); selector.Select(Profile?.AntagPreferences.Contains(antag.ID) == true ? 0 : 1); - var requirements = _entManager.System().GetAntagRequirement(antag); - if (!_requirements.CheckRoleRequirements(requirements, (HumanoidCharacterProfile?)_preferencesManager.Preferences?.SelectedCharacter, out var reason)) + if (!_requirements.IsAllowed( + antag, + (HumanoidCharacterProfile?)_preferencesManager.Preferences?.SelectedCharacter, + out var reason)) { selector.LockRequirements(reason); Profile = Profile?.WithAntagPreference(antag.ID, false); diff --git a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs index 314b59eda9..d085d9005c 100644 --- a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs +++ b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using Content.Client.Lobby; using Content.Shared.CCVar; using Content.Shared.Players; using Content.Shared.Players.JobWhitelist; @@ -26,7 +25,8 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager [Dependency] private readonly IPrototypeManager _prototypes = default!; private readonly Dictionary _roles = new(); - private readonly List _roleBans = new(); + private readonly List _jobBans = new(); + private readonly List _antagBans = new(); private readonly List _jobWhitelists = new(); private ISawmill _sawmill = default!; @@ -52,16 +52,19 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager // Reset on disconnect, just in case. _roles.Clear(); _jobWhitelists.Clear(); - _roleBans.Clear(); + _jobBans.Clear(); + _antagBans.Clear(); } } private void RxRoleBans(MsgRoleBans message) { - _sawmill.Debug($"Received roleban info containing {message.Bans.Count} entries."); + _sawmill.Debug($"Received role ban info: {message.JobBans.Count} job ban entries and {message.AntagBans.Count} antag ban entries."); - _roleBans.Clear(); - _roleBans.AddRange(message.Bans); + _jobBans.Clear(); + _jobBans.AddRange(message.JobBans); + _antagBans.Clear(); + _antagBans.AddRange(message.AntagBans); Updated?.Invoke(); } @@ -90,33 +93,97 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager Updated?.Invoke(); } - public bool IsAllowed(JobPrototype job, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason) + /// + /// Check a list of job- and antag prototypes against the current player, for requirements and bans. + /// + /// + /// False if any of the prototypes are banned or have unmet requirements. + /// > + public bool IsAllowed( + List>? jobs, + List>? antags, + HumanoidCharacterProfile? profile, + [NotNullWhen(false)] out FormattedMessage? reason) { reason = null; - if (_roleBans.Contains($"Job:{job.ID}")) + if (antags is not null) + { + foreach (var proto in antags) + { + if (!IsAllowed(_prototypes.Index(proto), profile, out reason)) + return false; + } + } + + if (jobs is not null) + { + foreach (var proto in jobs) + { + if (!IsAllowed(_prototypes.Index(proto), profile, out reason)) + return false; + } + } + + return true; + } + + /// + /// Check the job prototype against the current player, for requirements and bans + /// + public bool IsAllowed( + JobPrototype job, + HumanoidCharacterProfile? profile, + [NotNullWhen(false)] out FormattedMessage? reason) + { + // Check the player's bans + if (_jobBans.Contains(job.ID)) { reason = FormattedMessage.FromUnformatted(Loc.GetString("role-ban")); return false; } + // Check whitelist requirements if (!CheckWhitelist(job, out reason)) return false; - var player = _playerManager.LocalSession; - if (player == null) - return true; + // Check other role requirements + var reqs = _entManager.System().GetRoleRequirements(job); + if (!CheckRoleRequirements(reqs, profile, out reason)) + return false; - return CheckRoleRequirements(job, profile, out reason); + return true; } - public bool CheckRoleRequirements(JobPrototype job, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason) + /// + /// Check the antag prototype against the current player, for requirements and bans + /// + public bool IsAllowed( + AntagPrototype antag, + HumanoidCharacterProfile? profile, + [NotNullWhen(false)] out FormattedMessage? reason) { - var reqs = _entManager.System().GetJobRequirement(job); - return CheckRoleRequirements(reqs, profile, out reason); + // Check the player's bans + if (_antagBans.Contains(antag.ID)) + { + reason = FormattedMessage.FromUnformatted(Loc.GetString("role-ban")); + return false; + } + + // Check whitelist requirements + if (!CheckWhitelist(antag, out reason)) + return false; + + // Check other role requirements + var reqs = _entManager.System().GetRoleRequirements(antag); + if (!CheckRoleRequirements(reqs, profile, out reason)) + return false; + + return true; } - public bool CheckRoleRequirements(HashSet? requirements, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason) + // This must be private so code paths can't accidentally skip requirement overrides. Call this through IsAllowed() + private bool CheckRoleRequirements(HashSet? requirements, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason) { reason = null; @@ -151,6 +218,15 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager return true; } + public bool CheckWhitelist(AntagPrototype antag, [NotNullWhen(false)] out FormattedMessage? reason) + { + reason = default; + + // TODO: Implement antag whitelisting. + + return true; + } + public TimeSpan FetchOverallPlaytime() { return _roles.TryGetValue("Overall", out var overallPlaytime) ? overallPlaytime : TimeSpan.Zero; diff --git a/Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEui.cs b/Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEui.cs index 1cf1e55103..86dd6d2092 100644 --- a/Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEui.cs +++ b/Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEui.cs @@ -90,23 +90,25 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles var spriteSystem = sysManager.GetEntitySystem(); var requirementsManager = IoCManager.Resolve(); - // TODO: role.Requirements value doesn't work at all as an equality key, this must be fixed // Grouping roles var groupedRoles = ghostState.GhostRoles.GroupBy( - role => (role.Name, role.Description, role.Requirements)); + role => ( + role.Name, + role.Description, + // Check the prototypes for role requirements and bans + requirementsManager.IsAllowed(role.RolePrototypes.Item1, role.RolePrototypes.Item2, null, out var reason), + reason)); // Add a new entry for each role group foreach (var group in groupedRoles) { + var reason = group.Key.reason; var name = group.Key.Name; var description = group.Key.Description; - var hasAccess = requirementsManager.CheckRoleRequirements( - group.Key.Requirements, - null, - out var reason); + var prototypesAllowed = group.Key.Item3; // Adding a new role - _window.AddEntry(name, description, hasAccess, reason, group, spriteSystem); + _window.AddEntry(name, description, prototypesAllowed, reason, group, spriteSystem); } // Restore the Collapsible box state if it is saved diff --git a/Content.Server/Administration/BanPanelEui.cs b/Content.Server/Administration/BanPanelEui.cs index 0a09ad557f..4a4b721872 100644 --- a/Content.Server/Administration/BanPanelEui.cs +++ b/Content.Server/Administration/BanPanelEui.cs @@ -7,9 +7,7 @@ using Content.Server.EUI; using Content.Shared.Administration; using Content.Shared.Database; using Content.Shared.Eui; -using Content.Shared.Roles; using Robust.Shared.Network; -using Robust.Shared.Prototypes; namespace Content.Server.Administration; @@ -21,7 +19,6 @@ public sealed class BanPanelEui : BaseEui [Dependency] private readonly IPlayerLocator _playerLocator = default!; [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly IAdminManager _admins = default!; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; private readonly ISawmill _sawmill; @@ -52,7 +49,7 @@ public sealed class BanPanelEui : BaseEui switch (msg) { case BanPanelEuiStateMsg.CreateBanRequest r: - BanPlayer(r.Player, r.IpAddress, r.UseLastIp, r.Hwid, r.UseLastHwid, r.Minutes, r.Severity, r.Reason, r.Roles, r.Erase); + BanPlayer(r.Ban); break; case BanPanelEuiStateMsg.GetPlayerInfoRequest r: ChangePlayer(r.PlayerUsername); @@ -60,29 +57,26 @@ public sealed class BanPanelEui : BaseEui } } - private async void BanPlayer(string? target, string? ipAddressString, bool useLastIp, ImmutableTypedHwid? hwid, bool useLastHwid, uint minutes, NoteSeverity severity, string reason, IReadOnlyCollection? roles, bool erase) + private async void BanPlayer(Ban ban) { if (!_admins.HasAdminFlag(Player, AdminFlags.Ban)) { _sawmill.Warning($"{Player.Name} ({Player.UserId}) tried to create a ban with no ban flag"); + return; } - if (target == null && string.IsNullOrWhiteSpace(ipAddressString) && hwid == null) + + if (ban.Target == null && string.IsNullOrWhiteSpace(ban.IpAddress) && ban.Hwid == null) { _chat.DispatchServerMessage(Player, Loc.GetString("ban-panel-no-data")); + return; } (IPAddress, int)? addressRange = null; - if (ipAddressString is not null) + if (ban.IpAddress is not null) { - var hid = "0"; - var split = ipAddressString.Split('/', 2); - ipAddressString = split[0]; - if (split.Length > 1) - hid = split[1]; - - if (!IPAddress.TryParse(ipAddressString, out var ipAddress) || !uint.TryParse(hid, out var hidInt) || hidInt > Ipv6_CIDR || hidInt > Ipv4_CIDR && ipAddress.AddressFamily == AddressFamily.InterNetwork) + if (!IPAddress.TryParse(ban.IpAddress, out var ipAddress) || !uint.TryParse(ban.IpAddressHid, out var hidInt) || hidInt > Ipv6_CIDR || hidInt > Ipv4_CIDR && ipAddress.AddressFamily == AddressFamily.InterNetwork) { _chat.DispatchServerMessage(Player, Loc.GetString("ban-panel-invalid-ip")); return; @@ -94,12 +88,12 @@ public sealed class BanPanelEui : BaseEui addressRange = (ipAddress, (int) hidInt); } - var targetUid = target is not null ? PlayerId : null; - addressRange = useLastIp && LastAddress is not null ? (LastAddress, LastAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR) : addressRange; - var targetHWid = useLastHwid ? LastHwid : hwid; - if (target != null && target != PlayerName || Guid.TryParse(target, out var parsed) && parsed != PlayerId) + var targetUid = ban.Target is not null ? PlayerId : null; + addressRange = ban.UseLastIp && LastAddress is not null ? (LastAddress, LastAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR) : addressRange; + var targetHWid = ban.UseLastHwid ? LastHwid : ban.Hwid; + if (ban.Target != null && ban.Target != PlayerName || Guid.TryParse(ban.Target, out var parsed) && parsed != PlayerId) { - var located = await _playerLocator.LookupIdByNameOrIdAsync(target); + var located = await _playerLocator.LookupIdByNameOrIdAsync(ban.Target); if (located == null) { _chat.DispatchServerMessage(Player, Loc.GetString("cmd-ban-player")); @@ -107,7 +101,7 @@ public sealed class BanPanelEui : BaseEui } targetUid = located.UserId; var targetAddress = located.LastAddress; - if (useLastIp && targetAddress != null) + if (ban.UseLastIp && targetAddress != null) { if (targetAddress.IsIPv4MappedToIPv6) targetAddress = targetAddress.MapToIPv4(); @@ -116,30 +110,50 @@ public sealed class BanPanelEui : BaseEui var hid = targetAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR; addressRange = (targetAddress, hid); } - targetHWid = useLastHwid ? located.LastHWId : hwid; + targetHWid = ban.UseLastHwid ? located.LastHWId : ban.Hwid; } - if (roles?.Count > 0) + if (ban.BannedJobs?.Length > 0 || ban.BannedAntags?.Length > 0) { var now = DateTimeOffset.UtcNow; - foreach (var role in roles) + foreach (var role in ban.BannedJobs ?? []) { - if (_prototypeManager.HasIndex(role)) - { - _banManager.CreateRoleBan(targetUid, target, Player.UserId, addressRange, targetHWid, role, minutes, severity, reason, now); - } - else - { - _sawmill.Warning($"{Player.Name} ({Player.UserId}) tried to issue a job ban with an invalid job: {role}"); - } + _banManager.CreateRoleBan( + targetUid, + ban.Target, + Player.UserId, + addressRange, + targetHWid, + role, + ban.BanDurationMinutes, + ban.Severity, + ban.Reason, + now + ); + } + + foreach (var role in ban.BannedAntags ?? []) + { + _banManager.CreateRoleBan( + targetUid, + ban.Target, + Player.UserId, + addressRange, + targetHWid, + role, + ban.BanDurationMinutes, + ban.Severity, + ban.Reason, + now + ); } Close(); + return; } - if (erase && - targetUid != null) + if (ban.Erase && targetUid is not null) { try { @@ -152,7 +166,16 @@ public sealed class BanPanelEui : BaseEui } } - _banManager.CreateServerBan(targetUid, target, Player.UserId, addressRange, targetHWid, minutes, severity, reason); + _banManager.CreateServerBan( + targetUid, + ban.Target, + Player.UserId, + addressRange, + targetHWid, + ban.BanDurationMinutes, + ban.Severity, + ban.Reason + ); Close(); } diff --git a/Content.Server/Administration/Commands/RoleBanCommand.cs b/Content.Server/Administration/Commands/RoleBanCommand.cs index 7058803d2f..c49af32881 100644 --- a/Content.Server/Administration/Commands/RoleBanCommand.cs +++ b/Content.Server/Administration/Commands/RoleBanCommand.cs @@ -29,9 +29,10 @@ public sealed class RoleBanCommand : IConsoleCommand public async void Execute(IConsoleShell shell, string argStr, string[] args) { string target; - string job; + string role; string reason; uint minutes; + if (!Enum.TryParse(_cfg.GetCVar(CCVars.RoleBanDefaultSeverity), out NoteSeverity severity)) { _sawmill ??= _log.GetSawmill("admin.role_ban"); @@ -43,30 +44,33 @@ public sealed class RoleBanCommand : IConsoleCommand { case 3: target = args[0]; - job = args[1]; + role = args[1]; reason = args[2]; minutes = 0; + break; case 4: target = args[0]; - job = args[1]; + role = args[1]; reason = args[2]; if (!uint.TryParse(args[3], out minutes)) { shell.WriteError(Loc.GetString("cmd-roleban-minutes-parse", ("time", args[3]), ("help", Help))); + return; } break; case 5: target = args[0]; - job = args[1]; + role = args[1]; reason = args[2]; if (!uint.TryParse(args[3], out minutes)) { shell.WriteError(Loc.GetString("cmd-roleban-minutes-parse", ("time", args[3]), ("help", Help))); + return; } @@ -80,26 +84,27 @@ public sealed class RoleBanCommand : IConsoleCommand default: shell.WriteError(Loc.GetString("cmd-roleban-arg-count")); shell.WriteLine(Help); - return; - } - if (!_proto.HasIndex(job)) - { - shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", job))); - return; + return; } var located = await _locator.LookupIdByNameOrIdAsync(target); if (located == null) { shell.WriteError(Loc.GetString("cmd-roleban-name-parse")); + return; } var targetUid = located.UserId; var targetHWid = located.LastHWId; - _bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, job, minutes, severity, reason, DateTimeOffset.UtcNow); + if (_proto.HasIndex(role)) + _bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow); + else if (_proto.HasIndex(role)) + _bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow); + else + shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", role))); } public CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index 2d76c434e9..17f796e699 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -26,24 +26,25 @@ namespace Content.Server.Administration.Managers; public sealed partial class BanManager : IBanManager, IPostInjectInit { + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly IServerDbManager _db = default!; + [Dependency] private readonly ServerDbEntryManager _entryManager = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly ILocalizationManager _localizationManager = default!; + [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly INetManager _netManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IEntitySystemManager _systems = default!; - [Dependency] private readonly IConfigurationManager _cfg = default!; - [Dependency] private readonly ILocalizationManager _localizationManager = default!; - [Dependency] private readonly ServerDbEntryManager _entryManager = default!; - [Dependency] private readonly IChatManager _chat = default!; - [Dependency] private readonly INetManager _netManager = default!; - [Dependency] private readonly ILogManager _logManager = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly ITaskManager _taskManager = default!; [Dependency] private readonly UserDbDataManager _userDbData = default!; private ISawmill _sawmill = default!; public const string SawmillId = "admin.bans"; - public const string JobPrefix = "Job:"; + public const string PrefixAntag = "Antag:"; + public const string PrefixJob = "Job:"; private readonly Dictionary> _cachedRoleBans = new(); // Cached ban exemption flags are used to handle @@ -91,30 +92,6 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit _cachedBanExemptions.Remove(player); } - private async Task AddRoleBan(ServerRoleBanDef banDef) - { - banDef = await _db.AddServerRoleBanAsync(banDef); - - if (banDef.UserId != null - && _playerManager.TryGetSessionById(banDef.UserId, out var player) - && _cachedRoleBans.TryGetValue(player, out var cachedBans)) - { - cachedBans.Add(banDef); - } - - return true; - } - - public HashSet? GetRoleBans(NetUserId playerUserId) - { - if (!_playerManager.TryGetSessionById(playerUserId, out var session)) - return null; - - return _cachedRoleBans.TryGetValue(session, out var roleBans) - ? roleBans.Select(banDef => banDef.Role).ToHashSet() - : null; - } - public void Restart() { // Clear out players that have disconnected. @@ -232,23 +209,54 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit #endregion - #region Job Bans + #region Role Bans + // If you are trying to remove timeOfBan, please don't. It's there because the note system groups role bans by time, reason and banning admin. // Removing it will clutter the note list. Please also make sure that department bans are applied to roles with the same DateTimeOffset. - public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan) + public async void CreateRoleBan( + NetUserId? target, + string? targetUsername, + NetUserId? banningAdmin, + (IPAddress, int)? addressRange, + ImmutableTypedHwid? hwid, + ProtoId role, + uint? minutes, + NoteSeverity severity, + string reason, + DateTimeOffset timeOfBan + ) where T : class, IPrototype { - if (!_prototypeManager.TryIndex(role, out JobPrototype? _)) + string encodedRole; + + // TODO: Note that it's possible to clash IDs here between a job and an antag. The refactor that introduced + // this check has consciously avoided refactoring Job and Antag prototype. + // Refactor Job- and Antag- Prototype to introduce a common RolePrototype, which will fix this possible clash. + + //TODO remove this check as part of the above refactor + if (_prototypeManager.HasIndex(role) && _prototypeManager.HasIndex(role)) { - throw new ArgumentException($"Invalid role '{role}'", nameof(role)); + _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is both JobPrototype and AntagPrototype."); + + return; } - role = string.Concat(JobPrefix, role); - DateTimeOffset? expires = null; - if (minutes > 0) + // Don't trust the input: make sure the job or antag actually exists. + if (_prototypeManager.HasIndex(role)) + encodedRole = PrefixJob + role; + else if (_prototypeManager.HasIndex(role)) + encodedRole = PrefixAntag + role; + else { - expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value); + _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is not a JobPrototype or an AntagPrototype."); + + return; } + DateTimeOffset? expires = null; + + if (minutes > 0) + expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value); + _systems.TryGetEntitySystem(out GameTicker? ticker); int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId; var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero; @@ -266,21 +274,34 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit severity, banningAdmin, null, - role); + encodedRole); if (!await AddRoleBan(banDef)) { _chat.SendAdminAlert(Loc.GetString("cmd-roleban-existing", ("target", targetUsername ?? "null"), ("role", role))); + return; } var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires)); _chat.SendAdminAlert(Loc.GetString("cmd-roleban-success", ("target", targetUsername ?? "null"), ("role", role), ("reason", reason), ("length", length))); - if (target != null && _playerManager.TryGetSessionById(target.Value, out var session)) - { + if (target is not null && _playerManager.TryGetSessionById(target.Value, out var session)) SendRoleBans(session); + } + + private async Task AddRoleBan(ServerRoleBanDef banDef) + { + banDef = await _db.AddServerRoleBanAsync(banDef); + + if (banDef.UserId != null + && _playerManager.TryGetSessionById(banDef.UserId, out var player) + && _cachedRoleBans.TryGetValue(player, out var cachedBans)) + { + cachedBans.Add(banDef); } + + return true; } public async Task PardonRoleBan(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime) @@ -319,32 +340,109 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit } public HashSet>? GetJobBans(NetUserId playerUserId) + { + return GetRoleBans(playerUserId, PrefixJob); + } + + public HashSet>? GetAntagBans(NetUserId playerUserId) + { + return GetRoleBans(playerUserId, PrefixAntag); + } + + private HashSet>? GetRoleBans(NetUserId playerUserId, string prefix) where T : class, IPrototype { if (!_playerManager.TryGetSessionById(playerUserId, out var session)) return null; - if (!_cachedRoleBans.TryGetValue(session, out var roleBans)) + return GetRoleBans(session, prefix); + } + + private HashSet>? GetRoleBans(ICommonSession playerSession, string prefix) where T : class, IPrototype + { + if (!_cachedRoleBans.TryGetValue(playerSession, out var roleBans)) return null; return roleBans - .Where(ban => ban.Role.StartsWith(JobPrefix, StringComparison.Ordinal)) - .Select(ban => new ProtoId(ban.Role[JobPrefix.Length..])) + .Where(ban => ban.Role.StartsWith(prefix, StringComparison.Ordinal)) + .Select(ban => new ProtoId(ban.Role[prefix.Length..])) .ToHashSet(); } - #endregion + + public HashSet? GetRoleBans(NetUserId playerUserId) + { + if (!_playerManager.TryGetSessionById(playerUserId, out var session)) + return null; + + return _cachedRoleBans.TryGetValue(session, out var roleBans) + ? roleBans.Select(banDef => banDef.Role).ToHashSet() + : null; + } + + public bool IsRoleBanned(ICommonSession player, List> jobs) + { + return IsRoleBanned(player, jobs, PrefixJob); + } + + public bool IsRoleBanned(ICommonSession player, List> antags) + { + return IsRoleBanned(player, antags, PrefixAntag); + } + + private bool IsRoleBanned(ICommonSession player, List> roles, string prefix) where T : class, IPrototype + { + var bans = GetRoleBans(player.UserId); + + if (bans is null || bans.Count == 0) + return false; + + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator + foreach (var role in roles) + { + if (bans.Contains(prefix + role)) + return true; + } + + return false; + } public void SendRoleBans(ICommonSession pSession) { - var roleBans = _cachedRoleBans.GetValueOrDefault(pSession) ?? new List(); + var jobBans = GetRoleBans(pSession, PrefixJob); + var jobBansList = new List(jobBans?.Count ?? 0); + + if (jobBans is not null) + { + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator + foreach (var encodedId in jobBans) + { + jobBansList.Add(encodedId.ToString().Replace(PrefixJob, "")); + } + } + + var antagBans = GetRoleBans(pSession, PrefixAntag); + var antagBansList = new List(antagBans?.Count ?? 0); + + if (antagBans is not null) + { + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator + foreach (var encodedId in antagBans) + { + antagBansList.Add(encodedId.ToString().Replace(PrefixAntag, "")); + } + } + var bans = new MsgRoleBans() { - Bans = roleBans.Select(o => o.Role).ToList() + JobBans = jobBansList, + AntagBans = antagBansList, }; - _sawmill.Debug($"Sent rolebans to {pSession.Name}"); + _sawmill.Debug($"Sent role bans to {pSession.Name}"); _netManager.ServerSendMessage(bans, pSession.Channel); } + #endregion + public void PostInject() { _sawmill = _logManager.GetSawmill(SawmillId); diff --git a/Content.Server/Administration/Managers/IBanManager.cs b/Content.Server/Administration/Managers/IBanManager.cs index fc192cc306..1912ebe9ec 100644 --- a/Content.Server/Administration/Managers/IBanManager.cs +++ b/Content.Server/Administration/Managers/IBanManager.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Net; using System.Threading.Tasks; using Content.Shared.Database; @@ -25,19 +24,63 @@ public interface IBanManager /// Severity of the resulting ban note /// Reason for the ban public void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason); + + /// + /// Gets a list of prefixed prototype IDs with the player's role bans. + /// public HashSet? GetRoleBans(NetUserId playerUserId); + + /// + /// Checks if the player is currently banned from any of the listed roles. + /// + /// The player. + /// A list of valid antag prototype IDs. + /// Returns True if an active role ban is found for this player for any of the listed roles. + public bool IsRoleBanned(ICommonSession player, List> antags); + + /// + /// Checks if the player is currently banned from any of the listed roles. + /// + /// The player. + /// A list of valid job prototype IDs. + /// Returns True if an active role ban is found for this player for any of the listed roles. + public bool IsRoleBanned(ICommonSession player, List> jobs); + + /// + /// Gets a list of prototype IDs with the player's job bans. + /// public HashSet>? GetJobBans(NetUserId playerUserId); + /// + /// Gets a list of prototype IDs with the player's antag bans. + /// + public HashSet>? GetAntagBans(NetUserId playerUserId); + /// /// Creates a job ban for the specified target, username or GUID /// /// Target user, username or GUID, null for none - /// Role to be banned from + /// The username of the target, if known + /// The responsible admin for the ban + /// The range of IPs that are to be banned, if known + /// The HWID to be banned, if known + /// The role ID to be banned from. Either an AntagPrototype or a JobPrototype + /// Number of minutes to ban for. 0 and null mean permanent /// Severity of the resulting ban note /// Reason for the ban - /// Number of minutes to ban for. 0 and null mean permanent /// Time when the ban was applied, used for grouping role bans - public void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan); + public void CreateRoleBan( + NetUserId? target, + string? targetUsername, + NetUserId? banningAdmin, + (IPAddress, int)? addressRange, + ImmutableTypedHwid? hwid, + ProtoId role, + uint? minutes, + NoteSeverity severity, + string reason, + DateTimeOffset timeOfBan + ) where T : class, IPrototype; /// /// Pardons a role ban for the specified target, username or GUID diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs index 975c802eed..6703b7b7ca 100644 --- a/Content.Server/Antag/AntagSelectionSystem.API.cs +++ b/Content.Server/Antag/AntagSelectionSystem.API.cs @@ -2,16 +2,17 @@ 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.Antag; using Content.Shared.Chat; using Content.Shared.GameTicking.Components; using Content.Shared.Mind; using Content.Shared.Preferences; +using Content.Shared.Roles; using JetBrains.Annotations; using Robust.Shared.Audio; using Robust.Shared.Enums; using Robust.Shared.Player; +using Robust.Shared.Prototypes; namespace Content.Server.Antag; @@ -161,33 +162,35 @@ public sealed partial class AntagSelectionSystem } /// - /// Checks if a given session has the primary antag preferences for a given definition + /// Checks if a given session has enabled the antag preferences for a given definition, + /// and if it is blocked by any requirements or bans. /// - public bool HasPrimaryAntagPreference(ICommonSession? session, AntagSelectionDefinition def) + /// Returns true if at least one role from the provided list passes every condition> + public bool ValidAntagPreference(ICommonSession? session, List> roles) { if (session == null) return true; - if (def.PrefRoles.Count == 0) + if (roles.Count == 0) return false; var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter; - return pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p)); - } - /// - /// Checks if a given session has the fallback antag preferences for a given definition - /// - public bool HasFallbackAntagPreference(ICommonSession? session, AntagSelectionDefinition def) - { - if (session == null) - return true; + var valid = false; - if (def.FallbackRoles.Count == 0) - return false; + // Check each individual antag role + foreach (var role in roles) + { + var list = new List>{role}; - var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter; - return pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p)); + + if (pref.AntagPreferences.Contains(role) + && !_ban.IsRoleBanned(session, list) + && _playTime.IsAllowed(session, list)) + valid = true; + } + + return valid; } /// diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs index 7fdf812fbe..2d484a2aa9 100644 --- a/Content.Server/Antag/AntagSelectionSystem.cs +++ b/Content.Server/Antag/AntagSelectionSystem.cs @@ -1,4 +1,5 @@ using System.Linq; +using Content.Server.Administration.Managers; using Content.Server.Antag.Components; using Content.Server.Chat.Managers; using Content.Server.GameTicking; @@ -8,11 +9,11 @@ using Content.Server.Ghost.Roles; using Content.Server.Ghost.Roles.Components; using Content.Server.Mind; using Content.Server.Objectives; +using Content.Server.Players.PlayTimeTracking; using Content.Server.Preferences.Managers; using Content.Server.Roles; using Content.Server.Roles.Jobs; using Content.Server.Shuttles.Components; -using Content.Server.Station.Events; using Content.Shared.Administration.Logs; using Content.Shared.Antag; using Content.Shared.Clothing; @@ -40,12 +41,14 @@ namespace Content.Server.Antag; public sealed partial class AntagSelectionSystem : GameRuleSystem { [Dependency] private readonly AudioSystem _audio = default!; + [Dependency] private readonly IBanManager _ban = default!; [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly GhostRoleSystem _ghostRole = default!; [Dependency] private readonly JobSystem _jobs = default!; [Dependency] private readonly LoadoutSystem _loadout = default!; [Dependency] private readonly MindSystem _mind = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly PlayTimeTrackingSystem _playTime = default!; [Dependency] private readonly IServerPreferencesManager _pref = default!; [Dependency] private readonly RoleSystem _role = default!; [Dependency] private readonly TransformSystem _transform = default!; @@ -344,7 +347,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem? OnNotificationReceived; /// Sawmill to trace log database operations to. @@ -1386,7 +1385,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} ban.LastEditedAt, ban.ExpirationTime, ban.Hidden, - new [] { ban.RoleId.Replace(BanManager.JobPrefix, null) }, + new [] { ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null) }, MakePlayerRecord(unbanningAdmin), ban.Unban?.UnbanTime); } @@ -1686,7 +1685,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} NormalizeDatabaseTime(firstBan.LastEditedAt), NormalizeDatabaseTime(firstBan.ExpirationTime), firstBan.Hidden, - banGroup.Select(ban => ban.RoleId.Replace(BanManager.JobPrefix, null)).ToArray(), + banGroup.Select(ban => ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null)).ToArray(), MakePlayerRecord(unbanningAdmin), NormalizeDatabaseTime(firstBan.Unban?.UnbanTime))); } diff --git a/Content.Server/GameTicking/Events/IsJobAllowedEvent.cs b/Content.Server/GameTicking/Events/IsJobAllowedEvent.cs deleted file mode 100644 index 51969d61ea..0000000000 --- a/Content.Server/GameTicking/Events/IsJobAllowedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Content.Shared.Roles; -using Robust.Shared.Player; -using Robust.Shared.Prototypes; - -namespace Content.Server.GameTicking.Events; - -[ByRefEvent] -public struct IsJobAllowedEvent(ICommonSession player, ProtoId jobId, bool cancelled = false) -{ - public readonly ICommonSession Player = player; - public readonly ProtoId JobId = jobId; - public bool Cancelled = cancelled; -} diff --git a/Content.Server/GameTicking/Events/IsRoleAllowedEvent.cs b/Content.Server/GameTicking/Events/IsRoleAllowedEvent.cs new file mode 100644 index 0000000000..76d2805d1c --- /dev/null +++ b/Content.Server/GameTicking/Events/IsRoleAllowedEvent.cs @@ -0,0 +1,24 @@ +using Content.Shared.Roles; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; + +namespace Content.Server.GameTicking.Events; + +/// +/// Event raised to check if a player is allowed/able to assume a role. +/// +/// The player. +/// Optional list of job prototype IDs +/// Optional list of antag prototype IDs +[ByRefEvent] +public struct IsRoleAllowedEvent( + ICommonSession player, + List>? jobs, + List>? antags, + bool cancelled = false) +{ + public readonly ICommonSession Player = player; + public readonly List>? Jobs = jobs; + public readonly List>? Antags = antags; + public bool Cancelled = cancelled; +} diff --git a/Content.Server/GameTicking/GameTicker.Spawning.cs b/Content.Server/GameTicking/GameTicker.Spawning.cs index 194f6c4997..2338d4f1fe 100644 --- a/Content.Server/GameTicking/GameTicker.Spawning.cs +++ b/Content.Server/GameTicking/GameTicker.Spawning.cs @@ -141,12 +141,13 @@ namespace Content.Server.GameTicking var character = GetPlayerProfile(player); var jobBans = _banManager.GetJobBans(player.UserId); - if (jobBans == null || jobId != null && jobBans.Contains(jobId)) + if (jobBans == null || jobId != null && jobBans.Contains(jobId)) //TODO: use IsRoleBanned directly? return; if (jobId != null) { - var ev = new IsJobAllowedEvent(player, new ProtoId(jobId)); + var jobs = new List> {jobId}; + var ev = new IsRoleAllowedEvent(player, jobs, null); RaiseLocalEvent(ref ev); if (ev.Cancelled) return; diff --git a/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs b/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs index 5dd390bd72..98aaf672c2 100644 --- a/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs +++ b/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs @@ -15,12 +15,6 @@ public sealed partial class GhostRoleComponent : Component [DataField("rules")] private string _roleRules = "ghost-role-component-default-rules"; - // Actually make use of / enforce this requirement? - // Why is this even here. - // Move to ghost role prototype & respect CCvars.GameRoleTimerOverride - [DataField("requirements")] - public HashSet? Requirements; - /// /// Whether the should run on the mob. /// diff --git a/Content.Server/Ghost/Roles/GhostRoleSystem.cs b/Content.Server/Ghost/Roles/GhostRoleSystem.cs index 182d8e968e..b2cbd6a152 100644 --- a/Content.Server/Ghost/Roles/GhostRoleSystem.cs +++ b/Content.Server/Ghost/Roles/GhostRoleSystem.cs @@ -1,6 +1,8 @@ using System.Linq; using Content.Server.Administration.Logs; +using Content.Server.Administration.Managers; using Content.Server.EUI; +using Content.Server.GameTicking.Events; using Content.Server.Ghost.Roles.Components; using Content.Server.Ghost.Roles.Events; using Content.Shared.Ghost.Roles.Raffles; @@ -32,13 +34,16 @@ using Content.Server.Popups; using Content.Shared.Verbs; using Robust.Shared.Collections; using Content.Shared.Ghost.Roles.Components; +using Content.Shared.Roles.Components; namespace Content.Server.Ghost.Roles; [UsedImplicitly] public sealed class GhostRoleSystem : EntitySystem { + [Dependency] private readonly IBanManager _ban = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IEntityManager _ent = default!; [Dependency] private readonly EuiManager _euiManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; @@ -459,6 +464,23 @@ public sealed class GhostRoleSystem : EntitySystem if (!_ghostRoles.TryGetValue(identifier, out var roleEnt)) return; + TryPrototypes(roleEnt, out var antags, out var jobs); + + // Check role bans + if (_ban.IsRoleBanned(player, antags) || _ban.IsRoleBanned(player, jobs)) + { + Log.Warning($"Server rejected ghost role request '{roleEnt.Comp.RoleName}' for '{player.Name}' - client missed ban?"); + return; + } + + // Check role requirements + if (!IsRoleAllowed(player, jobs, antags)) + { + Log.Warning($"Server rejected ghost role request '{roleEnt.Comp.RoleName}' for '{player.Name}' - client missed requirement check?"); + return; + } + + // Decide to do a raffle or not if (roleEnt.Comp.RaffleConfig is not null) { JoinRaffle(player, identifier); @@ -469,6 +491,78 @@ public sealed class GhostRoleSystem : EntitySystem } } + /// + /// Collect all role prototypes on the Ghostrole. + /// + /// + /// Returns true if at least on role prototype could be found. + /// + private bool TryPrototypes( + Entity roleEnt, + out List> antags, + out List> jobs) + { + antags = []; + jobs = []; + + // If there is a mind already, check its mind roles. + // Not sure if this can ever actually happen. + if (TryComp(roleEnt, out var mindCont) + && TryComp(mindCont.Mind, out var mind)) + { + foreach (var role in mind.MindRoleContainer.ContainedEntities) + { + if(!TryComp(role, out var comp)) + continue; + + if (comp.JobPrototype is not null) + jobs.Add(comp.JobPrototype.Value); + + else if (comp.AntagPrototype is not null) + antags.Add(comp.AntagPrototype.Value); + } + + return antags.Count > 0 || jobs.Count > 0; + } + + if (roleEnt.Comp.JobProto is not null) + jobs.Add(roleEnt.Comp.JobProto.Value); + + + // If there is no mind, check the mindRole prototypes + foreach (var proto in roleEnt.Comp.MindRoles) + { + if (!_prototype.TryIndex(proto, out var indexed) + || !indexed.TryGetComponent(out var comp, _ent.ComponentFactory)) + continue; + var roleComp = (MindRoleComponent)comp; + + if (roleComp.JobPrototype is not null) + jobs.Add(roleComp.JobPrototype.Value); + else if (roleComp.AntagPrototype is not null) + antags.Add(roleComp.AntagPrototype.Value); + else + Log.Debug($"Mind role '{proto}' of '{roleEnt.Comp.RoleName}' has neither a job or antag prototype specified"); + } + + return antags.Count > 0 || jobs.Count > 0; + } + + /// + /// Checks if the player passes the requirements for the supplied roles. + /// Returns false if any role fails the check. + /// + private bool IsRoleAllowed( + ICommonSession player, + List>? jobIds, + List>? antagIds) + { + var ev = new IsRoleAllowedEvent(player, jobIds, antagIds); + RaiseLocalEvent(ref ev); + + return !ev.Cancelled; + } + /// /// Attempts having the player take over the ghost role with the corresponding ID. Does not start a raffle. /// @@ -571,13 +665,15 @@ public sealed class GhostRoleSystem : EntitySystem ? _timing.CurTime.Add(raffle.Countdown) : TimeSpan.MinValue; + TryPrototypes((uid, role), out var antags, out var jobs); + roles.Add(new GhostRoleInfo { Identifier = id, Name = role.RoleName, Description = role.RoleDescription, Rules = role.RoleRules, - Requirements = role.Requirements, + RolePrototypes = (jobs, antags), Kind = kind, RafflePlayerCount = rafflePlayerCount, RaffleEndTime = raffleEndTime diff --git a/Content.Server/Players/JobWhitelist/JobWhitelistManager.cs b/Content.Server/Players/JobWhitelist/JobWhitelistManager.cs index 72f18e00cb..c47ffa691f 100644 --- a/Content.Server/Players/JobWhitelist/JobWhitelistManager.cs +++ b/Content.Server/Players/JobWhitelist/JobWhitelistManager.cs @@ -58,6 +58,9 @@ public sealed class JobWhitelistManager : IPostInjectInit SendJobWhitelist(session); } + /// + /// Returns false if role whitelist is required but the player does not have it. + /// public bool IsAllowed(ICommonSession session, ProtoId job) { if (!_config.GetCVar(CCVars.GameRoleWhitelist)) diff --git a/Content.Server/Players/JobWhitelist/JobWhitelistSystem.cs b/Content.Server/Players/JobWhitelist/JobWhitelistSystem.cs index aaada99dea..2e2848fea3 100644 --- a/Content.Server/Players/JobWhitelist/JobWhitelistSystem.cs +++ b/Content.Server/Players/JobWhitelist/JobWhitelistSystem.cs @@ -23,7 +23,7 @@ public sealed class JobWhitelistSystem : EntitySystem { SubscribeLocalEvent(OnPrototypesReloaded); SubscribeLocalEvent(OnStationJobsGetCandidates); - SubscribeLocalEvent(OnIsJobAllowed); + SubscribeLocalEvent(OnIsRoleAllowed); SubscribeLocalEvent(OnGetDisallowedJobs); CacheJobs(); @@ -51,11 +51,18 @@ public sealed class JobWhitelistSystem : EntitySystem } } - private void OnIsJobAllowed(ref IsJobAllowedEvent ev) + private void OnIsRoleAllowed(ref IsRoleAllowedEvent ev) { - if (!_manager.IsAllowed(ev.Player, ev.JobId)) - ev.Cancelled = true; + if (ev.Jobs is null) + return; + + foreach (var proto in ev.Jobs) + { + if (!_manager.IsAllowed(ev.Player, proto)) + ev.Cancelled = true; + } } + //TODO: Antagonist role whitelists? private void OnGetDisallowedJobs(ref GetDisallowedJobsEvent ev) { diff --git a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs index d55920f83c..f218de1c77 100644 --- a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs +++ b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs @@ -54,7 +54,7 @@ public sealed class PlayTimeTrackingSystem : EntitySystem SubscribeLocalEvent(OnMobStateChanged); SubscribeLocalEvent(OnPlayerJoinedLobby); SubscribeLocalEvent(OnStationJobsGetCandidates); - SubscribeLocalEvent(OnIsJobAllowed); + SubscribeLocalEvent(OnIsRoleAllowed); SubscribeLocalEvent(OnGetDisallowedJobs); _adminManager.OnPermsChanged += AdminPermsChanged; } @@ -86,6 +86,9 @@ public sealed class PlayTimeTrackingSystem : EntitySystem trackers.UnionWith(GetTimedRoles(player)); } + /// + /// Returns true if the player has an attached mob and it is alive (even if in critical). + /// private bool IsPlayerAlive(ICommonSession session) { var attached = session.AttachedEntity; @@ -176,9 +179,9 @@ public sealed class PlayTimeTrackingSystem : EntitySystem RemoveDisallowedJobs(ev.Player, ev.Jobs); } - private void OnIsJobAllowed(ref IsJobAllowedEvent ev) + private void OnIsRoleAllowed(ref IsRoleAllowedEvent ev) { - if (!IsAllowed(ev.Player, ev.JobId)) + if (!IsAllowed(ev.Player, ev.Jobs) || !IsAllowed(ev.Player, ev.Antags)) ev.Cancelled = true; } @@ -187,10 +190,55 @@ public sealed class PlayTimeTrackingSystem : EntitySystem ev.Jobs.UnionWith(GetDisallowedJobs(ev.Player)); } - public bool IsAllowed(ICommonSession player, string role) + /// + /// Checks if the player meets role requirements. + /// + /// The player. + /// A list of role prototype IDs + /// Returns true if all requirements were met or there were no requirements. + public bool IsAllowed(ICommonSession player, List>? jobs) { - if (!_prototypes.TryIndex(role, out var job) || - !_cfg.GetCVar(CCVars.GameRoleTimers)) + if (jobs is null) + return true; + + foreach (var job in jobs) + { + if (!IsAllowed(player, job)) + return false; + } + + return true; + } + + /// + /// Checks if the player meets role requirements. + /// + /// The player. + /// A list of role prototype IDs + /// Returns true if all requirements were met or there were no requirements. + public bool IsAllowed(ICommonSession player, List>? antags) + { + if (antags is null) + return true; + + foreach (var antag in antags) + { + if (!IsAllowed(player, antag)) + return false; + } + + return true; + } + + /// + /// Checks if the player meets role requirements. + /// + /// The player. + /// A list of role prototype IDs + /// Returns true if all requirements were met or there were no requirements. + public bool IsAllowed(ICommonSession player, ProtoId job) + { + if (!_cfg.GetCVar(CCVars.GameRoleTimers)) return true; if (!_tracking.TryGetTrackerTimes(player, out var playTimes)) @@ -199,7 +247,43 @@ public sealed class PlayTimeTrackingSystem : EntitySystem playTimes = new Dictionary(); } - return JobRequirements.TryRequirementsMet(job, playTimes, out _, EntityManager, _prototypes, (HumanoidCharacterProfile?) _preferencesManager.GetPreferences(player.UserId).SelectedCharacter); + var requirements = _roles.GetRoleRequirements(job); + return JobRequirements.TryRequirementsMet( + requirements, + playTimes, + out _, + EntityManager, + _prototypes, + (HumanoidCharacterProfile?) + _preferencesManager.GetPreferences(player.UserId).SelectedCharacter); + } + + /// + /// Checks if the player meets role requirements. + /// + /// The player. + /// A list of role prototype IDs + /// Returns true if all requirements were met or there were no requirements. + public bool IsAllowed(ICommonSession player, ProtoId antag) + { + if (!_cfg.GetCVar(CCVars.GameRoleTimers)) + return true; + + if (!_tracking.TryGetTrackerTimes(player, out var playTimes)) + { + Log.Error($"Unable to check playtimes {Environment.StackTrace}"); + playTimes = new Dictionary(); + } + + var requirements = _roles.GetRoleRequirements(antag); + return JobRequirements.TryRequirementsMet( + requirements, + playTimes, + out _, + EntityManager, + _prototypes, + (HumanoidCharacterProfile?) + _preferencesManager.GetPreferences(player.UserId).SelectedCharacter); } public HashSet> GetDisallowedJobs(ICommonSession player) diff --git a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs index 3510aca85e..1dd7b70f8d 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs @@ -371,7 +371,7 @@ public sealed partial class StationJobsSystem if (weight is not null && job.Weight != weight.Value) continue; - if (!(roleBans == null || !roleBans.Contains(jobId))) + if (!(roleBans == null || !roleBans.Contains(jobId))) //TODO: Replace with IsRoleBanned continue; availableJobs ??= new List(profile.JobPriorities.Count); diff --git a/Content.Server/Zombies/ZombieSystem.Transform.cs b/Content.Server/Zombies/ZombieSystem.Transform.cs index 9c7d4b0699..8b5db4561c 100644 --- a/Content.Server/Zombies/ZombieSystem.Transform.cs +++ b/Content.Server/Zombies/ZombieSystem.Transform.cs @@ -1,7 +1,9 @@ +using Content.Server.Administration.Managers; using Content.Server.Atmos.Components; using Content.Server.Body.Components; using Content.Server.Chat; using Content.Server.Chat.Managers; +using Content.Server.Ghost; using Content.Server.Ghost.Roles.Components; using Content.Server.Humanoid; using Content.Server.IdentityManagement; @@ -14,6 +16,7 @@ using Content.Server.StationEvents.Components; using Content.Server.Speech.Components; using Content.Server.Temperature.Components; using Content.Shared.Body.Components; +using Content.Shared.Chat; using Content.Shared.CombatMode; using Content.Shared.CombatMode.Pacification; using Content.Shared.Damage; @@ -40,6 +43,7 @@ using Content.Shared.Tag; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Content.Shared.NPC.Prototypes; +using Content.Shared.Roles; namespace Content.Server.Zombies; @@ -52,23 +56,27 @@ namespace Content.Server.Zombies; public sealed partial class ZombieSystem { [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly IBanManager _ban = default!; [Dependency] private readonly IChatManager _chatMan = default!; [Dependency] private readonly SharedCombatModeSystem _combat = default!; [Dependency] private readonly NpcFactionSystem _faction = default!; + [Dependency] private readonly GhostSystem _ghost = default!; [Dependency] private readonly SharedHandsSystem _hands = default!; [Dependency] private readonly HumanoidAppearanceSystem _humanoidAppearance = default!; [Dependency] private readonly IdentitySystem _identity = default!; [Dependency] private readonly ServerInventorySystem _inventory = default!; [Dependency] private readonly MindSystem _mind = default!; [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!; + [Dependency] private readonly NameModifierSystem _nameMod = default!; [Dependency] private readonly NPCSystem _npc = default!; [Dependency] private readonly TagSystem _tag = default!; - [Dependency] private readonly NameModifierSystem _nameMod = default!; [Dependency] private readonly ISharedPlayerManager _player = default!; private static readonly ProtoId InvalidForGlobalSpawnSpellTag = "InvalidForGlobalSpawnSpell"; private static readonly ProtoId CannotSuicideTag = "CannotSuicide"; private static readonly ProtoId ZombieFaction = "Zombie"; + private static readonly string MindRoleZombie = "MindRoleZombie"; + private static readonly List> BannableZombiePrototypes = ["Zombie"]; /// /// Handles an entity turning into a zombie when they die or go into crit @@ -103,6 +111,24 @@ public sealed partial class ZombieSystem if (!Resolve(target, ref mobState, logMissing: false)) return; + // Detach role-banned players before zombification + if (TryComp(target, out var actor) && _ban.IsRoleBanned(actor.PlayerSession, BannableZombiePrototypes)) + { + var sess = actor.PlayerSession; + var message = Loc.GetString("zombie-roleban-ghosted"); + + if (_mind.TryGetMind(sess, out var playerMindEnt, out var playerMind)) + { + // Detach + _ghost.SpawnGhost((playerMindEnt, playerMind), target); + + // Notify + _chatMan.DispatchServerMessage(sess, message); + } + else + Log.Error($"Mind for session '{sess}' could not be found"); + } + //you're a real zombie now, son. var zombiecomp = AddComp(target); @@ -245,7 +271,7 @@ public sealed partial class ZombieSystem if (hasMind && mind != null && _player.TryGetSessionById(mind.UserId, out var session)) { //Zombie role for player manifest - _role.MindAddRole(mindId, "MindRoleZombie", mind: null, silent: true); + _role.MindAddRole(mindId, MindRoleZombie, mind: null, silent: true); //Greeting message for new bebe zombers _chatMan.DispatchServerMessage(session, Loc.GetString("zombie-infection-greeting")); @@ -266,6 +292,7 @@ public sealed partial class ZombieSystem ghostRole.RoleName = Loc.GetString("zombie-generic"); ghostRole.RoleDescription = Loc.GetString("zombie-role-desc"); ghostRole.RoleRules = Loc.GetString("zombie-role-rules"); + ghostRole.MindRoles.Add(MindRoleZombie); } if (TryComp(target, out var handsComp)) diff --git a/Content.Shared/Administration/BanPanelEuiState.cs b/Content.Shared/Administration/BanPanelEuiState.cs index 74c340566b..76de3d8e3f 100644 --- a/Content.Shared/Administration/BanPanelEuiState.cs +++ b/Content.Shared/Administration/BanPanelEuiState.cs @@ -1,6 +1,8 @@ using System.Net; using Content.Shared.Database; using Content.Shared.Eui; +using Content.Shared.Roles; +using Robust.Shared.Prototypes; using Robust.Shared.Serialization; namespace Content.Shared.Administration; @@ -21,32 +23,9 @@ public sealed class BanPanelEuiState : EuiStateBase public static class BanPanelEuiStateMsg { [Serializable, NetSerializable] - public sealed class CreateBanRequest : EuiMessageBase + public sealed class CreateBanRequest(Ban ban) : EuiMessageBase { - public string? Player { get; set; } - public string? IpAddress { get; set; } - public ImmutableTypedHwid? Hwid { get; set; } - public uint Minutes { get; set; } - public string Reason { get; set; } - public NoteSeverity Severity { get; set; } - public string[]? Roles { get; set; } - public bool UseLastIp { get; set; } - public bool UseLastHwid { get; set; } - public bool Erase { get; set; } - - public CreateBanRequest(string? player, (IPAddress, int)? ipAddress, bool useLastIp, ImmutableTypedHwid? hwid, bool useLastHwid, uint minutes, string reason, NoteSeverity severity, string[]? roles, bool erase) - { - Player = player; - IpAddress = ipAddress == null ? null : $"{ipAddress.Value.Item1}/{ipAddress.Value.Item2}"; - UseLastIp = useLastIp; - Hwid = hwid; - UseLastHwid = useLastHwid; - Minutes = minutes; - Reason = reason; - Severity = severity; - Roles = roles; - Erase = erase; - } + public Ban Ban { get; } = ban; } [Serializable, NetSerializable] @@ -60,3 +39,50 @@ public static class BanPanelEuiStateMsg } } } + +/// +/// Contains all the data related to a particular ban action created by the BanPanel window. +/// +[Serializable, NetSerializable] +public sealed record Ban +{ + public Ban( + string? target, + (IPAddress, int)? ipAddressTuple, + bool useLastIp, + ImmutableTypedHwid? hwid, + bool useLastHwid, + uint banDurationMinutes, + string reason, + NoteSeverity severity, + ProtoId[]? bannedJobs, + ProtoId[]? bannedAntags, + bool erase) + { + Target = target; + IpAddress = ipAddressTuple?.Item1.ToString(); + IpAddressHid = ipAddressTuple?.Item2.ToString() ?? "0"; + UseLastIp = useLastIp; + Hwid = hwid; + UseLastHwid = useLastHwid; + BanDurationMinutes = banDurationMinutes; + Reason = reason; + Severity = severity; + BannedJobs = bannedJobs; + BannedAntags = bannedAntags; + Erase = erase; + } + + public readonly string? Target; + public readonly string? IpAddress; + public readonly string? IpAddressHid; + public readonly bool UseLastIp; + public readonly ImmutableTypedHwid? Hwid; + public readonly bool UseLastHwid; + public readonly uint BanDurationMinutes; + public readonly string Reason; + public readonly NoteSeverity Severity; + public readonly ProtoId[]? BannedJobs; + public readonly ProtoId[]? BannedAntags; + public readonly bool Erase; +} diff --git a/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs b/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs index b5d8fedbd9..38086da856 100644 --- a/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs +++ b/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs @@ -1,5 +1,6 @@ using Content.Shared.Eui; using Content.Shared.Roles; +using Robust.Shared.Prototypes; using Robust.Shared.Serialization; namespace Content.Shared.Ghost.Roles @@ -12,11 +13,10 @@ namespace Content.Shared.Ghost.Roles public string Description { get; set; } public string Rules { get; set; } - // TODO ROLE TIMERS - // Actually make use of / enforce this requirement? - // Why is this even here. - // Move to ghost role prototype & respect CCvars.GameRoleTimerOverride - public HashSet? Requirements { get; set; } + /// + /// A list of all antag and job prototype IDs of the ghost role and its mind role(s). + /// + public (List>?,List>?) RolePrototypes; /// public GhostRoleKind Kind { get; set; } diff --git a/Content.Shared/Players/MsgRoleBans.cs b/Content.Shared/Players/MsgRoleBans.cs index fd90f62b0b..bcc28d01d2 100644 --- a/Content.Shared/Players/MsgRoleBans.cs +++ b/Content.Shared/Players/MsgRoleBans.cs @@ -11,24 +11,40 @@ public sealed class MsgRoleBans : NetMessage { public override MsgGroups MsgGroup => MsgGroups.EntityEvent; - public List Bans = new(); + public List JobBans = new(); + public List AntagBans = new(); public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { - var count = buffer.ReadVariableInt32(); - Bans.EnsureCapacity(count); + var jobCount = buffer.ReadVariableInt32(); + JobBans.EnsureCapacity(jobCount); - for (var i = 0; i < count; i++) + for (var i = 0; i < jobCount; i++) { - Bans.Add(buffer.ReadString()); + JobBans.Add(buffer.ReadString()); + } + + var antagCount = buffer.ReadVariableInt32(); + AntagBans.EnsureCapacity(antagCount); + + for (var i = 0; i < antagCount; i++) + { + AntagBans.Add(buffer.ReadString()); } } public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) { - buffer.WriteVariableInt32(Bans.Count); + buffer.WriteVariableInt32(JobBans.Count); - foreach (var ban in Bans) + foreach (var ban in JobBans) + { + buffer.Write(ban); + } + + buffer.WriteVariableInt32(AntagBans.Count); + + foreach (var ban in AntagBans) { buffer.Write(ban); } diff --git a/Content.Shared/Roles/AntagPrototype.cs b/Content.Shared/Roles/AntagPrototype.cs index ff2712600a..367b05c3dd 100644 --- a/Content.Shared/Roles/AntagPrototype.cs +++ b/Content.Shared/Roles/AntagPrototype.cs @@ -10,6 +10,12 @@ namespace Content.Shared.Roles; [Prototype] public sealed partial class AntagPrototype : IPrototype { + // The name to group all antagonists under. Equivalent to DepartmentPrototype IDs. + public static readonly string GroupName = "Antagonist"; + + // The colour to group all antagonists using. Equivalent to DepartmentPrototype Color fields. + public static readonly Color GroupColor = Color.Red; + [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; @@ -41,8 +47,6 @@ public sealed partial class AntagPrototype : IPrototype /// /// Requirements that must be met to opt in to this antag role. /// - // TODO ROLE TIMERS - // Actually check if the requirements are met. Because apparently this is actually unused. [DataField, Access(typeof(SharedRoleSystem), Other = AccessPermissions.None)] public HashSet? Requirements; diff --git a/Content.Shared/Roles/JobRequirements.cs b/Content.Shared/Roles/JobRequirements.cs index 17f5f7bd6a..62d50f8489 100644 --- a/Content.Shared/Roles/JobRequirements.cs +++ b/Content.Shared/Roles/JobRequirements.cs @@ -8,6 +8,13 @@ namespace Content.Shared.Roles; public static class JobRequirements { + /// + /// Checks if the requirements of the job are met by the provided play-times. + /// + /// The job to test. + /// The playtimes used for the check. + /// If the requirements were not met, details are provided here. + /// Returns true if all requirements were met or there were no requirements. public static bool TryRequirementsMet( JobPrototype job, IReadOnlyDictionary playTimes, @@ -17,7 +24,25 @@ public static class JobRequirements HumanoidCharacterProfile? profile) { var sys = entManager.System(); - var requirements = sys.GetJobRequirement(job); + var requirements = sys.GetRoleRequirements(job); + return TryRequirementsMet(requirements, playTimes, out reason, entManager, protoManager, profile); + } + + /// + /// Checks if the list of requirements are met by the provided play-times. + /// + /// The requirements to test. + /// The playtimes used for the check. + /// If the requirements were not met, details are provided here. + /// Returns true if all requirements were met or there were no requirements. + public static bool TryRequirementsMet( + HashSet? requirements, + IReadOnlyDictionary playTimes, + [NotNullWhen(false)] out FormattedMessage? reason, + IEntityManager entManager, + IPrototypeManager protoManager, + HumanoidCharacterProfile? profile) + { reason = null; if (requirements == null) return true; diff --git a/Content.Shared/Roles/SharedRoleSystem.cs b/Content.Shared/Roles/SharedRoleSystem.cs index ea25555257..eeab329661 100644 --- a/Content.Shared/Roles/SharedRoleSystem.cs +++ b/Content.Shared/Roles/SharedRoleSystem.cs @@ -667,10 +667,13 @@ public abstract class SharedRoleSystem : EntitySystem _audio.PlayGlobal(sound, session); } - // TODO ROLES Change to readonly. + // TODO ROLES Change to readonly? // Passing around a reference to a prototype's hashset makes me uncomfortable because it might be accidentally // mutated. - public HashSet? GetJobRequirement(JobPrototype job) + /// + /// Returns the list of requirements for a role, or null. May be altered by requirement overrides. + /// + public HashSet? GetRoleRequirements(JobPrototype job) { if (_requirementOverride != null && _requirementOverride.Jobs.TryGetValue(job.ID, out var req)) return req; @@ -678,33 +681,30 @@ public abstract class SharedRoleSystem : EntitySystem return job.Requirements; } - // TODO ROLES Change to readonly. - public HashSet? GetJobRequirement(ProtoId job) + // TODO ROLES Change to readonly? + /// + public HashSet? GetRoleRequirements(AntagPrototype antag) { - if (_requirementOverride != null && _requirementOverride.Jobs.TryGetValue(job, out var req)) - return req; - - return _prototypes.Index(job).Requirements; - } - - // TODO ROLES Change to readonly. - public HashSet? GetAntagRequirement(ProtoId antag) - { - if (_requirementOverride != null && _requirementOverride.Antags.TryGetValue(antag, out var req)) - return req; - - return _prototypes.Index(antag).Requirements; - } - - // TODO ROLES Change to readonly. - public HashSet? GetAntagRequirement(AntagPrototype antag) - { - if (_requirementOverride != null && _requirementOverride.Antags.TryGetValue(antag.ID, out var req)) + if (_requirementOverride != null && _requirementOverride.Jobs.TryGetValue(antag.ID, out var req)) return req; return antag.Requirements; } + // TODO ROLES Change to readonly? + /// + public HashSet? GetRoleRequirements(ProtoId jobId) + { + return _prototypes.TryIndex(jobId, out var job) ? GetRoleRequirements(job) : null; + } + + // TODO ROLES Change to readonly? + /// + public HashSet? GetRoleRequirements(ProtoId antagId) + { + return _prototypes.TryIndex(antagId, out var antag) ? GetRoleRequirements(antag) : null; + } + /// /// Returns the localized name of a role type's subtype. If the provided subtype parameter turns out to be empty, it returns the localized name of the role type instead. /// diff --git a/Resources/Locale/en-US/zombies/zombie.ftl b/Resources/Locale/en-US/zombies/zombie.ftl index 4643cd228b..39ee550bf9 100644 --- a/Resources/Locale/en-US/zombies/zombie.ftl +++ b/Resources/Locale/en-US/zombies/zombie.ftl @@ -9,3 +9,5 @@ zombie-role-rules = You are a [color={role-type-team-antagonist-color}][bold]{ro zombie-permadeath = This time, you're dead for real. zombification-resistance-coefficient-value = - [color=violet]Infection[/color] chance reduced by [color=lightblue]{$value}%[/color]. + +zombie-roleban-ghosted = You have been ghosted because you are banned from playing the Zombie role. diff --git a/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml b/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml index 5815dbba47..d3f2e172ec 100644 --- a/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml +++ b/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml @@ -1,3 +1,7 @@ +# The mind roles specified here will be overwritten by the actual entities' GhostRoleComponent when they spawn +# But the mind roles specified here are the ones checked for role bans when taking a ghost role! +# TODO make this simpler + - type: entity abstract: true parent: MarkerBase @@ -88,7 +92,7 @@ - type: GhostRole rules: ghost-role-information-rules-default-team-antagonist mindRoles: - - MindRoleGhostRoleTeamAntagonist + - MindRoleNukeops raffle: settings: default - type: GhostRoleMobSpawner @@ -128,7 +132,7 @@ description: roles-antag-nuclear-operative-commander-objective rules: ghost-role-information-rules-default-team-antagonist mindRoles: - - MindRoleGhostRoleTeamAntagonist + - MindRoleNukeopsCommander - type: entity categories: [ HideSpawnMenu, Spawner ] @@ -140,7 +144,7 @@ description: roles-antag-nuclear-operative-agent-objective rules: ghost-role-information-rules-default-team-antagonist mindRoles: - - MindRoleGhostRoleTeamAntagonist + - MindRoleNukeopsMedic - type: entity categories: [ HideSpawnMenu, Spawner ] @@ -152,7 +156,7 @@ description: roles-antag-nuclear-operative-objective rules: ghost-role-information-rules-default-team-antagonist mindRoles: - - MindRoleGhostRoleTeamAntagonist + - MindRoleNukeops - type: entity categories: [ HideSpawnMenu, Spawner ] @@ -164,7 +168,7 @@ description: ghost-role-information-space-dragon-description rules: ghost-role-information-space-dragon-rules mindRoles: - - MindRoleGhostRoleTeamAntagonist + - MindRoleDragon - type: Sprite layers: - state: green @@ -181,7 +185,7 @@ description: ghost-role-information-space-ninja-description rules: ghost-role-information-antagonist-rules mindRoles: - - MindRoleGhostRoleSoloAntagonist + - MindRoleNinja raffle: settings: default - type: Sprite @@ -201,7 +205,7 @@ description: ghost-role-information-paradox-clone-description rules: ghost-role-information-antagonist-rules mindRoles: - - MindRoleGhostRoleSoloAntagonist + - MindRoleParadoxClone raffle: settings: default - type: Sprite @@ -232,6 +236,8 @@ name: ghost-role-information-derelict-cyborg-name description: ghost-role-information-derelict-cyborg-description rules: ghost-role-information-silicon-rules + mindRoles: + - MindRoleSubvertedSilicon raffle: settings: default - type: Sprite @@ -300,7 +306,7 @@ name: ghost-role-information-wizard-name description: ghost-role-information-wizard-desc mindRoles: - - MindRoleGhostRoleSoloAntagonist + - MindRoleWizard raffle: settings: default - type: Sprite diff --git a/Resources/Prototypes/Entities/Mobs/Player/humanoid.yml b/Resources/Prototypes/Entities/Mobs/Player/humanoid.yml index 1f7ab7ac5c..f8cbae86a4 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/humanoid.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/humanoid.yml @@ -57,6 +57,7 @@ settings: short mindRoles: - MindRoleGhostRoleFamiliar + job: DeathSquad - type: Loadout prototypes: [ DeathSquadGear ] roleLoadout: [ RoleSurvivalEVA ] @@ -536,6 +537,7 @@ rules: ghost-role-information-nonantagonist-rules raffle: settings: short + job: CBURN - type: RandomMetadata nameSegments: - NamesMilitaryFirst @@ -564,6 +566,7 @@ rules: ghost-role-information-nonantagonist-rules raffle: settings: default + job: CentralCommandOfficial - type: Loadout prototypes: [ CentcomGear ] roleLoadout: [ RoleSurvivalStandard ] diff --git a/Resources/Prototypes/Entities/Mobs/Player/silicon.yml b/Resources/Prototypes/Entities/Mobs/Player/silicon.yml index 50a2e0b58e..1c416083bc 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/silicon.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/silicon.yml @@ -177,7 +177,7 @@ suffix: Empty components: - type: Anchorable - flags: + flags: - Anchorable - type: Rotatable - type: WarpPoint @@ -560,6 +560,7 @@ rules: ghost-role-information-silicon-rules raffle: settings: default + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -593,6 +594,7 @@ rules: ghost-role-information-silicon-rules raffle: settings: default + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -646,6 +648,7 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -680,6 +683,7 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -716,6 +720,7 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -753,6 +758,7 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -789,6 +795,7 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable - type: entity @@ -824,4 +831,5 @@ raffle: settings: default reregister: false + job: Borg - type: GhostTakeoverAvailable diff --git a/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/reinforcement_teleporter.yml b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/reinforcement_teleporter.yml index 79a10b6287..27d077df2f 100644 --- a/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/reinforcement_teleporter.yml +++ b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/reinforcement_teleporter.yml @@ -115,5 +115,6 @@ - MindRoleGhostRoleSilicon raffle: settings: default + job: Borg - type: GhostRoleMobSpawner prototype: PlayerBorgSyndicateAssaultBattery