VoteManager.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. using System.Collections;
  2. using System.Collections.Immutable;
  3. using System.Diagnostics.CodeAnalysis;
  4. using System.Linq;
  5. using Content.Server.Administration;
  6. using Content.Server.Administration.Logs;
  7. using Content.Server.Administration.Managers;
  8. using Content.Server.Chat.Managers;
  9. using Content.Server.GameTicking;
  10. using Content.Server.Maps;
  11. using Content.Shared.Administration;
  12. using Content.Shared.CCVar;
  13. using Content.Shared.Database;
  14. using Content.Shared.Ghost;
  15. using Content.Shared.Players.PlayTimeTracking;
  16. using Content.Shared.Voting;
  17. using Robust.Server.Player;
  18. using Robust.Shared.Configuration;
  19. using Robust.Shared.Enums;
  20. using Robust.Shared.Network;
  21. using Robust.Shared.Player;
  22. using Robust.Shared.Prototypes;
  23. using Robust.Shared.Random;
  24. using Robust.Shared.Timing;
  25. using Robust.Shared.Utility;
  26. namespace Content.Server.Voting.Managers
  27. {
  28. public sealed partial class VoteManager : IVoteManager
  29. {
  30. [Dependency] private readonly IServerNetManager _netManager = default!;
  31. [Dependency] private readonly IConfigurationManager _cfg = default!;
  32. [Dependency] private readonly IGameTiming _timing = default!;
  33. [Dependency] private readonly IPlayerManager _playerManager = default!;
  34. [Dependency] private readonly IChatManager _chatManager = default!;
  35. [Dependency] private readonly IAdminManager _adminMgr = default!;
  36. [Dependency] private readonly IRobustRandom _random = default!;
  37. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  38. [Dependency] private readonly IGameMapManager _gameMapManager = default!;
  39. [Dependency] private readonly IEntityManager _entityManager = default!;
  40. [Dependency] private readonly IAdminLogManager _adminLogger = default!;
  41. [Dependency] private readonly ISharedPlaytimeManager _playtimeManager = default!;
  42. private int _nextVoteId = 1;
  43. private readonly Dictionary<int, VoteReg> _votes = new();
  44. private readonly Dictionary<int, VoteHandle> _voteHandles = new();
  45. private readonly Dictionary<StandardVoteType, TimeSpan> _standardVoteTimeout = new();
  46. private readonly Dictionary<NetUserId, TimeSpan> _voteTimeout = new();
  47. private readonly HashSet<ICommonSession> _playerCanCallVoteDirty = new();
  48. private readonly StandardVoteType[] _standardVoteTypeValues = Enum.GetValues<StandardVoteType>();
  49. public void Initialize()
  50. {
  51. _netManager.RegisterNetMessage<MsgVoteData>();
  52. _netManager.RegisterNetMessage<MsgVoteCanCall>();
  53. _netManager.RegisterNetMessage<MsgVoteMenu>(ReceiveVoteMenu);
  54. _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
  55. _adminMgr.OnPermsChanged += AdminPermsChanged;
  56. _cfg.OnValueChanged(CCVars.VoteEnabled, _ =>
  57. {
  58. DirtyCanCallVoteAll();
  59. });
  60. foreach (var kvp in VoteTypesToEnableCVars)
  61. {
  62. _cfg.OnValueChanged(kvp.Value, _ =>
  63. {
  64. DirtyCanCallVoteAll();
  65. });
  66. }
  67. }
  68. private void ReceiveVoteMenu(MsgVoteMenu message)
  69. {
  70. var sender = message.MsgChannel;
  71. var session = _playerManager.GetSessionByChannel(sender);
  72. _adminLogger.Add(LogType.Vote, LogImpact.Low, $"{session} opened vote menu");
  73. }
  74. private void AdminPermsChanged(AdminPermsChangedEventArgs obj)
  75. {
  76. DirtyCanCallVote(obj.Player);
  77. }
  78. private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
  79. {
  80. if (e.NewStatus == SessionStatus.InGame)
  81. {
  82. // Send current votes to newly connected players.
  83. foreach (var voteReg in _votes.Values)
  84. {
  85. SendSingleUpdate(voteReg, e.Session);
  86. }
  87. DirtyCanCallVote(e.Session);
  88. }
  89. else if (e.NewStatus == SessionStatus.Disconnected)
  90. {
  91. // Clear votes from disconnected players.
  92. foreach (var voteReg in _votes.Values)
  93. {
  94. CastVote(voteReg, e.Session, null);
  95. }
  96. }
  97. }
  98. private void CastVote(VoteReg v, ICommonSession player, int? option)
  99. {
  100. if (!IsValidOption(v, option))
  101. throw new ArgumentOutOfRangeException(nameof(option), "Invalid vote option ID");
  102. if (v.CastVotes.TryGetValue(player, out var existingOption))
  103. {
  104. v.Entries[existingOption].Votes -= 1;
  105. }
  106. if (option != null)
  107. {
  108. v.Entries[option.Value].Votes += 1;
  109. v.CastVotes[player] = option.Value;
  110. }
  111. else
  112. {
  113. v.CastVotes.Remove(player);
  114. }
  115. v.VotesDirty.Add(player);
  116. v.Dirty = true;
  117. }
  118. private bool IsValidOption(VoteReg voteReg, int? option)
  119. {
  120. return option == null || option >= 0 && option < voteReg.Entries.Length;
  121. }
  122. public void Update()
  123. {
  124. // Handle active votes.
  125. var remQueue = new RemQueue<int>();
  126. foreach (var v in _votes.Values)
  127. {
  128. // Logger.Debug($"{_timing.ServerTime}");
  129. if (_timing.RealTime >= v.EndTime)
  130. EndVote(v);
  131. if (v.Finished)
  132. remQueue.Add(v.Id);
  133. if (v.Dirty)
  134. SendUpdates(v);
  135. }
  136. foreach (var id in remQueue)
  137. {
  138. _votes.Remove(id);
  139. _voteHandles.Remove(id);
  140. }
  141. // Handle player timeouts.
  142. var timeoutRemQueue = new RemQueue<NetUserId>();
  143. foreach (var (userId, timeout) in _voteTimeout)
  144. {
  145. if (timeout < _timing.RealTime)
  146. timeoutRemQueue.Add(userId);
  147. }
  148. foreach (var userId in timeoutRemQueue)
  149. {
  150. _voteTimeout.Remove(userId);
  151. if (_playerManager.TryGetSessionById(userId, out var session))
  152. DirtyCanCallVote(session);
  153. }
  154. // Handle standard vote timeouts.
  155. var stdTimeoutRemQueue = new RemQueue<StandardVoteType>();
  156. foreach (var (type, timeout) in _standardVoteTimeout)
  157. {
  158. if (timeout < _timing.RealTime)
  159. stdTimeoutRemQueue.Add(type);
  160. }
  161. foreach (var type in stdTimeoutRemQueue)
  162. {
  163. _standardVoteTimeout.Remove(type);
  164. DirtyCanCallVoteAll();
  165. }
  166. // Handle dirty canCallVotes.
  167. foreach (var dirtyPlayer in _playerCanCallVoteDirty)
  168. {
  169. if (dirtyPlayer.Status != SessionStatus.Disconnected)
  170. SendUpdateCanCallVote(dirtyPlayer);
  171. }
  172. _playerCanCallVoteDirty.Clear();
  173. }
  174. public IVoteHandle CreateVote(VoteOptions options)
  175. {
  176. var id = _nextVoteId++;
  177. var entries = options.Options.Select(o => new VoteEntry(o.data, o.text)).ToArray();
  178. var start = _timing.RealTime;
  179. var end = start + options.Duration;
  180. var reg = new VoteReg(id, entries, options.Title, options.InitiatorText,
  181. options.InitiatorPlayer, start, end, options.VoterEligibility, options.DisplayVotes, options.TargetEntity);
  182. var handle = new VoteHandle(this, reg);
  183. _votes.Add(id, reg);
  184. _voteHandles.Add(id, handle);
  185. if (options.InitiatorPlayer != null)
  186. {
  187. var timeout = options.InitiatorTimeout ?? options.Duration * 2;
  188. _voteTimeout[options.InitiatorPlayer.UserId] = _timing.RealTime + timeout;
  189. }
  190. DirtyCanCallVoteAll();
  191. return handle;
  192. }
  193. private void SendUpdates(VoteReg v)
  194. {
  195. foreach (var player in _playerManager.Sessions)
  196. {
  197. SendSingleUpdate(v, player);
  198. }
  199. v.VotesDirty.Clear();
  200. v.Dirty = false;
  201. }
  202. private void SendSingleUpdate(VoteReg v, ICommonSession player)
  203. {
  204. var msg = new MsgVoteData();
  205. msg.VoteId = v.Id;
  206. msg.VoteActive = !v.Finished;
  207. if (!CheckVoterEligibility(player, v.VoterEligibility))
  208. {
  209. msg.VoteActive = false;
  210. player.Channel.SendMessage(msg);
  211. return;
  212. }
  213. if (!v.Finished)
  214. {
  215. msg.VoteTitle = v.Title;
  216. msg.VoteInitiator = v.InitiatorText;
  217. msg.StartTime = v.StartTime;
  218. msg.EndTime = v.EndTime;
  219. if (v.TargetEntity != null)
  220. {
  221. msg.TargetEntity = v.TargetEntity.Value.Id;
  222. }
  223. }
  224. if (v.CastVotes.TryGetValue(player, out var cast))
  225. {
  226. // Only send info for your vote IF IT CHANGED.
  227. // Otherwise there would be a reconciliation b*g causing the UI to jump back and forth.
  228. // (votes are not in simulation so can't use normal prediction/reconciliation sadly).
  229. var dirty = v.VotesDirty.Contains(player);
  230. msg.IsYourVoteDirty = dirty;
  231. if (dirty)
  232. {
  233. msg.YourVote = (byte)cast;
  234. }
  235. }
  236. // Admin always see the vote count, even if the vote is set to hide it.
  237. if (v.DisplayVotes || _adminMgr.HasAdminFlag(player, AdminFlags.Moderator))
  238. {
  239. msg.DisplayVotes = true;
  240. }
  241. msg.Options = new (ushort votes, string name)[v.Entries.Length];
  242. for (var i = 0; i < msg.Options.Length; i++)
  243. {
  244. ref var entry = ref v.Entries[i];
  245. msg.Options[i] = (msg.DisplayVotes ? (ushort)entry.Votes : (ushort)0, entry.Text);
  246. }
  247. player.Channel.SendMessage(msg);
  248. }
  249. private void DirtyCanCallVoteAll()
  250. {
  251. _playerCanCallVoteDirty.UnionWith(_playerManager.Sessions);
  252. }
  253. private void SendUpdateCanCallVote(ICommonSession player)
  254. {
  255. var msg = new MsgVoteCanCall();
  256. msg.CanCall = CanCallVote(player, null, out var isAdmin, out var timeSpan);
  257. msg.WhenCanCallVote = timeSpan;
  258. if (isAdmin)
  259. {
  260. msg.VotesUnavailable = Array.Empty<(StandardVoteType, TimeSpan)>();
  261. }
  262. else
  263. {
  264. var votesUnavailable = new List<(StandardVoteType, TimeSpan)>();
  265. foreach (var v in _standardVoteTypeValues)
  266. {
  267. if (CanCallVote(player, v, out _, out var typeTimeSpan))
  268. continue;
  269. votesUnavailable.Add((v, typeTimeSpan));
  270. }
  271. msg.VotesUnavailable = votesUnavailable.ToArray();
  272. }
  273. _netManager.ServerSendMessage(msg, player.Channel);
  274. }
  275. private bool CanCallVote(
  276. ICommonSession initiator,
  277. StandardVoteType? voteType,
  278. out bool isAdmin,
  279. out TimeSpan timeSpan)
  280. {
  281. isAdmin = false;
  282. timeSpan = default;
  283. // Admins can always call votes.
  284. if (_adminMgr.HasAdminFlag(initiator, AdminFlags.Moderator))
  285. {
  286. isAdmin = true;
  287. return true;
  288. }
  289. // If voting is disabled, block votes.
  290. if (!_cfg.GetCVar(CCVars.VoteEnabled))
  291. return false;
  292. // Specific standard vote types can be disabled with cvars.
  293. if (voteType != null && VoteTypesToEnableCVars.TryGetValue(voteType.Value, out var cvar) && !_cfg.GetCVar(cvar))
  294. return false;
  295. // Cannot start vote if vote is already active (as non-admin).
  296. if (_votes.Count != 0)
  297. return false;
  298. // Standard vote on timeout, no calling.
  299. // Ghosts I understand you're dead but stop spamming the restart vote bloody hell.
  300. if (voteType != null && _standardVoteTimeout.TryGetValue(voteType.Value, out timeSpan))
  301. return false;
  302. // If only one Preset available thats not really a vote
  303. // Still allow vote if availbable one is different from current one
  304. if (voteType == StandardVoteType.Preset)
  305. {
  306. var presets = GetGamePresets();
  307. if (presets.Count == 1 && presets.Select(x => x.Key).Single() == _entityManager.System<GameTicker>().Preset?.ID)
  308. return false;
  309. }
  310. return !_voteTimeout.TryGetValue(initiator.UserId, out timeSpan);
  311. }
  312. public bool CanCallVote(ICommonSession initiator, StandardVoteType? voteType = null)
  313. {
  314. return CanCallVote(initiator, voteType, out _, out _);
  315. }
  316. private void EndVote(VoteReg v)
  317. {
  318. if (v.Finished)
  319. {
  320. return;
  321. }
  322. // Remove ineligible votes that somehow slipped through
  323. foreach (var playerVote in v.CastVotes)
  324. {
  325. if (!CheckVoterEligibility(playerVote.Key, v.VoterEligibility))
  326. {
  327. v.Entries[playerVote.Value].Votes -= 1;
  328. v.CastVotes.Remove(playerVote.Key);
  329. }
  330. }
  331. // Find winner or stalemate.
  332. var winners = v.Entries
  333. .GroupBy(e => e.Votes)
  334. .OrderByDescending(g => g.Key)
  335. .First()
  336. .Select(e => e.Data)
  337. .ToImmutableArray();
  338. // Store all votes in order for webhooks
  339. var voteTally = new List<int>();
  340. foreach (var entry in v.Entries)
  341. {
  342. voteTally.Add(entry.Votes);
  343. }
  344. v.Finished = true;
  345. v.Dirty = true;
  346. var args = new VoteFinishedEventArgs(winners.Length == 1 ? winners[0] : null, winners, voteTally);
  347. v.OnFinished?.Invoke(_voteHandles[v.Id], args);
  348. DirtyCanCallVoteAll();
  349. }
  350. private void CancelVote(VoteReg v)
  351. {
  352. if (v.Cancelled)
  353. return;
  354. v.Cancelled = true;
  355. v.Finished = true;
  356. v.Dirty = true;
  357. v.OnCancelled?.Invoke(_voteHandles[v.Id]);
  358. DirtyCanCallVoteAll();
  359. }
  360. public bool CheckVoterEligibility(ICommonSession player, VoterEligibility eligibility)
  361. {
  362. if (eligibility == VoterEligibility.All)
  363. return true;
  364. if (eligibility == VoterEligibility.Ghost || eligibility == VoterEligibility.GhostMinimumPlaytime)
  365. {
  366. if (!_entityManager.TryGetComponent(player.AttachedEntity, out GhostComponent? ghostComp))
  367. return false;
  368. if (eligibility == VoterEligibility.GhostMinimumPlaytime)
  369. {
  370. var playtime = _playtimeManager.GetPlayTimes(player);
  371. if (!playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) || overallTime < TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickEligibleVoterPlaytime)))
  372. return false;
  373. if ((int)_timing.RealTime.Subtract(ghostComp.TimeOfDeath).TotalSeconds < _cfg.GetCVar(CCVars.VotekickEligibleVoterDeathtime))
  374. return false;
  375. }
  376. }
  377. if (eligibility == VoterEligibility.MinimumPlaytime)
  378. {
  379. var playtime = _playtimeManager.GetPlayTimes(player);
  380. if (!playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) || overallTime < TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickEligibleVoterPlaytime)))
  381. return false;
  382. }
  383. return true;
  384. }
  385. public IEnumerable<IVoteHandle> ActiveVotes => _voteHandles.Values;
  386. public bool TryGetVote(int voteId, [NotNullWhen(true)] out IVoteHandle? vote)
  387. {
  388. if (_voteHandles.TryGetValue(voteId, out var vHandle))
  389. {
  390. vote = vHandle;
  391. return true;
  392. }
  393. vote = default;
  394. return false;
  395. }
  396. private void DirtyCanCallVote(ICommonSession player)
  397. {
  398. _playerCanCallVoteDirty.Add(player);
  399. }
  400. #region Preset Votes
  401. private void WirePresetVoteInitiator(VoteOptions options, ICommonSession? player)
  402. {
  403. if (player != null)
  404. {
  405. options.SetInitiator(player);
  406. }
  407. else
  408. {
  409. options.InitiatorText = Loc.GetString("ui-vote-initiator-server");
  410. }
  411. }
  412. #endregion
  413. #region Vote Data
  414. private sealed class VoteReg
  415. {
  416. public readonly int Id;
  417. public readonly Dictionary<ICommonSession, int> CastVotes = new();
  418. public readonly VoteEntry[] Entries;
  419. public readonly string Title;
  420. public readonly string InitiatorText;
  421. public readonly TimeSpan StartTime;
  422. public readonly TimeSpan EndTime;
  423. public readonly HashSet<ICommonSession> VotesDirty = new();
  424. public readonly VoterEligibility VoterEligibility;
  425. public readonly bool DisplayVotes;
  426. public readonly NetEntity? TargetEntity;
  427. public bool Cancelled;
  428. public bool Finished;
  429. public bool Dirty = true;
  430. public VoteFinishedEventHandler? OnFinished;
  431. public VoteCancelledEventHandler? OnCancelled;
  432. public ICommonSession? Initiator { get; }
  433. public VoteReg(int id, VoteEntry[] entries, string title, string initiatorText,
  434. ICommonSession? initiator, TimeSpan start, TimeSpan end, VoterEligibility voterEligibility, bool displayVotes, NetEntity? targetEntity)
  435. {
  436. Id = id;
  437. Entries = entries;
  438. Title = title;
  439. InitiatorText = initiatorText;
  440. Initiator = initiator;
  441. StartTime = start;
  442. EndTime = end;
  443. VoterEligibility = voterEligibility;
  444. DisplayVotes = displayVotes;
  445. TargetEntity = targetEntity;
  446. }
  447. }
  448. private struct VoteEntry
  449. {
  450. public object Data;
  451. public string Text;
  452. public int Votes;
  453. public VoteEntry(object data, string text)
  454. {
  455. Data = data;
  456. Text = text;
  457. Votes = 0;
  458. }
  459. }
  460. public enum VoterEligibility
  461. {
  462. All,
  463. Ghost, // Player needs to be a ghost
  464. GhostMinimumPlaytime, // Player needs to be a ghost, with a minimum playtime and deathtime as defined by votekick CCvars.
  465. MinimumPlaytime //Player needs to have a minimum playtime and deathtime as defined by votekick CCvars.
  466. }
  467. #endregion
  468. #region IVoteHandle API surface
  469. private sealed class VoteHandle : IVoteHandle
  470. {
  471. private readonly VoteManager _mgr;
  472. private readonly VoteReg _reg;
  473. public int Id => _reg.Id;
  474. public string Title => _reg.Title;
  475. public string InitiatorText => _reg.InitiatorText;
  476. public bool Finished => _reg.Finished;
  477. public bool Cancelled => _reg.Cancelled;
  478. public IReadOnlyDictionary<ICommonSession, int> CastVotes => _reg.CastVotes;
  479. public IReadOnlyDictionary<object, int> VotesPerOption { get; }
  480. public event VoteFinishedEventHandler? OnFinished
  481. {
  482. add => _reg.OnFinished += value;
  483. remove => _reg.OnFinished -= value;
  484. }
  485. public event VoteCancelledEventHandler? OnCancelled
  486. {
  487. add => _reg.OnCancelled += value;
  488. remove => _reg.OnCancelled -= value;
  489. }
  490. public VoteHandle(VoteManager mgr, VoteReg reg)
  491. {
  492. _mgr = mgr;
  493. _reg = reg;
  494. VotesPerOption = new VoteDict(reg);
  495. }
  496. public bool IsValidOption(int optionId)
  497. {
  498. return _mgr.IsValidOption(_reg, optionId);
  499. }
  500. public void CastVote(ICommonSession session, int? optionId)
  501. {
  502. _mgr.CastVote(_reg, session, optionId);
  503. }
  504. public void Cancel()
  505. {
  506. _mgr.CancelVote(_reg);
  507. }
  508. private sealed class VoteDict : IReadOnlyDictionary<object, int>
  509. {
  510. private readonly VoteReg _reg;
  511. public VoteDict(VoteReg reg)
  512. {
  513. _reg = reg;
  514. }
  515. public IEnumerator<KeyValuePair<object, int>> GetEnumerator()
  516. {
  517. return _reg.Entries.Select(e => KeyValuePair.Create(e.Data, e.Votes)).GetEnumerator();
  518. }
  519. IEnumerator IEnumerable.GetEnumerator()
  520. {
  521. return GetEnumerator();
  522. }
  523. public int Count => _reg.Entries.Length;
  524. public bool ContainsKey(object key)
  525. {
  526. return TryGetValue(key, out _);
  527. }
  528. public bool TryGetValue(object key, out int value)
  529. {
  530. var entry = _reg.Entries.FirstOrNull(a => a.Data.Equals(key));
  531. if (entry != null)
  532. {
  533. value = entry.Value.Votes;
  534. return true;
  535. }
  536. value = default;
  537. return false;
  538. }
  539. public int this[object key]
  540. {
  541. get
  542. {
  543. if (!TryGetValue(key, out var votes))
  544. {
  545. throw new KeyNotFoundException();
  546. }
  547. return votes;
  548. }
  549. }
  550. public IEnumerable<object> Keys => _reg.Entries.Select(c => c.Data);
  551. public IEnumerable<int> Values => _reg.Entries.Select(c => c.Votes);
  552. }
  553. }
  554. #endregion
  555. }
  556. }