]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add Votekick functionality (#32005)
authorSlamBamActionman <83650252+SlamBamActionman@users.noreply.github.com>
Thu, 26 Sep 2024 16:32:13 +0000 (18:32 +0200)
committerGitHub <noreply@github.com>
Thu, 26 Sep 2024 16:32:13 +0000 (18:32 +0200)
20 files changed:
Content.Client/Voting/UI/VoteCallMenu.xaml
Content.Client/Voting/UI/VoteCallMenu.xaml.cs
Content.Client/Voting/UI/VotePopup.xaml
Content.Client/Voting/UI/VotePopup.xaml.cs
Content.Client/Voting/VoteManager.cs
Content.Client/Voting/VotingSystem.cs [new file with mode: 0644]
Content.Server/Voting/IVoteHandle.cs
Content.Server/Voting/Managers/IVoteManager.cs
Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs
Content.Server/Voting/Managers/VoteManager.cs
Content.Server/Voting/VoteCommands.cs
Content.Server/Voting/VoteOptions.cs
Content.Server/Voting/VotingSystem.cs [new file with mode: 0644]
Content.Shared/CCVar/CCVars.cs
Content.Shared/Voting/MsgVoteData.cs
Content.Shared/Voting/StandardVoteType.cs
Content.Shared/Voting/VotingEvents.cs [new file with mode: 0644]
Resources/Locale/en-US/voting/managers/vote-manager.ftl
Resources/Locale/en-US/voting/ui/vote-call-menu.ftl
Resources/Locale/en-US/voting/ui/vote-popup.ftl

index cb03dd6bb883fc629fc17e92b4334e16335e1292..caca4fd553d501faf344e245ae20ce9f065bc780 100644 (file)
@@ -1,7 +1,7 @@
-<ui:VoteCallMenu xmlns="https://spacestation14.io"
+<ui:VoteCallMenu xmlns="https://spacestation14.io"
                  xmlns:ui="clr-namespace:Content.Client.Voting.UI"
                  xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
-                 MouseFilter="Stop" MinSize="350 150">
+                 MouseFilter="Stop" MinSize="350 200">
     <PanelContainer StyleClasses="AngleRect" />
     <BoxContainer Orientation="Vertical">
         <BoxContainer Margin="8 0" Orientation="Horizontal">
         <controls:HighDivider />
 
         <BoxContainer Orientation="Vertical" Margin="8 2 8 0" VerticalExpand="True" VerticalAlignment="Top">
-            <BoxContainer Orientation="Horizontal">
-                <OptionButton Name="VoteTypeButton" HorizontalExpand="True" />
-                <Control HorizontalExpand="True">
-                    <OptionButton Name="VoteSecondButton" Visible="False" />
-                </Control>
+            <BoxContainer Orientation="Vertical">
+                <OptionButton Margin="2 1" Name="VoteTypeButton" HorizontalExpand="False" />
+                <BoxContainer Name="VoteOptionsButtonContainer" HorizontalExpand="False" Orientation="Vertical"> 
+                </BoxContainer>
+                <Button Margin="64 4" Name="FollowButton" Text="{Loc 'ui-vote-follow-button'}" Visible="False" />
+                <Label Margin="2 2" Name="VoteNotTrustedLabel" Text="{Loc 'ui-vote-trusted-users-notice'}" Visible="False" />
+                <Label Margin="2 2" Name="VoteWarningLabel" Text="{Loc 'ui-vote-abuse-warning'}" Visible="False" HorizontalAlignment="Center"/>
             </BoxContainer>
-            <Label Name="VoteTypeTimeoutLabel" Visible="False" />
+            <Label Margin="8 2" Name="VoteTypeTimeoutLabel" Visible="False" />
         </BoxContainer>
-
-        <Button Margin="8 2" Name="CreateButton" Text="{Loc 'ui-vote-create-button'}" />
+        
+        <Button Margin="8 32 8 2" Name="CreateButton" Text="{Loc 'ui-vote-create-button'}" />
 
         <PanelContainer StyleClasses="LowDivider" />
         <Label Margin="12 0 0 0" StyleClasses="LabelSubText" Text="{Loc 'ui-vote-fluff'}" />
index 0eede4c4804f0559e49cb5ff7f23a35a248780aa..c5746c24d791e4e05b802a908a5c139bb1a5ee00 100644 (file)
@@ -1,7 +1,9 @@
-using System;
+using System.Linq;
 using System.Numerics;
 using Content.Client.Stylesheets;
 using Content.Shared.Administration;
+using Content.Shared.CCVar;
+using Content.Shared.Ghost;
 using Content.Shared.Voting;
 using JetBrains.Annotations;
 using Robust.Client.AutoGenerated;
@@ -9,10 +11,8 @@ using Robust.Client.Console;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.CustomControls;
 using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Configuration;
 using Robust.Shared.Console;
-using Robust.Shared.IoC;
-using Robust.Shared.Localization;
-using Robust.Shared.Maths;
 using Robust.Shared.Network;
 using Robust.Shared.Timing;
 
@@ -25,32 +25,54 @@ namespace Content.Client.Voting.UI
         [Dependency] private readonly IVoteManager _voteManager = default!;
         [Dependency] private readonly IGameTiming _gameTiming = default!;
         [Dependency] private readonly IClientNetManager _netManager = default!;
+        [Dependency] private readonly IEntityManager _entityManager = default!;
+        [Dependency] private readonly IEntityNetworkManager _entNetManager = default!;
+        [Dependency] private readonly IConfigurationManager _cfg = default!;
 
-        public static readonly (string name, StandardVoteType type, (string name, string id)[]? secondaries)[]
-            AvailableVoteTypes =
-            {
-                ("ui-vote-type-restart", StandardVoteType.Restart, null),
-                ("ui-vote-type-gamemode", StandardVoteType.Preset, null),
-                ("ui-vote-type-map", StandardVoteType.Map, null)
-            };
+        private VotingSystem _votingSystem;
+
+        public StandardVoteType Type;
+
+        public Dictionary<StandardVoteType, CreateVoteOption> AvailableVoteOptions = new Dictionary<StandardVoteType, CreateVoteOption>()
+        {
+            { StandardVoteType.Restart, new CreateVoteOption("ui-vote-type-restart", new(), false, null) },
+            { StandardVoteType.Preset, new CreateVoteOption("ui-vote-type-gamemode", new(), false, null) },
+            { StandardVoteType.Map, new CreateVoteOption("ui-vote-type-map", new(), false, null) },
+            { StandardVoteType.Votekick, new CreateVoteOption("ui-vote-type-votekick", new(), true, 0) }
+        };
+
+        public Dictionary<string, string> VotekickReasons = new Dictionary<string, string>()
+        {
+            { VotekickReasonType.Raiding.ToString(), Loc.GetString("ui-vote-votekick-type-raiding") },
+            { VotekickReasonType.Cheating.ToString(), Loc.GetString("ui-vote-votekick-type-cheating") },
+            { VotekickReasonType.Spam.ToString(), Loc.GetString("ui-vote-votekick-type-spamming") }
+        };
+
+        public Dictionary<NetUserId, (NetEntity, string)> PlayerList = new();
+
+        public OptionButton? _followDropdown = null;
+
+        public bool IsAllowedVotekick = false;
 
         public VoteCallMenu()
         {
             IoCManager.InjectDependencies(this);
             RobustXamlLoader.Load(this);
+            _votingSystem = _entityManager.System<VotingSystem>();
 
             Stylesheet = IoCManager.Resolve<IStylesheetManager>().SheetSpace;
             CloseButton.OnPressed += _ => Close();
+            VoteNotTrustedLabel.Text = Loc.GetString("ui-vote-trusted-users-notice", ("timeReq", _cfg.GetCVar(CCVars.VotekickEligibleVoterDeathtime) / 60));
 
-            for (var i = 0; i < AvailableVoteTypes.Length; i++)
+            foreach (StandardVoteType voteType in Enum.GetValues<StandardVoteType>())
             {
-                var (text, _, _) = AvailableVoteTypes[i];
-                VoteTypeButton.AddItem(Loc.GetString(text), i);
+                var option = AvailableVoteOptions[voteType];
+                VoteTypeButton.AddItem(Loc.GetString(option.Name), (int)voteType);
             }
 
             VoteTypeButton.OnItemSelected += VoteTypeSelected;
-            VoteSecondButton.OnItemSelected += VoteSecondSelected;
             CreateButton.OnPressed += CreatePressed;
+            FollowButton.OnPressed += FollowSelected;
         }
 
         protected override void Opened()
@@ -60,6 +82,8 @@ namespace Content.Client.Voting.UI
             _netManager.ClientSendMessage(new MsgVoteMenu());
 
             _voteManager.CanCallVoteChanged += CanCallVoteChanged;
+            _votingSystem.VotePlayerListResponse += UpdateVotePlayerList;
+            _votingSystem.RequestVotePlayerList();
         }
 
         public override void Close()
