Files
tbd-station-14/Content.Server/Voting/Managers/VoteManager.cs
2025-02-11 20:59:50 +11:00

669 lines
23 KiB
C#

using System.Collections;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Administration;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
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;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Voting.Managers
{
public sealed partial class VoteManager : IVoteManager
{
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IAdminManager _adminMgr = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[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;
private readonly Dictionary<int, VoteReg> _votes = new();
private readonly Dictionary<int, VoteHandle> _voteHandles = new();
private readonly Dictionary<StandardVoteType, TimeSpan> _standardVoteTimeout = new();
private readonly Dictionary<NetUserId, TimeSpan> _voteTimeout = new();
private readonly HashSet<ICommonSession> _playerCanCallVoteDirty = new();
private readonly StandardVoteType[] _standardVoteTypeValues = Enum.GetValues<StandardVoteType>();
public void Initialize()
{
_netManager.RegisterNetMessage<MsgVoteData>();
_netManager.RegisterNetMessage<MsgVoteCanCall>();
_netManager.RegisterNetMessage<MsgVoteMenu>(ReceiveVoteMenu);
_playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
_adminMgr.OnPermsChanged += AdminPermsChanged;
_cfg.OnValueChanged(CCVars.VoteEnabled, _ =>
{
DirtyCanCallVoteAll();
});
foreach (var kvp in VoteTypesToEnableCVars)
{
_cfg.OnValueChanged(kvp.Value, _ =>
{
DirtyCanCallVoteAll();
});
}
}
private void ReceiveVoteMenu(MsgVoteMenu message)
{
var sender = message.MsgChannel;
var session = _playerManager.GetSessionByChannel(sender);
_adminLogger.Add(LogType.Vote, LogImpact.Low, $"{session} opened vote menu");
}
private void AdminPermsChanged(AdminPermsChangedEventArgs obj)
{
DirtyCanCallVote(obj.Player);
}
private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
if (e.NewStatus == SessionStatus.InGame)
{
// Send current votes to newly connected players.
foreach (var voteReg in _votes.Values)
{
SendSingleUpdate(voteReg, e.Session);
}
DirtyCanCallVote(e.Session);
}
else if (e.NewStatus == SessionStatus.Disconnected)
{
// Clear votes from disconnected players.
foreach (var voteReg in _votes.Values)
{
CastVote(voteReg, e.Session, null);
}
}
}
private void CastVote(VoteReg v, ICommonSession player, int? option)
{
if (!IsValidOption(v, option))
throw new ArgumentOutOfRangeException(nameof(option), "Invalid vote option ID");
if (v.CastVotes.TryGetValue(player, out var existingOption))
{
v.Entries[existingOption].Votes -= 1;
}
if (option != null)
{
v.Entries[option.Value].Votes += 1;
v.CastVotes[player] = option.Value;
}
else
{
v.CastVotes.Remove(player);
}
v.VotesDirty.Add(player);
v.Dirty = true;
}
private bool IsValidOption(VoteReg voteReg, int? option)
{
return option == null || option >= 0 && option < voteReg.Entries.Length;
}
public void Update()
{
// Handle active votes.
var remQueue = new RemQueue<int>();
foreach (var v in _votes.Values)
{
// Logger.Debug($"{_timing.ServerTime}");
if (_timing.RealTime >= v.EndTime)
EndVote(v);
if (v.Finished)
remQueue.Add(v.Id);
if (v.Dirty)
SendUpdates(v);
}
foreach (var id in remQueue)
{
_votes.Remove(id);
_voteHandles.Remove(id);
}
// Handle player timeouts.
var timeoutRemQueue = new RemQueue<NetUserId>();
foreach (var (userId, timeout) in _voteTimeout)
{
if (timeout < _timing.RealTime)
timeoutRemQueue.Add(userId);
}
foreach (var userId in timeoutRemQueue)
{
_voteTimeout.Remove(userId);
if (_playerManager.TryGetSessionById(userId, out var session))
DirtyCanCallVote(session);
}
// Handle standard vote timeouts.
var stdTimeoutRemQueue = new RemQueue<StandardVoteType>();
foreach (var (type, timeout) in _standardVoteTimeout)
{
if (timeout < _timing.RealTime)
stdTimeoutRemQueue.Add(type);
}
foreach (var type in stdTimeoutRemQueue)
{
_standardVoteTimeout.Remove(type);
DirtyCanCallVoteAll();
}
// Handle dirty canCallVotes.
foreach (var dirtyPlayer in _playerCanCallVoteDirty)
{
if (dirtyPlayer.Status != SessionStatus.Disconnected)
SendUpdateCanCallVote(dirtyPlayer);
}
_playerCanCallVoteDirty.Clear();
}
public IVoteHandle CreateVote(VoteOptions options)
{
var id = _nextVoteId++;
var entries = options.Options.Select(o => new VoteEntry(o.data, o.text)).ToArray();
var start = _timing.RealTime;
var end = start + options.Duration;
var reg = new VoteReg(id, entries, options.Title, options.InitiatorText,
options.InitiatorPlayer, start, end, options.VoterEligibility, options.DisplayVotes, options.TargetEntity);
var handle = new VoteHandle(this, reg);
_votes.Add(id, reg);
_voteHandles.Add(id, handle);
if (options.InitiatorPlayer != null)
{
var timeout = options.InitiatorTimeout ?? options.Duration * 2;
_voteTimeout[options.InitiatorPlayer.UserId] = _timing.RealTime + timeout;
}
DirtyCanCallVoteAll();
return handle;
}
private void SendUpdates(VoteReg v)
{
foreach (var player in _playerManager.Sessions)
{
SendSingleUpdate(v, player);
}
v.VotesDirty.Clear();
v.Dirty = false;
}
private void SendSingleUpdate(VoteReg v, ICommonSession player)
{
var msg = new MsgVoteData();
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))
{
// Only send info for your vote IF IT CHANGED.
// Otherwise there would be a reconciliation b*g causing the UI to jump back and forth.
// (votes are not in simulation so can't use normal prediction/reconciliation sadly).
var dirty = v.VotesDirty.Contains(player);
msg.IsYourVoteDirty = dirty;
if (dirty)
{
msg.YourVote = (byte) cast;
}
}
// Admin always see the vote count, even if the vote is set to hide it.
if (v.DisplayVotes || _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] = (msg.DisplayVotes ? (ushort) entry.Votes : (ushort) 0, entry.Text);
}
player.Channel.SendMessage(msg);
}
private void DirtyCanCallVoteAll()
{
_playerCanCallVoteDirty.UnionWith(_playerManager.Sessions);
}
private void SendUpdateCanCallVote(ICommonSession player)
{
var msg = new MsgVoteCanCall();
msg.CanCall = CanCallVote(player, null, out var isAdmin, out var timeSpan);
msg.WhenCanCallVote = timeSpan;
if (isAdmin)
{
msg.VotesUnavailable = Array.Empty<(StandardVoteType, TimeSpan)>();
}
else
{
var votesUnavailable = new List<(StandardVoteType, TimeSpan)>();
foreach (var v in _standardVoteTypeValues)
{
if (CanCallVote(player, v, out _, out var typeTimeSpan))
continue;
votesUnavailable.Add((v, typeTimeSpan));
}
msg.VotesUnavailable = votesUnavailable.ToArray();
}
_netManager.ServerSendMessage(msg, player.Channel);
}
private bool CanCallVote(
ICommonSession initiator,
StandardVoteType? voteType,
out bool isAdmin,
out TimeSpan timeSpan)
{
isAdmin = false;
timeSpan = default;
// Admins can always call votes.
if (_adminMgr.HasAdminFlag(initiator, AdminFlags.Moderator))
{
isAdmin = true;
return true;
}
// If voting is disabled, block votes.
if (!_cfg.GetCVar(CCVars.VoteEnabled))
return false;
// Specific standard vote types can be disabled with cvars.
if (voteType != null && VoteTypesToEnableCVars.TryGetValue(voteType.Value, out var cvar) && !_cfg.GetCVar(cvar))
return false;
// Cannot start vote if vote is already active (as non-admin).
if (_votes.Count != 0)
return false;
// Standard vote on timeout, no calling.
// Ghosts I understand you're dead but stop spamming the restart vote bloody hell.
if (voteType != null && _standardVoteTimeout.TryGetValue(voteType.Value, out timeSpan))
return false;
// If only one Preset available thats not really a vote
// Still allow vote if availbable one is different from current one
if (voteType == StandardVoteType.Preset)
{
var presets = GetGamePresets();
if (presets.Count == 1 && presets.Select(x => x.Key).Single() == _entityManager.System<GameTicker>().Preset?.ID)
return false;
}
return !_voteTimeout.TryGetValue(initiator.UserId, out timeSpan);
}
public bool CanCallVote(ICommonSession initiator, StandardVoteType? voteType = null)
{
return CanCallVote(initiator, voteType, out _, out _);
}
private void EndVote(VoteReg v)
{
if (v.Finished)
{
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)
.OrderByDescending(g => g.Key)
.First()
.Select(e => e.Data)
.ToImmutableArray();
// Store all votes in order for webhooks
var voteTally = new List<int>();
foreach(var entry in v.Entries)
{
voteTally.Add(entry.Votes);
}
v.Finished = true;
v.Dirty = true;
var args = new VoteFinishedEventArgs(winners.Length == 1 ? winners[0] : null, winners, voteTally);
v.OnFinished?.Invoke(_voteHandles[v.Id], args);
DirtyCanCallVoteAll();
}
private void CancelVote(VoteReg v)
{
if (v.Cancelled)
return;
v.Cancelled = true;
v.Finished = true;
v.Dirty = true;
v.OnCancelled?.Invoke(_voteHandles[v.Id]);
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)
{
if (_voteHandles.TryGetValue(voteId, out var vHandle))
{
vote = vHandle;
return true;
}
vote = default;
return false;
}
private void DirtyCanCallVote(ICommonSession player)
{
_playerCanCallVoteDirty.Add(player);
}
#region Preset Votes
private void WirePresetVoteInitiator(VoteOptions options, ICommonSession? player)
{
if (player != null)
{
options.SetInitiator(player);
}
else
{
options.InitiatorText = Loc.GetString("ui-vote-initiator-server");
}
}
#endregion
#region Vote Data
private sealed class VoteReg
{
public readonly int Id;
public readonly Dictionary<ICommonSession, int> CastVotes = new();
public readonly VoteEntry[] Entries;
public readonly string Title;
public readonly string InitiatorText;
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 bool Dirty = true;
public VoteFinishedEventHandler? OnFinished;
public VoteCancelledEventHandler? OnCancelled;
public ICommonSession? Initiator { get; }
public VoteReg(int id, VoteEntry[] entries, string title, string initiatorText,
ICommonSession? initiator, TimeSpan start, TimeSpan end, VoterEligibility voterEligibility, bool displayVotes, NetEntity? targetEntity)
{
Id = id;
Entries = entries;
Title = title;
InitiatorText = initiatorText;
Initiator = initiator;
StartTime = start;
EndTime = end;
VoterEligibility = voterEligibility;
DisplayVotes = displayVotes;
TargetEntity = targetEntity;
}
}
private struct VoteEntry
{
public object Data;
public string Text;
public int Votes;
public VoteEntry(object data, string text)
{
Data = data;
Text = text;
Votes = 0;
}
}
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
private sealed class VoteHandle : IVoteHandle
{
private readonly VoteManager _mgr;
private readonly VoteReg _reg;
public int Id => _reg.Id;
public string Title => _reg.Title;
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 event VoteFinishedEventHandler? OnFinished
{
add => _reg.OnFinished += value;
remove => _reg.OnFinished -= value;
}
public event VoteCancelledEventHandler? OnCancelled
{
add => _reg.OnCancelled += value;
remove => _reg.OnCancelled -= value;
}
public VoteHandle(VoteManager mgr, VoteReg reg)
{
_mgr = mgr;
_reg = reg;
VotesPerOption = new VoteDict(reg);
}
public bool IsValidOption(int optionId)
{
return _mgr.IsValidOption(_reg, optionId);
}
public void CastVote(ICommonSession session, int? optionId)
{
_mgr.CastVote(_reg, session, optionId);
}
public void Cancel()
{
_mgr.CancelVote(_reg);
}
private sealed class VoteDict : IReadOnlyDictionary<object, int>
{
private readonly VoteReg _reg;
public VoteDict(VoteReg reg)
{
_reg = reg;
}
public IEnumerator<KeyValuePair<object, int>> GetEnumerator()
{
return _reg.Entries.Select(e => KeyValuePair.Create(e.Data, e.Votes)).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public int Count => _reg.Entries.Length;
public bool ContainsKey(object key)
{
return TryGetValue(key, out _);
}
public bool TryGetValue(object key, out int value)
{
var entry = _reg.Entries.FirstOrNull(a => a.Data.Equals(key));
if (entry != null)
{
value = entry.Value.Votes;
return true;
}
value = default;
return false;
}
public int this[object key]
{
get
{
if (!TryGetValue(key, out var votes))
{
throw new KeyNotFoundException();
}
return votes;
}
}
public IEnumerable<object> Keys => _reg.Entries.Select(c => c.Data);
public IEnumerable<int> Values => _reg.Entries.Select(c => c.Votes);
}
}
#endregion
}
}