fish-station/Content.Server/Voting/Managers/VoteManager.cs

761 lines
26 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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._Sunrise.SunriseCCVars;
using Content.Shared._Sunrise.Vote;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Database;
using Content.Shared.Ghost;
using Content.Shared.Interaction;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Voting;
using Robust.Server.Player;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
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 List<ICommonSession> _ignoredMusicClients = [];
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>();
private readonly SoundSpecifier _voteAudio = new SoundPathSpecifier("/Audio/_Sunrise/voting.ogg");
private EntityUid? _voteAudioStream;
public void Initialize()
{
_netManager.RegisterNetMessage<MsgVoteData>();
_netManager.RegisterNetMessage<MsgVoteCanCall>();
_netManager.RegisterNetMessage<RequestVoteMusicDisableOptionMessage>();
_netManager.RegisterNetMessage<VoteMusicDisableOptionMessage>(OnClientVoteMusicDisableOption);
_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 async void OnClientVoteMusicDisableOption(VoteMusicDisableOptionMessage message)
{
var sender = message.MsgChannel;
var session = _playerManager.GetSessionByChannel(sender);
if (message.Disable)
_ignoredMusicClients.Add(session);
else
_ignoredMusicClients.Remove(session);
}
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);
var message = new RequestVoteMusicDisableOptionMessage();
_netManager.ServerSendMessage(message, e.Session.Channel);
}
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();
if (_voteAudioStream != null && _entityManager.EntityExists(_voteAudioStream))
{
_entityManager.System<SharedAudioSystem>().Stop(_voteAudioStream);
}
var audio = _entityManager.System<SharedAudioSystem>();
_voteAudioStream = audio.PlayGlobal(
audio.ResolveSound(_voteAudio),
Filter.Broadcast().RemovePlayers(_ignoredMusicClients),
true,
AudioParams.Default.WithLoop(true).WithVolume(-10f))!.Value.Entity;
// Sunrise-Start
if (_entityManager.System<GameTicker>().RunLevel == GameRunLevel.PreRoundLobby)
{
if (_cfg.GetCVar(SunriseCCVars.VotePause))
_entityManager.System<GameTicker>().PauseStart(true);
if (_cfg.GetCVar(SunriseCCVars.VoteDisableOOC))
_cfg.SetCVar(CCVars.OocEnabled, false);
}
// Sunrise-End
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.DisplayVotesAdmins, // Sunrise-Edit
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) && v.DisplayVotesAdmins) // Sunrise-Edit
{
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.
// Sunrise-Start: На случай пустых голосований
ImmutableArray<object> winners;
if (v.Entries.Any())
{
winners = v.Entries
.GroupBy(e => e.Votes)
.OrderByDescending(g => g.Key)
.First()
.Select(e => e.Data)
.ToImmutableArray();
}
else
{
winners = ImmutableArray<object>.Empty;
}
// Sunrise-End
// 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;
// Sunrise-Start: На случай пустых голосований
if (_voteHandles.ContainsKey(v.Id))
{
var args = new VoteFinishedEventArgs(winners.Length == 1 ? winners[0] : null, winners, voteTally);
v.OnFinished?.Invoke(_voteHandles[v.Id], args);
}
else
{
Logger.Error($"Vote handle with Id {v.Id} does not exist in _voteHandles.");
}
var activeVotes = 0;
foreach (var vote in _votes)
{
if (vote.Value.Finished)
continue;
activeVotes += 1;
}
if (activeVotes < 1)
{
_entityManager.System<SharedAudioSystem>().Stop(_voteAudioStream);
if (_entityManager.System<GameTicker>().RunLevel == GameRunLevel.PreRoundLobby)
{
if (_cfg.GetCVar(SunriseCCVars.VotePause))
_entityManager.System<GameTicker>().PauseStart(false);
if (_cfg.GetCVar(SunriseCCVars.VoteDisableOOC))
_cfg.SetCVar(CCVars.OocEnabled, true);
}
}
// Sunrise-End
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 bool DisplayVotesAdmins; // Sunrise-Edit
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, bool displayVotesAdmins, NetEntity? targetEntity) // Sunrise-Edit
{
Id = id;
Entries = entries;
Title = title;
InitiatorText = initiatorText;
Initiator = initiator;
StartTime = start;
EndTime = end;
VoterEligibility = voterEligibility;
DisplayVotes = displayVotes;
DisplayVotesAdmins = displayVotesAdmins; // Sunrise-Edit
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
}
}