@@ -67,6 +91,7 @@ namespace Content.Client.Voting.UI
             base.Close();
 
             _voteManager.CanCallVoteChanged -= CanCallVoteChanged;
+            _votingSystem.VotePlayerListResponse -= UpdateVotePlayerList;
         }
 
         protected override void FrameUpdate(FrameEventArgs args)
@@ -82,21 +107,50 @@ namespace Content.Client.Voting.UI
                 Close();
         }
 
+        private void UpdateVotePlayerList(VotePlayerListResponseEvent msg)
+        {
+            Dictionary<string, string> optionsList = new();
+            Dictionary<NetUserId, (NetEntity, string)> playerList = new();
+            foreach ((NetUserId, NetEntity, string) player in msg.Players)
+            {
+                optionsList.Add(player.Item1.ToString(), player.Item3);
+                playerList.Add(player.Item1, (player.Item2, player.Item3));
+            }
+            if (optionsList.Count == 0)
+                optionsList.Add(" ", " ");
+
+            PlayerList = playerList;
+
+            IsAllowedVotekick = !msg.Denied;
+
+            var updatedDropdownOption = AvailableVoteOptions[StandardVoteType.Votekick];
+            updatedDropdownOption.Dropdowns = new List<Dictionary<string, string>>() { optionsList, VotekickReasons };
+            AvailableVoteOptions[StandardVoteType.Votekick] = updatedDropdownOption;
+        }
+
         private void CreatePressed(BaseButton.ButtonEventArgs obj)
         {
             var typeId = VoteTypeButton.SelectedId;
-            var (_, typeKey, secondaries) = AvailableVoteTypes[typeId];
+            var voteType = AvailableVoteOptions[(StandardVoteType)typeId];
 
-            if (secondaries != null)
-            {
-                var secondaryId = VoteSecondButton.SelectedId;
-                var (_, secondKey) = secondaries[secondaryId];
+            var commandArgs = "";
 
-                _consoleHost.LocalShell.RemoteExecuteCommand($"createvote {typeKey} {secondKey}");
+            if (voteType.Dropdowns == null || voteType.Dropdowns.Count == 0)
+            {
+                _consoleHost.LocalShell.RemoteExecuteCommand($"createvote {((StandardVoteType)typeId).ToString()}");
             }
             else
             {
-                _consoleHost.LocalShell.RemoteExecuteCommand($"createvote {typeKey}");
+                int i = 0;
+                foreach(var dropdowns in VoteOptionsButtonContainer.Children)
+                {
+                    if (dropdowns is OptionButton optionButton && AvailableVoteOptions[(StandardVoteType)typeId].Dropdowns != null)
+                    {
+                        commandArgs += AvailableVoteOptions[(StandardVoteType)typeId].Dropdowns[i].ElementAt(optionButton.SelectedId).Key + " ";
+                        i++;
+                    }
+                }
+                _consoleHost.LocalShell.RemoteExecuteCommand($"createvote {((StandardVoteType)typeId).ToString()} {commandArgs}");
             }
 
             Close();
@@ -104,9 +158,16 @@ namespace Content.Client.Voting.UI
 
         private void UpdateVoteTimeout()
         {
-            var (_, typeKey, _) = AvailableVoteTypes[VoteTypeButton.SelectedId];
+            var typeKey = (StandardVoteType)VoteTypeButton.SelectedId;
             var isAvailable = _voteManager.CanCallStandardVote(typeKey, out var timeout);
-            CreateButton.Disabled = !isAvailable;
+            if (typeKey == StandardVoteType.Votekick && !IsAllowedVotekick)
+            {
+                CreateButton.Disabled = true;
+            }
+            else
+            {
+                CreateButton.Disabled = !isAvailable;
+            }
             VoteTypeTimeoutLabel.Visible = !isAvailable;
 
             if (!isAvailable)
@@ -123,29 +184,73 @@ namespace Content.Client.Voting.UI
             }
         }
 
-        private static void VoteSecondSelected(OptionButton.ItemSelectedEventArgs obj)
+        private static void ButtonSelected(OptionButton.ItemSelectedEventArgs obj)
         {
             obj.Button.SelectId(obj.Id);
         }
 
+        private void FollowSelected(Button.ButtonEventArgs obj)
+        {
+            if (_followDropdown == null)
+                return;
+
+            if (_followDropdown.SelectedId >= PlayerList.Count)
+                return;
+
+            var netEntity = PlayerList.ElementAt(_followDropdown.SelectedId).Value.Item1;
+
+            var msg = new GhostWarpToTargetRequestEvent(netEntity);
+            _entNetManager.SendSystemNetworkMessage(msg);
+        }
+
         private void VoteTypeSelected(OptionButton.ItemSelectedEventArgs obj)
         {
             VoteTypeButton.SelectId(obj.Id);
 
-            var (_, _, options) = AvailableVoteTypes[obj.Id];
-            if (options == null)
+            VoteNotTrustedLabel.Visible = false;
+            if ((StandardVoteType)obj.Id == StandardVoteType.Votekick)
             {
-                VoteSecondButton.Visible = false;
+                if (!IsAllowedVotekick)
+                {
+                    VoteNotTrustedLabel.Visible = true;
+                    var updatedDropdownOption = AvailableVoteOptions[StandardVoteType.Votekick];
+                    updatedDropdownOption.Dropdowns = new List<Dictionary<string, string>>();
+                    AvailableVoteOptions[StandardVoteType.Votekick] = updatedDropdownOption;
+                }
+                else
+                {
+                    _votingSystem.RequestVotePlayerList();
+                }
             }
-            else
-            {
-                VoteSecondButton.Visible = true;
-                VoteSecondButton.Clear();
 
-                for (var i = 0; i < options.Length; i++)
+            VoteWarningLabel.Visible = AvailableVoteOptions[(StandardVoteType)obj.Id].EnableVoteWarning;
+            FollowButton.Visible = false;
+
+            var voteList = AvailableVoteOptions[(StandardVoteType)obj.Id].Dropdowns;
+
+            VoteOptionsButtonContainer.RemoveAllChildren();
+            if (voteList != null)
+            {
+                int i = 0;
+                foreach (var voteDropdown in voteList)
                 {
-                    var (text, _) = options[i];
-                    VoteSecondButton.AddItem(Loc.GetString(text), i);
+                    var optionButton = new OptionButton();
+                    int j = 0;
+                    foreach (var (key, value) in voteDropdown)
+                    {
+                        optionButton.AddItem(Loc.GetString(value), j);
+                        j++;
+                    }
+                    VoteOptionsButtonContainer.AddChild(optionButton);
+                    optionButton.Visible = true;
+                    optionButton.OnItemSelected += ButtonSelected;
+                    optionButton.Margin = new Thickness(2, 1);
+                    if (AvailableVoteOptions[(StandardVoteType)obj.Id].FollowDropdownId != null && AvailableVoteOptions[(StandardVoteType)obj.Id].FollowDropdownId == i)
+                    {
+                        _followDropdown = optionButton;
+                        FollowButton.Visible = true;
+                    }
+                    i++;
                 }
             }
         }
@@ -168,4 +273,20 @@ namespace Content.Client.Voting.UI
             new VoteCallMenu().OpenCentered();
         }
     }
+
+    public record struct CreateVoteOption
+    {
+        public string Name;
+        public List<Dictionary<string, string>> Dropdowns;
+        public bool EnableVoteWarning;
+        public int? FollowDropdownId;  // If set, this will enable the Follow button and use the dropdown matching the ID as input.
+
+        public CreateVoteOption(string name, List<Dictionary<string, string>> dropdowns, bool enableVoteWarning, int? followDropdownId)
+        {
+            Name = name;
+            Dropdowns = dropdowns;
+            EnableVoteWarning = enableVoteWarning;
+            FollowDropdownId = followDropdownId;
+        }
+    }
 }
index fd40d7b790c591663227a5cf7583f516add1e598..aacefd33a8afde7fc31293aca7cbc451e220c60f 100644 (file)
@@ -1,10 +1,11 @@
-<Control xmlns="https://spacestation14.io" MinWidth="300" MaxWidth="500">
+<Control xmlns="https://spacestation14.io" MinWidth="300" MaxWidth="500">
     <PanelContainer StyleClasses="AngleRect" />
     <BoxContainer Margin="4" Orientation="Vertical">
         <Label Name="VoteCaller" />
         <RichTextLabel Name="VoteTitle" />
-
-        <GridContainer Columns="3" Name="VoteOptionsContainer" />
+        <Button Margin="4 4" Name="FollowVoteTarget" Text="{Loc 'ui-vote-follow-button-popup'}" Visible="False"></Button>
+        
+        <GridContainer Columns="3" Name="VoteOptionsContainer"/>
         <BoxContainer Orientation="Horizontal">
             <ProgressBar Margin="4" HorizontalExpand="True" Name="TimeLeftBar" MinValue="0" MaxValue="1" />
             <Label Name="TimeLeftText" />
