Add Votekick functionality (#32005)

This commit is contained in:
SlamBamActionman
2024-09-26 18:32:13 +02:00
committed by GitHub
parent 4491550a5e
commit eeadc75b0a
20 changed files with 1009 additions and 164 deletions

View 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">
@@ -13,16 +13,18 @@
<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>
<Label Name="VoteTypeTimeoutLabel" Visible="False" />
<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 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'}" />

View 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 =
private VotingSystem _votingSystem;
public StandardVoteType Type;
public Dictionary<StandardVoteType, CreateVoteOption> AvailableVoteOptions = new Dictionary<StandardVoteType, CreateVoteOption>()
{
("ui-vote-type-restart", StandardVoteType.Restart, null),
("ui-vote-type-gamemode", StandardVoteType.Preset, null),
("ui-vote-type-map", StandardVoteType.Map, null)
{ 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 commandArgs = "";
if (voteType.Dropdowns == null || voteType.Dropdowns.Count == 0)
{
var secondaryId = VoteSecondButton.SelectedId;
var (_, secondKey) = secondaries[secondaryId];
_consoleHost.LocalShell.RemoteExecuteCommand($"createvote {typeKey} {secondKey}");
_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);
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
{
VoteSecondButton.Visible = true;
VoteSecondButton.Clear();
_votingSystem.RequestVotePlayerList();
}
}
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)
{
var (text, _) = options[i];
VoteSecondButton.AddItem(Loc.GetString(text), i);
int i = 0;
foreach (var voteDropdown in voteList)
{
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;
}
}
}

View 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" />
<Button Margin="4 4" Name="FollowVoteTarget" Text="{Loc 'ui-vote-follow-button-popup'}" Visible="False"></Button>
<GridContainer Columns="3" Name="VoteOptionsContainer" />
<GridContainer Columns="3" Name="VoteOptionsContainer"/>
<BoxContainer Orientation="Horizontal">
<ProgressBar Margin="4" HorizontalExpand="True" Name="TimeLeftBar" MinValue="0" MaxValue="1" />
<Label Name="TimeLeftText" />

View 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];
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}");

View 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;

View File

@@ -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());
}
}

View 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>

View 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.

View 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,11 +61,16 @@ 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();
if (timeoutVote)
TimeoutStandardVote(voteType);
}
@@ -56,35 +81,58 @@ namespace Content.Server.Voting.Managers
var totalPlayers = _playerManager.Sessions.Count(session => session.Status != SessionStatus.Disconnected);
var ghostVotePercentageRequirement = _cfg.GetCVar(CCVars.VoteRestartGhostPercentage);
var ghostCount = 0;
var ghostVoterPercentage = CalculateEligibleVoterPercentage(VoterEligibility.Ghost);
foreach (var player in _playerManager.Sessions)
{
_playerManager.UpdateState(player);
if (player.Status != SessionStatus.Disconnected && _entityManager.HasComponent<GhostComponent>(player.AttachedEntity))
{
ghostCount++;
}
}
var ghostPercentage = 0.0;
if (totalPlayers > 0)
{
ghostPercentage = ((double)ghostCount / totalPlayers) * 100;
}
var roundedGhostPercentage = (int)Math.Round(ghostPercentage);
if (totalPlayers <= playerVoteMaximum || roundedGhostPercentage >= ghostVotePercentageRequirement)
if (totalPlayers <= playerVoteMaximum || ghostVoterPercentage >= ghostVotePercentageRequirement)
{
StartVote(initiator);
}
else
{
NotifyNotEnoughGhostPlayers(ghostVotePercentageRequirement, roundedGhostPercentage);
NotifyNotEnoughGhostPlayers(ghostVotePercentageRequirement, ghostVoterPercentage);
}
}
/// <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);
var eligiblePercentage = 0.0;
if (totalPlayers > 0)
{
eligiblePercentage = ((double)eligibleCount / totalPlayers) * 100;
}
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)
{
_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;
@@ -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));

View 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; }

View 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)

View 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.

View File

@@ -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;
}
}

View 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

View 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)

View File

@@ -1,10 +1,10 @@
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.
/// </summary>
public enum StandardVoteType : byte
{
/// <summary>
/// Vote to restart the round.
/// </summary>
@@ -18,6 +18,20 @@
/// <summary>
/// Vote to change the map for the next round.
/// </summary>
Map
}
Map,
/// <summary>
/// Vote to kick a player.
/// </summary>
Votekick
}
/// <summary>
/// Reasons available to initiate a votekick.
/// </summary>
public enum VotekickReasonType : byte
{
Raiding,
Cheating,
Spam
}

View File

@@ -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;
}

View 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.

View 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

View File

@@ -1,2 +1,4 @@
ui-vote-created = { $initiator } has called a vote:
ui-vote-button = { $text } ({ $votes })
ui-vote-button-no-votes = { $text }
ui-vote-follow-button-popup = Follow User