-<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'}" />
-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;
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;
[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()
_netManager.ClientSendMessage(new MsgVoteMenu());
_voteManager.CanCallVoteChanged += CanCallVoteChanged;
+ _votingSystem.VotePlayerListResponse += UpdateVotePlayerList;
+ _votingSystem.RequestVotePlayerList();
}
public override void Close()
base.Close();
_voteManager.CanCallVoteChanged -= CanCallVoteChanged;
+ _votingSystem.VotePlayerListResponse -= UpdateVotePlayerList;
}
protected override void FrameUpdate(FrameEventArgs args)
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();
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)
}
}
- 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++;
}
}
}
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;
+ }
+ }
}
-<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" />
-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;
{
[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)
{
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();
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}");
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Content.Shared.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}");
public string Initiator = "";
public int? OurVote;
public int Id;
-
+ public bool DisplayVotes;
+ public int? TargetEntity; // NetEntity
public ActiveVote(int voteId)
{
Id = voteId;
--- /dev/null
+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());
+ }
+}
-using Content.Server.Voting.Managers;
+using Content.Server.Voting.Managers;
using Robust.Shared.Player;
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>
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
using Content.Shared.Voting;
using Robust.Shared.Player;
/// 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.
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;
{
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:
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)
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)
};
}
+ 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));
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;
[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;
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);
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))
}
}
+ // 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);
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)
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)
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;
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;
Initiator = initiator;
StartTime = start;
EndTime = end;
+ VoterEligibility = voterEligibility;
+ DisplayVotes = displayVotes;
+ TargetEntity = targetEntity;
}
}
}
}
+ 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
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; }
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))
{
return;
}
- mgr.CreateStandardVote(shell.Player, type);
+ mgr.CreateStandardVote(shell.Player, type, args.Skip(1).ToArray());
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+using Content.Server.Voting.Managers;
using Robust.Shared.Player;
-
namespace Content.Server.Voting
{
/// <summary>
/// </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.
--- /dev/null
+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;
+ }
+}
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>
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
-using Lidgren.Network;
+using Lidgren.Network;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
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)
{
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++)
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)
-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
}
--- /dev/null
+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;
+}
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.
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
# 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
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