index 6bcd18165a11e3b36d573bdc7988c498a1aaeae2..2a9a6b31f898fe2b6093ba11d6894ffa0d82982a 100644 (file)
@@ -1,12 +1,10 @@
-using System;
+using System;
 using Content.Client.Stylesheets;
+using Content.Shared.Ghost;
 using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.XAML;
-using Robust.Shared.IoC;
-using Robust.Shared.Localization;
-using Robust.Shared.Maths;
 using Robust.Shared.Timing;
 using Robust.Shared.Utility;
 
@@ -17,9 +15,11 @@ namespace Content.Client.Voting.UI
     {
         [Dependency] private readonly IGameTiming _gameTiming = default!;
         [Dependency] private readonly IVoteManager _voteManager = default!;
+        [Dependency] private readonly IEntityNetworkManager _net = default!;
 
         private readonly VoteManager.ActiveVote _vote;
         private readonly Button[] _voteButtons;
+        private readonly NetEntity? _targetEntity;
 
         public VotePopup(VoteManager.ActiveVote vote)
         {
@@ -29,6 +29,13 @@ namespace Content.Client.Voting.UI
 
             Stylesheet = IoCManager.Resolve<IStylesheetManager>().SheetSpace;
 
+            if (_vote.TargetEntity != null && _vote.TargetEntity != 0)
+            {
+                _targetEntity = new NetEntity(_vote.TargetEntity.Value);
+                FollowVoteTarget.Visible = true;
+                FollowVoteTarget.OnPressed += _ => AttemptFollowVoteEntity();
+            }
+
             Modulate = Color.White.WithAlpha(0.75f);
             _voteButtons = new Button[vote.Entries.Length];
             var group = new ButtonGroup();
@@ -55,13 +62,29 @@ namespace Content.Client.Voting.UI
             for (var i = 0; i < _voteButtons.Length; i++)
             {
                 var entry = _vote.Entries[i];
-                _voteButtons[i].Text = Loc.GetString("ui-vote-button", ("text", entry.Text), ("votes", entry.Votes));
+                if (_vote.DisplayVotes)
+                {
+                    _voteButtons[i].Text = Loc.GetString("ui-vote-button", ("text", entry.Text), ("votes", entry.Votes));
+                }
+                else
+                {
+                    _voteButtons[i].Text = Loc.GetString("ui-vote-button-no-votes", ("text", entry.Text));
+                }
 
                 if (_vote.OurVote == i)
                     _voteButtons[i].Pressed = true;
             }
         }
 
+        private void AttemptFollowVoteEntity()
+        {
+            if (_targetEntity != null)
+            {
+                var msg = new GhostWarpToTargetRequestEvent(_targetEntity.Value);
+                _net.SendSystemNetworkMessage(msg);
+            }
+        }
+
         protected override void FrameUpdate(FrameEventArgs args)
         {
             // Logger.Debug($"{_gameTiming.ServerTime}, {_vote.StartTime}, {_vote.EndTime}");
index a7c799b58feb6e2d6e831b4bf072efffe3b8fc1b..629adb36aa55a3c4885133f8c8fb9bd27e47a253 100644 (file)
@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using Content.Shared.Voting;
@@ -184,6 +184,8 @@ namespace Content.Client.Voting
             existingVote.Title = message.VoteTitle;
             existingVote.StartTime = _gameTiming.RealServerToLocal(message.StartTime);
             existingVote.EndTime = _gameTiming.RealServerToLocal(message.EndTime);
+            existingVote.DisplayVotes = message.DisplayVotes;
+            existingVote.TargetEntity = message.TargetEntity;
 
             // Logger.Debug($"{existingVote.StartTime}, {existingVote.EndTime}, {_gameTiming.RealTime}");
 
@@ -245,7 +247,8 @@ namespace Content.Client.Voting
             public string Initiator = "";
             public int? OurVote;
             public int Id;
-
+            public bool DisplayVotes;
+            public int? TargetEntity; // NetEntity
             public ActiveVote(int voteId)
             {
                 Id = voteId;
diff --git a/Content.Client/Voting/VotingSystem.cs b/Content.Client/Voting/VotingSystem.cs
new file mode 100644 (file)
index 0000000..d204917
--- /dev/null
@@ -0,0 +1,34 @@
+using Content.Client.Ghost;
+using Content.Shared.Voting;
+
+namespace Content.Client.Voting;
+
+public sealed class VotingSystem : EntitySystem
+{
+
+    public event Action<VotePlayerListResponseEvent>? VotePlayerListResponse; //Provides a list of players elligble for vote actions
+
+    [Dependency] private readonly GhostSystem _ghostSystem = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeNetworkEvent<VotePlayerListResponseEvent>(OnVotePlayerListResponseEvent);
+    }
+
+    private void OnVotePlayerListResponseEvent(VotePlayerListResponseEvent msg)
+    {
+        if (!_ghostSystem.IsGhost)
+        {
+            return;
+        }
+
+        VotePlayerListResponse?.Invoke(msg);
+    }
+
+    public void RequestVotePlayerList()
+    {
+        RaiseNetworkEvent(new VotePlayerListRequestEvent());
+    }
+}
index 869f2017d70304bc22eedef51ae23e7bf74eef24..f5c31c5b1eb4c1e7e67381cff262f98cc3880ac2 100644 (file)
@@ -1,4 +1,4 @@
-using Content.Server.Voting.Managers;
+using Content.Server.Voting.Managers;
 using Robust.Shared.Player;
 
 namespace Content.Server.Voting
@@ -43,6 +43,11 @@ namespace Content.Server.Voting
         /// </remarks>
         bool Cancelled { get; }
 
+        /// <summary>
+        /// Dictionary of votes cast by players, matching the option's id.
+        /// </summary>
+        IReadOnlyDictionary<ICommonSession, int> CastVotes { get; }
+
         /// <summary>
         /// Current count of votes per option type.
         /// </summary>
index d95fac9ae9212093490e392c20400e1f3506dd4c..03221f5a67bc801ac980cc96135fa2d77778eadf 100644 (file)
@@ -1,4 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
 using Content.Shared.Voting;
 using Robust.Shared.Player;
 
@@ -51,7 +51,7 @@ namespace Content.Server.Voting.Managers
         /// If null it is assumed to be an automatic vote by the server.
         /// </param>
         /// <param name="voteType">The type of standard vote to make.</param>
-        void CreateStandardVote(ICommonSession? initiator, StandardVoteType voteType);
+        void CreateStandardVote(ICommonSession? initiator, StandardVoteType voteType, string[]? args = null);
 
         /// <summary>
         /// Create a non-standard vote with special parameters.
index c479f7498953c0c8c0d4c33664107e8b7b37721b..0f7d238518809520f3a4097bc9995e22b5fe01e5 100644 (file)
@@ -1,11 +1,18 @@
 using System.Linq;
+using Content.Server.Administration;
+using Content.Server.Administration.Managers;
+using Content.Server.Database;
 using Content.Server.GameTicking;
 using Content.Server.GameTicking.Presets;
 using Content.Server.Maps;
+using Content.Server.Roles;
 using Content.Server.RoundEnd;
 using Content.Shared.CCVar;
+using Content.Shared.Chat;
 using Content.Shared.Database;
 using Content.Shared.Ghost;
+using Content.Shared.Players;
+using Content.Shared.Players.PlayTimeTracking;
 using Content.Shared.Voting;
 using Robust.Shared.Configuration;
 using Robust.Shared.Enums;
@@ -16,20 +23,33 @@ namespace Content.Server.Voting.Managers
 {
     public sealed partial class VoteManager
     {
+        [Dependency] private readonly IPlayerLocator _locator = default!;
+        [Dependency] private readonly ILogManager _logManager = default!;
+        [Dependency] private readonly IBanManager _bans = default!;
+        [Dependency] private readonly IServerDbManager _dbManager = default!;
+
+        private VotingSystem? _votingSystem;
+        private RoleSystem? _roleSystem;
+
         private static readonly Dictionary<StandardVoteType, CVarDef<bool>> _voteTypesToEnableCVars = new()
         {
             {StandardVoteType.Restart, CCVars.VoteRestartEnabled},
             {StandardVoteType.Preset, CCVars.VotePresetEnabled},
             {StandardVoteType.Map, CCVars.VoteMapEnabled},
+            {StandardVoteType.Votekick, CCVars.VotekickEnabled}
         };
 
-        public void CreateStandardVote(ICommonSession? initiator, StandardVoteType voteType)
+        public void CreateStandardVote(ICommonSession? initiator, StandardVoteType voteType, string[]? args = null)
         {
-            if (initiator != null)
+            if (initiator != null && args == null)
                 _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"{initiator} initiated a {voteType.ToString()} vote");
+            else if (initiator != null && args != null)
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"{initiator} initiated a {voteType.ToString()} vote with the arguments: {String.Join(",", args)}");
             else
                 _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Initiated a {voteType.ToString()} vote");
 
+            bool timeoutVote = true;
+
             switch (voteType)
             {
                 case StandardVoteType.Restart:
@@ -41,12 +61,17 @@ namespace Content.Server.Voting.Managers
                 case StandardVoteType.Map:
                     CreateMapVote(initiator);
                     break;
+                case StandardVoteType.Votekick:
+                    timeoutVote = false; // Allows the timeout to be updated manually in the create method
+                    CreateVotekickVote(initiator, args);
+                    break;
                 default:
                     throw new ArgumentOutOfRangeException(nameof(voteType), voteType, null);
             }
             var ticker = _entityManager.EntitySysManager.GetEntitySystem<GameTicker>();
             ticker.UpdateInfoText();
-            TimeoutStandardVote(voteType);
+            if (timeoutVote)
+                TimeoutStandardVote(voteType);
         }
 
         private void CreateRestartVote(ICommonSession? initiator)
@@ -56,104 +81,127 @@ namespace Content.Server.Voting.Managers
             var totalPlayers = _playerManager.Sessions.Count(session => session.Status != SessionStatus.Disconnected);
 
             var ghostVotePercentageRequirement = _cfg.GetCVar(CCVars.VoteRestartGhostPercentage);
-            var ghostCount = 0;
-            
-            foreach (var player in _playerManager.Sessions)
+            var ghostVoterPercentage = CalculateEligibleVoterPercentage(VoterEligibility.Ghost);
+
+            if (totalPlayers <= playerVoteMaximum || ghostVoterPercentage >= ghostVotePercentageRequirement)
             {
-                _playerManager.UpdateState(player);
-                if (player.Status != SessionStatus.Disconnected && _entityManager.HasComponent<GhostComponent>(player.AttachedEntity))
-                {
-                    ghostCount++;
-                }
+                StartVote(initiator);
             }
-
-            var ghostPercentage = 0.0;
-            if (totalPlayers > 0)
+            else
             {
-                ghostPercentage = ((double)ghostCount / totalPlayers) * 100;
+                NotifyNotEnoughGhostPlayers(ghostVotePercentageRequirement, ghostVoterPercentage);
             }
+        }
 
-            var roundedGhostPercentage = (int)Math.Round(ghostPercentage);
+        /// <summary>
+        /// Gives the current percentage of players eligible to vote, rounded to nearest percentage point.
+        /// </summary>
+        /// <param name="eligibility">The eligibility requirement to vote.</param>
+        public int CalculateEligibleVoterPercentage(VoterEligibility eligibility)
+        {
+            var eligibleCount = CalculateEligibleVoterNumber(eligibility);
+            var totalPlayers = _playerManager.Sessions.Count(session => session.Status != SessionStatus.Disconnected);
 
-            if (totalPlayers <= playerVoteMaximum || roundedGhostPercentage >= ghostVotePercentageRequirement)
+            var eligiblePercentage = 0.0;
+            if (totalPlayers > 0)
             {
-                StartVote(initiator);
+                eligiblePercentage = ((double)eligibleCount / totalPlayers) * 100;
             }
-            else
+
+            var roundedEligiblePercentage = (int)Math.Round(eligiblePercentage);
+
+            return roundedEligiblePercentage;
+        }
+
+        /// <summary>
+        /// Gives the current number of players eligible to vote.
+        /// </summary>
+        /// <param name="eligibility">The eligibility requirement to vote.</param>
+        public int CalculateEligibleVoterNumber(VoterEligibility eligibility)
+        {
+            var eligibleCount = 0;
+
+            foreach (var player in _playerManager.Sessions)
             {
-                NotifyNotEnoughGhostPlayers(ghostVotePercentageRequirement, roundedGhostPercentage);
+                _playerManager.UpdateState(player);
+                if (player.Status != SessionStatus.Disconnected && CheckVoterEligibility(player, eligibility))
+                {
+                    eligibleCount++;
+                }
             }
+
+            return eligibleCount;
         }
 
         private void StartVote(ICommonSession? initiator)
         {
             var alone = _playerManager.PlayerCount == 1 && initiator != null;
-                var options = new VoteOptions
+            var options = new VoteOptions
+            {
+                Title = Loc.GetString("ui-vote-restart-title"),
+                Options =
                 {
-                    Title = Loc.GetString("ui-vote-restart-title"),
-                    Options =
-                    {
-                        (Loc.GetString("ui-vote-restart-yes"), "yes"),
-                        (Loc.GetString("ui-vote-restart-no"), "no"),
-                        (Loc.GetString("ui-vote-restart-abstain"), "abstain")
-                    },
-                    Duration = alone
-                        ? TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerAlone))
-                        : TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerRestart)),
-                    InitiatorTimeout = TimeSpan.FromMinutes(5)
-                };
+                    (Loc.GetString("ui-vote-restart-yes"), "yes"),
+                    (Loc.GetString("ui-vote-restart-no"), "no"),
+                    (Loc.GetString("ui-vote-restart-abstain"), "abstain")
+                },
+                Duration = alone
+                    ? TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerAlone))
+                    : TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerRestart)),
+                InitiatorTimeout = TimeSpan.FromMinutes(5)
+            };
+
+            if (alone)
+                options.InitiatorTimeout = TimeSpan.FromSeconds(10);
 
-                if (alone)
-                    options.InitiatorTimeout = TimeSpan.FromSeconds(10);
+            WirePresetVoteInitiator(options, initiator);
 
-                WirePresetVoteInitiator(options, initiator);
+            var vote = CreateVote(options);
 
-                var vote = CreateVote(options);
+            vote.OnFinished += (_, _) =>
+            {
+                var votesYes = vote.VotesPerOption["yes"];
+                var votesNo = vote.VotesPerOption["no"];
+                var total = votesYes + votesNo;
 
-                vote.OnFinished += (_, _) =>
+                var ratioRequired = _cfg.GetCVar(CCVars.VoteRestartRequiredRatio);
+                if (total > 0 && votesYes / (float) total >= ratioRequired)
                 {
-                    var votesYes = vote.VotesPerOption["yes"];
-                    var votesNo = vote.VotesPerOption["no"];
-                    var total = votesYes + votesNo;
-
-                    var ratioRequired = _cfg.GetCVar(CCVars.VoteRestartRequiredRatio);
-                    if (total > 0 && votesYes / (float) total >= ratioRequired)
+                    // Check if an admin is online, and ignore the passed vote if the cvar is enabled
+                    if (_cfg.GetCVar(CCVars.VoteRestartNotAllowedWhenAdminOnline) && _adminMgr.ActiveAdmins.Count() != 0)
                     {
-                        // Check if an admin is online, and ignore the passed vote if the cvar is enabled
-                        if (_cfg.GetCVar(CCVars.VoteRestartNotAllowedWhenAdminOnline) && _adminMgr.ActiveAdmins.Count() != 0)
-                        {
-                            _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote attempted to pass, but an admin was online. {votesYes}/{votesNo}");
-                        }
-                        else // If the cvar is disabled or there's no admins on, proceed as normal
-                        {
-                            _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote succeeded: {votesYes}/{votesNo}");
-                            _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-restart-succeeded"));
-                            var roundEnd = _entityManager.EntitySysManager.GetEntitySystem<RoundEndSystem>();
-                            roundEnd.EndRound();
-                        }
+                        _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote attempted to pass, but an admin was online. {votesYes}/{votesNo}");
                     }
-                    else
+                    else // If the cvar is disabled or there's no admins on, proceed as normal
                     {
-                        _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote failed: {votesYes}/{votesNo}");
-                        _chatManager.DispatchServerAnnouncement(
-                            Loc.GetString("ui-vote-restart-failed", ("ratio", ratioRequired)));
+                        _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote succeeded: {votesYes}/{votesNo}");
+                        _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-restart-succeeded"));
+                        var roundEnd = _entityManager.EntitySysManager.GetEntitySystem<RoundEndSystem>();
+                        roundEnd.EndRound();
                     }
-                };
-
-                if (initiator != null)
+                }
+                else
                 {
-                    // Cast yes vote if created the vote yourself.
-                    vote.CastVote(initiator, 0);
+                    _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote failed: {votesYes}/{votesNo}");
+                    _chatManager.DispatchServerAnnouncement(
+                        Loc.GetString("ui-vote-restart-failed", ("ratio", ratioRequired)));
                 }
+            };
+
+            if (initiator != null)
+            {
+                // Cast yes vote if created the vote yourself.
+                vote.CastVote(initiator, 0);
+            }
 
-                foreach (var player in _playerManager.Sessions)
+            foreach (var player in _playerManager.Sessions)
+            {
+                if (player != initiator)
                 {
-                    if (player != initiator)
-                    {
-                        // Everybody else defaults to an abstain vote to say they don't mind.
-                        vote.CastVote(player, 2);
-                    }
+                    // Everybody else defaults to an abstain vote to say they don't mind.
+                    vote.CastVote(player, 2);
                 }
+            }
         }
 
         private void NotifyNotEnoughGhostPlayers(int ghostPercentageRequirement, int roundedGhostPercentage)
@@ -275,6 +323,230 @@ namespace Content.Server.Voting.Managers
             };
         }
 
+        private async void CreateVotekickVote(ICommonSession? initiator, string[]? args)
+        {
+            if (args == null || args.Length <= 1)
+            {
+                return;
+            }
+
+            if (_roleSystem == null)
+                _roleSystem = _entityManager.SystemOrNull<RoleSystem>();
+            if (_votingSystem == null)
+                _votingSystem = _entityManager.SystemOrNull<VotingSystem>();
+
+            // Check that the initiator is actually allowed to do a votekick.
+            if (_votingSystem != null && !await _votingSystem.CheckVotekickInitEligibility(initiator))
+            {
+                _logManager.GetSawmill("admin.votekick").Warning($"User {initiator} attempted a votekick, despite not being eligible to!");
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator}, but they are not eligible to votekick!");
+                DirtyCanCallVoteAll();
+                return;
+            }
+
+            var eligibleVoterNumberRequirement = _cfg.GetCVar(CCVars.VotekickEligibleNumberRequirement);
+            var eligibleVoterNumber = _cfg.GetCVar(CCVars.VotekickVoterGhostRequirement) ? CalculateEligibleVoterNumber(VoterEligibility.GhostMinimumPlaytime) : CalculateEligibleVoterNumber(VoterEligibility.MinimumPlaytime);
+
+            string target = args[0];
+            string reason = args[1];
+
+            // Start by getting all relevant target data
+            var located = await _locator.LookupIdByNameOrIdAsync(target);
+            if (located == null)
+            {
+                _logManager.GetSawmill("admin.votekick")
+                    .Warning($"Votekick attempted for player {target} but they couldn't be found!");
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player string {target}, but they could not be found!");
+                DirtyCanCallVoteAll();
+                return;
+            }
+            var targetUid = located.UserId;
+            var targetHWid = located.LastHWId;
+            if (!_playerManager.TryGetSessionById(located.UserId, out ICommonSession? targetSession))
+            {
+                _logManager.GetSawmill("admin.votekick")
+                    .Warning($"Votekick attempted for player {target} but their session couldn't be found!");
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player string {target}, but they could not be found!");
+                DirtyCanCallVoteAll();
+                return;
+            }
+
+            string targetEntityName = located.Username; // Target's player-facing name when voting; uses the player's username as fallback if no entity name is found
+            if (targetSession.AttachedEntity is { Valid: true } attached && _votingSystem != null)
+                targetEntityName = _votingSystem.GetPlayerVoteListName(attached);
+
+            var isAntagSafe = false;
+            var targetMind = targetSession.GetMind();
+            var playtime = _playtimeManager.GetPlayTimes(targetSession);
+
+            // Check whether the target is an antag, and if they are, give them protection against the Raider votekick if they have the requisite hours.
+            if (targetMind != null &&
+                _roleSystem != null &&
+                _roleSystem.MindIsAntagonist(targetMind) &&
+                playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) &&
+                overallTime >= TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickAntagRaiderProtection)))
+            {
+                isAntagSafe = true;
+            }
+
+
+            // Don't let a user votekick themselves
+            if (initiator == targetSession)
+            {
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for themselves? Votekick cancelled.");
+                DirtyCanCallVoteAll();
+                return;
+            }
+
+            // Cancels the vote if there's not enough voters; only the person initiating the vote gets a return message.
+            if (eligibleVoterNumber < eligibleVoterNumberRequirement)
+            {
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player {targetSession}, but there were not enough ghost roles! {eligibleVoterNumberRequirement} required, {eligibleVoterNumber} found.");
+                if (initiator != null)
+                {
+                    var message = Loc.GetString("ui-vote-votekick-not-enough-eligible", ("voters", eligibleVoterNumber.ToString()), ("requirement", eligibleVoterNumberRequirement.ToString()));
+                    var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message));
+                    _chatManager.ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, initiator.Channel);
+                }
+                DirtyCanCallVoteAll();
+                return;
+            }
+
+            // Check for stuff like the target being an admin. These targets shouldn't show up in the UI, but it's necessary to doublecheck in case someone writes the command in console.
+            if (_votingSystem != null && !_votingSystem.CheckVotekickTargetEligibility(targetSession))
+            {
+                _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player {targetSession}, but they are not eligible to be votekicked!");
+                DirtyCanCallVoteAll();
+                return;
+            }
+
+            // Create the vote object
+
+            string voteTitle = "";
+            NetEntity? targetNetEntity = _entityManager.GetNetEntity(targetSession.AttachedEntity);
+            var initiatorName = initiator != null ? initiator.Name : Loc.GetString("ui-vote-votekick-unknown-initiator");
+
+            voteTitle = Loc.GetString("ui-vote-votekick-title", ("initiator", initiatorName), ("targetEntity", targetEntityName), ("reason", reason));
+
+            var options = new VoteOptions
+            {
+                Title = voteTitle,
+                Options =
+                {
+                    (Loc.GetString("ui-vote-votekick-yes"), "yes"),
+                    (Loc.GetString("ui-vote-votekick-no"), "no"),
+                    (Loc.GetString("ui-vote-votekick-abstain"), "abstain")
+                },
+                Duration = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VotekickTimer)),
+                InitiatorTimeout = TimeSpan.FromMinutes(_cfg.GetCVar(CCVars.VotekickTimeout)),
+                VoterEligibility = _cfg.GetCVar(CCVars.VotekickVoterGhostRequirement) ? VoterEligibility.GhostMinimumPlaytime : VoterEligibility.MinimumPlaytime,
+                DisplayVotes = false,
+                TargetEntity = targetNetEntity
+            };
+
+            WirePresetVoteInitiator(options, initiator);
+
+            var vote = CreateVote(options);
+            _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} ({targetEntityName}) due to {reason} started, initiated by {initiator}.");
+
+            // Time out the vote now that we know it will happen
+            TimeoutStandardVote(StandardVoteType.Votekick);
+
+            vote.OnFinished += (_, _) =>
+            {
+
+                var votesYes = vote.VotesPerOption["yes"];
+                var votesNo = vote.VotesPerOption["no"];
+                var total = votesYes + votesNo;
+
+                // Get the voters, for logging purposes.
+                List<ICommonSession> yesVoters = new();
+                List<ICommonSession> noVoters = new();
+                foreach (var (voter, castVote) in vote.CastVotes)
+                {
+                    if (castVote == 0)
+                    {
+                        yesVoters.Add(voter);
+                    }
+                    if (castVote == 1)
+                    {
+                        noVoters.Add(voter);
+                    }
+                }
+                var yesVotersString = string.Join(", ", yesVoters);
+                var noVotersString = string.Join(", ", noVoters);
+
+                var ratioRequired = _cfg.GetCVar(CCVars.VotekickRequiredRatio);
+                if (total > 0 && votesYes / (float)total >= ratioRequired)
+                {
+                    // Some conditions that cancel the vote want to let the vote run its course first and then cancel it
+                    // so we check for that here
+
+                    // Check if an admin is online, and ignore the vote if the cvar is enabled
+                    if (_cfg.GetCVar(CCVars.VotekickNotAllowedWhenAdminOnline) && _adminMgr.ActiveAdmins.Count() != 0)
+                    {
+                        _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} attempted to pass, but an admin was online. Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}");
+                        AnnounceCancelledVotekickForVoters(targetEntityName);
+                        return;
+                    }
+                    // Check if the target is an antag and the vote reason is raiding (this is to prevent false positives)
+                    else if (isAntagSafe && reason == VotekickReasonType.Raiding.ToString())
+                    {
+                        _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} due to {reason} finished, created by {initiator}, but was cancelled due to the target being an antagonist.");
+                        AnnounceCancelledVotekickForVoters(targetEntityName);
+                        return;
+                    }
+                    // Check if the target is an admin/de-admined admin
+                    else if (targetSession.AttachedEntity != null && _adminMgr.IsAdmin(targetSession.AttachedEntity.Value, true))
+                    {
+                        _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} due to {reason} finished, created by {initiator}, but was cancelled due to the target being a de-admined admin.");
+                        AnnounceCancelledVotekickForVoters(targetEntityName);
+                        return;
+                    }
+                    else
+                    {
+                        _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} succeeded:  Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}");
+                        _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-votekick-success", ("target", targetEntityName), ("reason", reason)));
+
+                        if (!Enum.TryParse(_cfg.GetCVar(CCVars.VotekickBanDefaultSeverity), out NoteSeverity severity))
+                        {
+                            _logManager.GetSawmill("admin.votekick")
+                                .Warning("Votekick ban severity could not be parsed from config! Defaulting to high.");
+                            severity = NoteSeverity.High;
+                        }
+
+                        uint minutes = (uint)_cfg.GetCVar(CCVars.VotekickBanDuration);
+
+                        _bans.CreateServerBan(targetUid, target, null, null, targetHWid, minutes, severity, reason);
+                    }
+                }
+                else
+                {
+                    _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick failed: Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}");
+                    _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-votekick-failure", ("target", targetEntityName), ("reason", reason)));
+                }
+            };
+
+            if (initiator != null)
+            {
+                // Cast yes vote if created the vote yourself.
+                vote.CastVote(initiator, 0);
+            }
+        }
+
+        private void AnnounceCancelledVotekickForVoters(string target)
+        {
+            foreach (var player in _playerManager.Sessions)
+            {
+                if (CheckVoterEligibility(player, VoterEligibility.GhostMinimumPlaytime))
+                {
+                    var message = Loc.GetString("ui-vote-votekick-server-cancelled", ("target", target));
+                    var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message));
+                    _chatManager.ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, player.Channel);
+                }
+            }
+        }
+
         private void TimeoutStandardVote(StandardVoteType type)
         {
             var timeout = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteSameTypeTimeout));
index edb11f0bdb533db03d94602df8ad00d20df12f25..04e319164823f439575e1d189fc48d580c10bb3c 100644 (file)
@@ -11,6 +11,8 @@ using Content.Server.Maps;
 using Content.Shared.Administration;
 using Content.Shared.CCVar;
 using Content.Shared.Database;
+using Content.Shared.Ghost;
+using Content.Shared.Players.PlayTimeTracking;
 using Content.Shared.Voting;
 using Robust.Server.Player;
 using Robust.Shared.Configuration;
@@ -38,6 +40,7 @@ namespace Content.Server.Voting.Managers
         [Dependency] private readonly IGameMapManager _gameMapManager = default!;
         [Dependency] private readonly IEntityManager _entityManager = default!;
         [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+        [Dependency] private readonly ISharedPlaytimeManager _playtimeManager = default!; 
 
         private int _nextVoteId = 1;
 
@@ -209,7 +212,7 @@ namespace Content.Server.Voting.Managers
             var start = _timing.RealTime;
             var end = start + options.Duration;
             var reg = new VoteReg(id, entries, options.Title, options.InitiatorText,
-                options.InitiatorPlayer, start, end);
+                options.InitiatorPlayer, start, end, options.VoterEligibility, options.DisplayVotes, options.TargetEntity);
 
             var handle = new VoteHandle(this, reg);
 
@@ -245,12 +248,24 @@ namespace Content.Server.Voting.Managers
             msg.VoteId = v.Id;
             msg.VoteActive = !v.Finished;
 
+            if (!CheckVoterEligibility(player, v.VoterEligibility))
+            {
+                msg.VoteActive = false;
+                player.Channel.SendMessage(msg);
+                return;
+            }
+
             if (!v.Finished)
             {
                 msg.VoteTitle = v.Title;
                 msg.VoteInitiator = v.InitiatorText;
                 msg.StartTime = v.StartTime;
                 msg.EndTime = v.EndTime;
+
+                if (v.TargetEntity != null)
+                {
+                    msg.TargetEntity = v.TargetEntity.Value.Id;
+                }
             }
 
             if (v.CastVotes.TryGetValue(player, out var cast))
@@ -266,11 +281,17 @@ namespace Content.Server.Voting.Managers
                 }
             }
 
+            // Admin always see the vote count, even if the vote is set to hide it.
+            if (_adminMgr.HasAdminFlag(player, AdminFlags.Moderator))
+            {
+                msg.DisplayVotes = true;
+            }
+
             msg.Options = new (ushort votes, string name)[v.Entries.Length];
             for (var i = 0; i < msg.Options.Length; i++)
             {
                 ref var entry = ref v.Entries[i];
-                msg.Options[i] = ((ushort) entry.Votes, entry.Text);
+                msg.Options[i] = (msg.DisplayVotes ? (ushort) entry.Votes : (ushort) 0, entry.Text);
             }
 
             player.Channel.SendMessage(msg);
@@ -362,6 +383,16 @@ namespace Content.Server.Voting.Managers
                 return;
             }
 
+            // Remove ineligible votes that somehow slipped through
+            foreach (var playerVote in v.CastVotes)
+            {
+                if (!CheckVoterEligibility(playerVote.Key, v.VoterEligibility))
+                {
+                    v.Entries[playerVote.Value].Votes -= 1;
+                    v.CastVotes.Remove(playerVote.Key);
+                }
+            }
+
             // Find winner or stalemate.
             var winners = v.Entries
                 .GroupBy(e => e.Votes)
@@ -395,6 +426,37 @@ namespace Content.Server.Voting.Managers
             DirtyCanCallVoteAll();
         }
 
+        public bool CheckVoterEligibility(ICommonSession player, VoterEligibility eligibility)
+        {
+            if (eligibility == VoterEligibility.All)
+                return true;
+
+            if (eligibility == VoterEligibility.Ghost || eligibility == VoterEligibility.GhostMinimumPlaytime)
+            {
+                if (!_entityManager.TryGetComponent(player.AttachedEntity, out GhostComponent? ghostComp))
+                    return false;
+
+                if (eligibility == VoterEligibility.GhostMinimumPlaytime)
+                {
+                    var playtime = _playtimeManager.GetPlayTimes(player);
+                    if (!playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) || overallTime < TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickEligibleVoterPlaytime)))
+                        return false;
+
+                    if ((int)_timing.RealTime.Subtract(ghostComp.TimeOfDeath).TotalSeconds < _cfg.GetCVar(CCVars.VotekickEligibleVoterDeathtime))
+                        return false;
+                }
+            }
+
+            if (eligibility == VoterEligibility.MinimumPlaytime)
+            {
+                var playtime = _playtimeManager.GetPlayTimes(player);
+                if (!playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) || overallTime < TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickEligibleVoterPlaytime)))
+                    return false;
+            }
+
+            return true;
+        }
+
         public IEnumerable<IVoteHandle> ActiveVotes => _voteHandles.Values;
 
         public bool TryGetVote(int voteId, [NotNullWhen(true)] out IVoteHandle? vote)
@@ -442,6 +504,9 @@ namespace Content.Server.Voting.Managers
             public readonly TimeSpan StartTime;
             public readonly TimeSpan EndTime;
             public readonly HashSet<ICommonSession> VotesDirty = new();
+            public readonly VoterEligibility VoterEligibility;
+            public readonly bool DisplayVotes;
+            public readonly NetEntity? TargetEntity;
 
             public bool Cancelled;
             public bool Finished;
@@ -452,7 +517,7 @@ namespace Content.Server.Voting.Managers
             public ICommonSession? Initiator { get; }
 
             public VoteReg(int id, VoteEntry[] entries, string title, string initiatorText,
-                ICommonSession? initiator, TimeSpan start, TimeSpan end)
+                ICommonSession? initiator, TimeSpan start, TimeSpan end, VoterEligibility voterEligibility, bool displayVotes, NetEntity? targetEntity)
             {
                 Id = id;
                 Entries = entries;
@@ -461,6 +526,9 @@ namespace Content.Server.Voting.Managers
                 Initiator = initiator;
                 StartTime = start;
                 EndTime = end;
+                VoterEligibility = voterEligibility;
+                DisplayVotes = displayVotes;
+                TargetEntity = targetEntity;
             }
         }
 
@@ -478,6 +546,14 @@ namespace Content.Server.Voting.Managers
             }
         }
 
+        public enum VoterEligibility
+        {
+            All,
+            Ghost, // Player needs to be a ghost
+            GhostMinimumPlaytime, // Player needs to be a ghost, with a minimum playtime and deathtime as defined by votekick CCvars.
+            MinimumPlaytime //Player needs to have a minimum playtime and deathtime as defined by votekick CCvars.
+        }
+
         #endregion
 
         #region IVoteHandle API surface
@@ -492,6 +568,7 @@ namespace Content.Server.Voting.Managers
             public string InitiatorText => _reg.InitiatorText;
             public bool Finished => _reg.Finished;
             public bool Cancelled => _reg.Cancelled;
+            public IReadOnlyDictionary<ICommonSession, int> CastVotes => _reg.CastVotes;
 
             public IReadOnlyDictionary<object, int> VotesPerOption { get; }
 
index c5ceccfc11f71c11fe7d11af497592ac4fbdd876..7dbb41b50fefb3ef2feb52074fcc2e6484ea06f4 100644 (file)
@@ -29,11 +29,17 @@ namespace Content.Server.Voting
 
         public void Execute(IConsoleShell shell, string argStr, string[] args)
         {
-            if (args.Length != 1)
+            if (args.Length != 1 && args[0] != StandardVoteType.Votekick.ToString())
             {
                 shell.WriteError(Loc.GetString("shell-need-exactly-one-argument"));
                 return;
             }
+            if (args.Length != 3 && args[0] == StandardVoteType.Votekick.ToString())
+            {
+                shell.WriteError(Loc.GetString("shell-wrong-arguments-number-need-specific", ("properAmount", 3), ("currentAmount", args.Length)));
+                return;
+            }
+
 
             if (!Enum.TryParse<StandardVoteType>(args[0], ignoreCase: true, out var type))
             {
@@ -50,7 +56,7 @@ namespace Content.Server.Voting
                 return;
             }
 
-            mgr.CreateStandardVote(shell.Player, type);
+            mgr.CreateStandardVote(shell.Player, type, args.Skip(1).ToArray());
         }
 
         public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
index 5475d10d3297afc2706d65141ff5d820d9e76dd2..e0647fb2d0f4c4606a5ef311a88c67c2d289bc06 100644 (file)
@@ -1,6 +1,6 @@
+using Content.Server.Voting.Managers;
 using Robust.Shared.Player;
 
-
 namespace Content.Server.Voting
 {
     /// <summary>
@@ -39,6 +39,21 @@ namespace Content.Server.Voting
         /// </summary>
         public List<(string text, object data)> Options { get; set; } = new();
 
+        /// <summary>
+        ///     Which sessions may send a vote. Used when only a subset of players should be able to vote. Defaults to all.
+        /// </summary>
+        public VoteManager.VoterEligibility VoterEligibility = VoteManager.VoterEligibility.All;
+
+        /// <summary>
+        ///     Whether the vote should send and display the number of votes to the clients. Being an admin defaults this option to true for your client.
+        /// </summary>
+        public bool DisplayVotes = true;
+
+        /// <summary>
+        ///     Whether the vote should have an entity attached to it, to be used for things like letting ghosts follow it. 
+        /// </summary>
+        public NetEntity? TargetEntity = null;
+
         /// <summary>
         ///     Sets <see cref="InitiatorPlayer"/> and <see cref="InitiatorText"/>
         ///     by setting the latter to the player's name.
diff --git a/Content.Server/Voting/VotingSystem.cs b/Content.Server/Voting/VotingSystem.cs
new file mode 100644 (file)
index 0000000..25475c2
--- /dev/null
@@ -0,0 +1,122 @@
+using Content.Server.Administration.Managers;
+using Content.Server.Database;
+using Content.Server.Ghost;
+using Content.Server.Roles.Jobs;
+using Content.Shared.CCVar;
+using Content.Shared.Ghost;
+using Content.Shared.Mind.Components;
+using Content.Shared.Voting;
+using Robust.Server.Player;
+using Robust.Shared.Configuration;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+using System.Threading.Tasks;
+
+namespace Content.Server.Voting;
+
+public sealed class VotingSystem : EntitySystem
+{
+
+    [Dependency] private readonly IPlayerManager _playerManager = default!;
+    [Dependency] private readonly IAdminManager _adminManager = default!;
+    [Dependency] private readonly IServerDbManager _dbManager = default!;
+    [Dependency] private readonly IGameTiming _gameTiming = default!;
+    [Dependency] private readonly IConfigurationManager _cfg = default!;
+    [Dependency] private readonly JobSystem _jobs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeNetworkEvent<VotePlayerListRequestEvent>(OnVotePlayerListRequestEvent);
+    }
+
+    private async void OnVotePlayerListRequestEvent(VotePlayerListRequestEvent msg, EntitySessionEventArgs args)
+    {
+        if (args.SenderSession.AttachedEntity is not { Valid: true } entity
+            || !await CheckVotekickInitEligibility(args.SenderSession))
+        {
+            var deniedResponse = new VotePlayerListResponseEvent(new (NetUserId, NetEntity, string)[0], true);
+            RaiseNetworkEvent(deniedResponse, args.SenderSession.Channel);
+            return;
+        }
+
+        List<(NetUserId, NetEntity, string)> players = new();
+
+        foreach (var player in _playerManager.Sessions)
+        {
+            if (player.AttachedEntity is not { Valid: true } attached)
+                continue;
+
+            if (attached == entity) continue;
+
+            if (_adminManager.IsAdmin(player, false)) continue;
+
+            var playerName = GetPlayerVoteListName(attached);
+            var netEntity = GetNetEntity(attached);
+
+            players.Add((player.UserId, netEntity, playerName));
+        }
+
+        var response = new VotePlayerListResponseEvent(players.ToArray(), false);
+        RaiseNetworkEvent(response, args.SenderSession.Channel);
+    }
+
+    public string GetPlayerVoteListName(EntityUid attached)
+    {
+        TryComp<MindContainerComponent>(attached, out var mind);
+
+        var jobName = _jobs.MindTryGetJobName(mind?.Mind);
+        var playerInfo = $"{Comp<MetaDataComponent>(attached).EntityName} ({jobName})";
+
+        return playerInfo;
+    }
+
+    /// <summary>
+    /// Used to check whether the player initiating a votekick is allowed to do so serverside.
+    /// </summary>
+    /// <param name="initiator">The session initiating the votekick.</param>
+    public async Task<bool> CheckVotekickInitEligibility(ICommonSession? initiator)
+    {
+        if (initiator == null)
+            return false;
+
+        // Being an admin overrides the votekick eligibility
+        if (initiator.AttachedEntity != null && _adminManager.IsAdmin(initiator.AttachedEntity.Value, false))
+            return true;
+
+        if (_cfg.GetCVar(CCVars.VotekickInitiatorGhostRequirement))
+        {
+            // Must be ghost
+            if (!TryComp(initiator.AttachedEntity, out GhostComponent? ghostComp))
+                return false;
+
+            // Must have been dead for x seconds
+            if ((int)_gameTiming.RealTime.Subtract(ghostComp.TimeOfDeath).TotalSeconds < _cfg.GetCVar(CCVars.VotekickEligibleVoterDeathtime))
+                return false;
+        }
+
+        // Must be whitelisted
+        if (!await _dbManager.GetWhitelistStatusAsync(initiator.UserId))
+            return false;
+
+        return true;
+    }
+
+    /// <summary>
+    /// Used to check whether the player being targetted for a votekick is a valid target.
+    /// </summary>
+    /// <param name="target">The session being targetted for a votekick.</param>
+    public bool CheckVotekickTargetEligibility(ICommonSession? target)
+    {
+        if (target == null)
+            return false;
+
+        // Admins can't be votekicked
+        if (target.AttachedEntity != null && _adminManager.IsAdmin(target.AttachedEntity.Value))
+            return false;
+
+        return true;
+    }
+}
index 26101c7537ee4dfc7efc44596753443aa8cef013..be97dd93a80452b1844b89c2f45f6caa53876602 100644 (file)
@@ -1404,7 +1404,6 @@ namespace Content.Shared.CCVar
         public static readonly CVarDef<float> VoteSameTypeTimeout =
             CVarDef.Create("vote.same_type_timeout", 240f, CVar.SERVERONLY);
 
-
         /// <summary>
         ///     Sets the duration of the map vote timer.
         /// </summary>
@@ -1429,6 +1428,87 @@ namespace Content.Shared.CCVar
         public static readonly CVarDef<int>
             VoteTimerAlone = CVarDef.Create("vote.timeralone", 10, CVar.SERVERONLY);
 
+        /*
+         * VOTEKICK
+         */
+
+        /// <summary>
+        ///     Allows enabling/disabling player-started votekick for ultimate authority
+        /// </summary>
+        public static readonly CVarDef<bool> VotekickEnabled =
+            CVarDef.Create("votekick.enabled", true, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Config for when the votekick should be allowed to be called based on number of eligible voters.
+        /// </summary>
+        public static readonly CVarDef<int> VotekickEligibleNumberRequirement =
+            CVarDef.Create("votekick.eligible_number", 10, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Whether a votekick initiator must be a ghost or not.
+        /// </summary>
+        public static readonly CVarDef<bool> VotekickInitiatorGhostRequirement =
+            CVarDef.Create("votekick.initiator_ghost_requirement", true, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Whether a votekick voter must be a ghost or not.
+        /// </summary>
+        public static readonly CVarDef<bool> VotekickVoterGhostRequirement =
+            CVarDef.Create("votekick.voter_ghost_requirement", true, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Config for how many hours playtime a player must have to be able to vote on a votekick.
+        /// </summary>
+        public static readonly CVarDef<int> VotekickEligibleVoterPlaytime =
+            CVarDef.Create("votekick.voter_playtime", 100, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Config for how many seconds a player must have been dead to initiate a votekick / be able to vote on a votekick.
+        /// </summary>
+        public static readonly CVarDef<int> VotekickEligibleVoterDeathtime =
+            CVarDef.Create("votekick.voter_deathtime", 180, CVar.REPLICATED | CVar.SERVER);
+
+        /// <summary>
+        ///     The required ratio of eligible voters that must agree for a votekick to go through.
+        /// </summary>
+        public static readonly CVarDef<float> VotekickRequiredRatio =
+            CVarDef.Create("votekick.required_ratio", 0.6f, CVar.SERVERONLY);
+
+        /// <summary>
+        /// Whether or not to prevent the votekick from having any effect when there is an online admin.
+        /// </summary>
+        public static readonly CVarDef<bool> VotekickNotAllowedWhenAdminOnline =
+            CVarDef.Create("votekick.not_allowed_when_admin_online", true, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     The delay for which two votekicks are allowed to be made by separate people, in seconds.
+        /// </summary>
+        public static readonly CVarDef<float> VotekickTimeout =
+            CVarDef.Create("votekick.timeout", 120f, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Sets the duration of the votekick vote timer.
+        /// </summary>
+        public static readonly CVarDef<int>
+            VotekickTimer = CVarDef.Create("votekick.timer", 60, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Config for how many hours playtime a player must have to get protection from the Raider votekick type when playing as an antag.
+        /// </summary>
+        public static readonly CVarDef<int> VotekickAntagRaiderProtection =
+            CVarDef.Create("votekick.antag_raider_protection", 10, CVar.SERVERONLY);
+
+        /// <summary>
+        ///     Default severity for votekick bans
+        /// </summary>
+        public static readonly CVarDef<string> VotekickBanDefaultSeverity =
+            CVarDef.Create("votekick.ban_default_severity", "High", CVar.ARCHIVE | CVar.SERVER | CVar.REPLICATED);
+
+        /// <summary>
+        ///     Duration of a ban caused by a votekick (in minutes).
+        /// </summary>
+        public static readonly CVarDef<int> VotekickBanDuration =
+            CVarDef.Create("votekick.ban_duration", 180, CVar.SERVERONLY);
 
         /*
          * BAN
index 49050c3493c0944b8e118d6c16183755fdd6d23a..c9d9d49887ee135abcf5baddb55b5c96756f82af 100644 (file)
@@ -1,4 +1,4 @@
-using Lidgren.Network;
+using Lidgren.Network;
 using Robust.Shared.Network;
 using Robust.Shared.Serialization;
 
@@ -17,6 +17,8 @@ namespace Content.Shared.Voting
         public (ushort votes, string name)[] Options = default!;
         public bool IsYourVoteDirty;
         public byte? YourVote;
+        public bool DisplayVotes;
+        public int TargetEntity;
 
         public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
         {
@@ -31,6 +33,8 @@ namespace Content.Shared.Voting
             VoteInitiator = buffer.ReadString();
             StartTime = TimeSpan.FromTicks(buffer.ReadInt64());
             EndTime = TimeSpan.FromTicks(buffer.ReadInt64());
+            DisplayVotes = buffer.ReadBoolean();
+            TargetEntity = buffer.ReadVariableInt32();
 
             Options = new (ushort votes, string name)[buffer.ReadByte()];
             for (var i = 0; i < Options.Length; i++)
@@ -58,6 +62,8 @@ namespace Content.Shared.Voting
             buffer.Write(VoteInitiator);
             buffer.Write(StartTime.Ticks);
             buffer.Write(EndTime.Ticks);
+            buffer.Write(DisplayVotes);
+            buffer.WriteVariableInt32(TargetEntity);
 
             buffer.Write((byte) Options.Length);
             foreach (var (votes, name) in Options)
index a0b33359acd21a958c9099d49022a99f0a019596..b53d9a9df70e9463486cc40fe7fcd41d011eb609 100644 (file)
@@ -1,23 +1,37 @@
-namespace Content.Shared.Voting
+namespace Content.Shared.Voting;
+
+/// <summary>
+/// Standard vote types that players can initiate themselves from the escape menu.
+/// </summary>
+public enum StandardVoteType : byte
 {
     /// <summary>
-    /// Standard vote types that players can initiate themselves from the escape menu.
+    /// Vote to restart the round.
+    /// </summary>
+    Restart,
+
+    /// <summary>
+    /// Vote to change the game preset for next round.
     /// </summary>
-    public enum StandardVoteType : byte
-    {
-        /// <summary>
-        /// Vote to restart the round.
-        /// </summary>
-        Restart,
+    Preset,
 
-        /// <summary>
-        /// Vote to change the game preset for next round.
-        /// </summary>
-        Preset,
+    /// <summary>
+    /// Vote to change the map for the next round.
+    /// </summary>
+    Map,
 
-        /// <summary>
-        /// Vote to change the map for the next round.
-        /// </summary>
-        Map
-    }
+    /// <summary>
+    /// Vote to kick a player.
+    /// </summary>
+    Votekick
+}
+
+/// <summary>
+/// Reasons available to initiate a votekick.
+/// </summary>
+public enum VotekickReasonType : byte
+{
+    Raiding,
+    Cheating,
+    Spam
 }
diff --git a/Content.Shared/Voting/VotingEvents.cs b/Content.Shared/Voting/VotingEvents.cs
new file mode 100644 (file)
index 0000000..548bb0d
--- /dev/null
@@ -0,0 +1,30 @@
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Voting;
+
+[Serializable, NetSerializable]
+public sealed class VotePlayerListRequestEvent : EntityEventArgs
+{
+
+}
+
+[Serializable, NetSerializable]
+public sealed class VotePlayerListResponseEvent : EntityEventArgs
+{
+    public VotePlayerListResponseEvent((NetUserId, NetEntity, string)[] players, bool denied)
+    {
+        Players = players;
+        Denied = denied;
+    }
+
+    /// <summary>
+    /// The players available to have a votekick started for them.
+    /// </summary>
+    public (NetUserId, NetEntity, string)[] Players { get; }
+
+    /// <summary>
+    /// Whether the server will allow the user to start a votekick or not.
+    /// </summary>
+    public bool Denied;
+}
index 1825fc5ea9452d99620a58602a602f0d01cc9c0f..7fd534db3048c5d8e6171f52c58f920dd1a74434 100644 (file)
@@ -20,3 +20,16 @@ ui-vote-map-tie = Tie for map vote! Picking... { $picked }
 ui-vote-map-win = { $winner } won the map vote!
 ui-vote-map-notlobby = Voting for maps is only valid in the pre-round lobby!
 ui-vote-map-notlobby-time = Voting for maps is only valid in the pre-round lobby with { $time } remaining!
+
+
+# Votekick votes
+ui-vote-votekick-unknown-initiator = A player
+ui-vote-votekick-unknown-target = Unknown Player
+ui-vote-votekick-title = { $initiator } has called a votekick for user: { $targetEntity }. Reason: { $reason }
+ui-vote-votekick-yes = Yes
+ui-vote-votekick-no = No
+ui-vote-votekick-abstain = Abstain
+ui-vote-votekick-success = Votekick for { $target } succeeded. Votekick reason: { $reason }
+ui-vote-votekick-failure = Votekick for { $target } failed. Votekick reason: { $reason }
+ui-vote-votekick-not-enough-eligible = Not enough eligible voters online to start a votekick: { $voters }/{ $requirement }
+ui-vote-votekick-server-cancelled = Votekick for { $target } was cancelled by the server.
index 04ccd2851332118b395b35475ea4978ccce6e7e9..7ac9c344fde21f350d98f0db924d912d8fc9f596 100644 (file)
@@ -1,6 +1,12 @@
 ui-vote-type-restart = Restart round
 ui-vote-type-gamemode = Next gamemode
 ui-vote-type-map = Next map
+ui-vote-type-votekick = Votekick
+
+# Votekick reasons
+ui-vote-votekick-type-raiding = Raiding
+ui-vote-votekick-type-cheating = Cheating
+ui-vote-votekick-type-spamming = Spamming
 
 # Window title of the vote create menu
 ui-vote-create-title = Call Vote
@@ -8,12 +14,25 @@ ui-vote-create-title = Call Vote
 # Submit button in the vote create button
 ui-vote-create-button = Call Vote
 
+# Follow button in the vote create menu
+ui-vote-follow-button = Follow User
+
 # Timeout text if a standard vote type is currently on timeout.
 ui-vote-type-timeout = This vote was called too recently ({$remaining})
 
 # Unavailable text if a vote type has been disabled manually.
 ui-vote-type-not-available = This vote type has been disabled
 
+# Vote option only available for specific users.
+ui-vote-trusted-users-notice =
+  This vote option is only available to whitelisted players.
+  In addition, you must have been a ghost for { $timeReq } minutes.
+
+# Warning to not abuse a specific vote option.
+ui-vote-abuse-warning =
+  Warning!
+  Abuse of the votekick system may result in an indefinite ban!
+
 # Hue hue hue
 ui-vote-fluff = Powered by Robust™ Anti-Tamper Technology
 
index 7e8a9f4273d5972ddef714149cc9bda88085d2dc..c03abea1f85f0e2e0dbd1742920731e9e59e8a90 100644 (file)
@@ -1,2 +1,4 @@
 ui-vote-created = { $initiator } has called a vote:
-ui-vote-button  = { $text } ({ $votes })
\ No newline at end of file
+ui-vote-button  = { $text } ({ $votes })
+ui-vote-button-no-votes  = { $text }
+ui-vote-follow-button-popup = Follow User