VoteManager.DefaultVotes.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. using System.Linq;
  2. using System.Net;
  3. using System.Net.Sockets;
  4. using Content.Server.Administration;
  5. using Content.Server.Administration.Managers;
  6. using Content.Server.Discord.WebhookMessages;
  7. using Content.Server.GameTicking;
  8. using Content.Server.GameTicking.Presets;
  9. using Content.Server.Maps;
  10. using Content.Server.Roles;
  11. using Content.Server.RoundEnd;
  12. using Content.Shared.CCVar;
  13. using Content.Shared.Chat;
  14. using Content.Shared.Database;
  15. using Content.Shared.Players;
  16. using Content.Shared.Players.PlayTimeTracking;
  17. using Content.Shared.Voting;
  18. using Robust.Shared.Configuration;
  19. using Robust.Shared.Enums;
  20. using Robust.Shared.Player;
  21. using Robust.Shared.Random;
  22. using Robust.Shared.Configuration;
  23. namespace Content.Server.Voting.Managers
  24. {
  25. public sealed partial class VoteManager
  26. {
  27. [Dependency] private readonly IPlayerLocator _locator = default!;
  28. [Dependency] private readonly ILogManager _logManager = default!;
  29. [Dependency] private readonly IBanManager _bans = default!;
  30. [Dependency] private readonly ILogManager _log = default!;
  31. [Dependency] private readonly VoteWebhooks _voteWebhooks = default!;
  32. [Dependency] private readonly IConfigurationManager _configurationManager = default!;
  33. private readonly ISawmill _sawmill = default!;
  34. private VotingSystem? _votingSystem;
  35. private RoleSystem? _roleSystem;
  36. private GameTicker? _gameTicker;
  37. private static readonly Dictionary<StandardVoteType, CVarDef<bool>> VoteTypesToEnableCVars = new()
  38. {
  39. {StandardVoteType.Restart, CCVars.VoteRestartEnabled},
  40. {StandardVoteType.Preset, CCVars.VotePresetEnabled},
  41. {StandardVoteType.Map, CCVars.VoteMapEnabled},
  42. {StandardVoteType.Votekick, CCVars.VotekickEnabled}
  43. };
  44. public void CreateStandardVote(ICommonSession? initiator, StandardVoteType voteType, string[]? args = null)
  45. {
  46. if (initiator != null && args == null)
  47. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"{initiator} initiated a {voteType.ToString()} vote");
  48. else if (initiator != null && args != null)
  49. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"{initiator} initiated a {voteType.ToString()} vote with the arguments: {String.Join(",", args)}");
  50. else
  51. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Initiated a {voteType.ToString()} vote");
  52. _gameTicker = _entityManager.EntitySysManager.GetEntitySystem<GameTicker>();
  53. bool timeoutVote = true;
  54. switch (voteType)
  55. {
  56. case StandardVoteType.Restart:
  57. CreateRestartVote(initiator);
  58. break;
  59. case StandardVoteType.Preset:
  60. CreatePresetVote(initiator);
  61. break;
  62. case StandardVoteType.Map:
  63. CreateMapVote(initiator);
  64. break;
  65. case StandardVoteType.Votekick:
  66. timeoutVote = false; // Allows the timeout to be updated manually in the create method
  67. CreateVotekickVote(initiator, args);
  68. break;
  69. default:
  70. throw new ArgumentOutOfRangeException(nameof(voteType), voteType, null);
  71. }
  72. _gameTicker.UpdateInfoText();
  73. if (timeoutVote)
  74. TimeoutStandardVote(voteType);
  75. }
  76. private void CreateRestartVote(ICommonSession? initiator)
  77. {
  78. var playerVoteMaximum = _cfg.GetCVar(CCVars.VoteRestartMaxPlayers);
  79. var totalPlayers = _playerManager.Sessions.Count(session => session.Status != SessionStatus.Disconnected);
  80. var ghostVotePercentageRequirement = _cfg.GetCVar(CCVars.VoteRestartGhostPercentage);
  81. var ghostVoterPercentage = CalculateEligibleVoterPercentage(VoterEligibility.Ghost);
  82. if (totalPlayers <= playerVoteMaximum || ghostVoterPercentage >= ghostVotePercentageRequirement)
  83. {
  84. StartVote(initiator);
  85. }
  86. else
  87. {
  88. NotifyNotEnoughGhostPlayers(ghostVotePercentageRequirement, ghostVoterPercentage);
  89. }
  90. }
  91. /// <summary>
  92. /// Gives the current percentage of players eligible to vote, rounded to nearest percentage point.
  93. /// </summary>
  94. /// <param name="eligibility">The eligibility requirement to vote.</param>
  95. public int CalculateEligibleVoterPercentage(VoterEligibility eligibility)
  96. {
  97. var eligibleCount = CalculateEligibleVoterNumber(eligibility);
  98. var totalPlayers = _playerManager.Sessions.Count(session => session.Status != SessionStatus.Disconnected);
  99. var eligiblePercentage = 0.0;
  100. if (totalPlayers > 0)
  101. {
  102. eligiblePercentage = ((double)eligibleCount / totalPlayers) * 100;
  103. }
  104. var roundedEligiblePercentage = (int)Math.Round(eligiblePercentage);
  105. return roundedEligiblePercentage;
  106. }
  107. /// <summary>
  108. /// Gives the current number of players eligible to vote.
  109. /// </summary>
  110. /// <param name="eligibility">The eligibility requirement to vote.</param>
  111. public int CalculateEligibleVoterNumber(VoterEligibility eligibility)
  112. {
  113. var eligibleCount = 0;
  114. foreach (var player in _playerManager.Sessions)
  115. {
  116. _playerManager.UpdateState(player);
  117. if (player.Status != SessionStatus.Disconnected && CheckVoterEligibility(player, eligibility))
  118. {
  119. eligibleCount++;
  120. }
  121. }
  122. return eligibleCount;
  123. }
  124. private void StartVote(ICommonSession? initiator)
  125. {
  126. var alone = _playerManager.PlayerCount == 1 && initiator != null;
  127. var options = new VoteOptions
  128. {
  129. Title = Loc.GetString("ui-vote-restart-title"),
  130. Options =
  131. {
  132. (Loc.GetString("ui-vote-restart-yes"), "yes"),
  133. (Loc.GetString("ui-vote-restart-no"), "no"),
  134. (Loc.GetString("ui-vote-restart-abstain"), "abstain")
  135. },
  136. Duration = alone
  137. ? TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerAlone))
  138. : TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerRestart)),
  139. InitiatorTimeout = TimeSpan.FromMinutes(5)
  140. };
  141. if (alone)
  142. options.InitiatorTimeout = TimeSpan.FromSeconds(10);
  143. WirePresetVoteInitiator(options, initiator);
  144. var vote = CreateVote(options);
  145. vote.OnFinished += (_, _) =>
  146. {
  147. var votesYes = vote.VotesPerOption["yes"];
  148. var votesNo = vote.VotesPerOption["no"];
  149. var total = votesYes + votesNo;
  150. var ratioRequired = _cfg.GetCVar(CCVars.VoteRestartRequiredRatio);
  151. if (total > 0 && votesYes / (float)total >= ratioRequired)
  152. {
  153. // Check if an admin is online, and ignore the passed vote if the cvar is enabled
  154. if (_cfg.GetCVar(CCVars.VoteRestartNotAllowedWhenAdminOnline) && _adminMgr.ActiveAdmins.Count() != 0)
  155. {
  156. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote attempted to pass, but an admin was online. {votesYes}/{votesNo}");
  157. }
  158. else // If the cvar is disabled or there's no admins on, proceed as normal
  159. {
  160. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote succeeded: {votesYes}/{votesNo}");
  161. _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-restart-succeeded"));
  162. var roundEnd = _entityManager.EntitySysManager.GetEntitySystem<RoundEndSystem>();
  163. roundEnd.EndRound();
  164. }
  165. }
  166. else
  167. {
  168. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote failed: {votesYes}/{votesNo}");
  169. _chatManager.DispatchServerAnnouncement(
  170. Loc.GetString("ui-vote-restart-failed", ("ratio", ratioRequired)));
  171. }
  172. };
  173. if (initiator != null)
  174. {
  175. // Cast yes vote if created the vote yourself.
  176. vote.CastVote(initiator, 0);
  177. }
  178. foreach (var player in _playerManager.Sessions)
  179. {
  180. if (player != initiator)
  181. {
  182. // Everybody else defaults to an abstain vote to say they don't mind.
  183. vote.CastVote(player, 2);
  184. }
  185. }
  186. }
  187. private void NotifyNotEnoughGhostPlayers(int ghostPercentageRequirement, int roundedGhostPercentage)
  188. {
  189. // Logic to notify that there are not enough ghost players to start a vote
  190. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Restart vote failed: Current Ghost player percentage:{roundedGhostPercentage.ToString()}% does not meet {ghostPercentageRequirement.ToString()}%");
  191. _chatManager.DispatchServerAnnouncement(
  192. Loc.GetString("ui-vote-restart-fail-not-enough-ghost-players", ("ghostPlayerRequirement", ghostPercentageRequirement)));
  193. }
  194. private void CreatePresetVote(ICommonSession? initiator)
  195. {
  196. var presets = GetGamePresets();
  197. var alone = _playerManager.PlayerCount == 1 && initiator != null;
  198. var options = new VoteOptions
  199. {
  200. Title = Loc.GetString("ui-vote-gamemode-title"),
  201. Duration = alone
  202. ? TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerAlone))
  203. : TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerPreset))
  204. };
  205. if (alone)
  206. options.InitiatorTimeout = TimeSpan.FromSeconds(10);
  207. foreach (var (k, v) in presets)
  208. {
  209. options.Options.Add((Loc.GetString(v), k));
  210. }
  211. WirePresetVoteInitiator(options, initiator);
  212. var vote = CreateVote(options);
  213. vote.OnFinished += (_, args) =>
  214. {
  215. string picked;
  216. if (args.Winner == null)
  217. {
  218. picked = (string)_random.Pick(args.Winners);
  219. _chatManager.DispatchServerAnnouncement(
  220. Loc.GetString("ui-vote-gamemode-tie", ("picked", Loc.GetString(presets[picked]))));
  221. }
  222. else
  223. {
  224. picked = (string)args.Winner;
  225. _chatManager.DispatchServerAnnouncement(
  226. Loc.GetString("ui-vote-gamemode-win", ("winner", Loc.GetString(presets[picked]))));
  227. }
  228. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Preset vote finished: {picked}");
  229. var ticker = _entityManager.EntitySysManager.GetEntitySystem<GameTicker>();
  230. ticker.SetGamePreset(picked);
  231. };
  232. }
  233. public void CreateMapVote(ICommonSession? initiator)
  234. {
  235. var maps = _gameMapManager.CurrentlyEligibleMaps().ToDictionary(map => map, map => map.MapName);
  236. var alone = _playerManager.PlayerCount == 1 && initiator != null;
  237. var options = new VoteOptions
  238. {
  239. Title = Loc.GetString("ui-vote-map-title"),
  240. Duration = alone
  241. ? TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerAlone))
  242. : TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteTimerMap))
  243. };
  244. if (alone)
  245. options.InitiatorTimeout = TimeSpan.FromSeconds(10);
  246. foreach (var (k, v) in maps)
  247. {
  248. options.Options.Add((v, k));
  249. }
  250. WirePresetVoteInitiator(options, initiator);
  251. var vote = CreateVote(options);
  252. vote.OnFinished += (_, args) =>
  253. {
  254. GameMapPrototype picked;
  255. if (args.Winner == null)
  256. {
  257. picked = (GameMapPrototype)_random.Pick(args.Winners);
  258. _chatManager.DispatchServerAnnouncement(
  259. Loc.GetString("ui-vote-map-tie", ("picked", maps[picked])));
  260. }
  261. else
  262. {
  263. picked = (GameMapPrototype)args.Winner;
  264. _chatManager.DispatchServerAnnouncement(
  265. Loc.GetString("ui-vote-map-win", ("winner", maps[picked])));
  266. }
  267. _adminLogger.Add(LogType.Vote, LogImpact.Medium, $"Map vote finished: {picked.MapName}");
  268. var ticker = _entityManager.EntitySysManager.GetEntitySystem<GameTicker>();
  269. if (ticker.CanUpdateMap())
  270. {
  271. if (_gameMapManager.TrySelectMapIfEligible(picked.ID))
  272. {
  273. _configurationManager.SetCVar(CCVars.GameMap, picked.ID);
  274. ticker.UpdateInfoText();
  275. }
  276. }
  277. else
  278. {
  279. _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-map-notlobby"));
  280. }
  281. };
  282. }
  283. private async void CreateVotekickVote(ICommonSession? initiator, string[]? args)
  284. {
  285. if (args == null || args.Length <= 1)
  286. {
  287. return;
  288. }
  289. if (_roleSystem == null)
  290. _roleSystem = _entityManager.SystemOrNull<RoleSystem>();
  291. if (_votingSystem == null)
  292. _votingSystem = _entityManager.SystemOrNull<VotingSystem>();
  293. // Check that the initiator is actually allowed to do a votekick.
  294. if (_votingSystem != null && !await _votingSystem.CheckVotekickInitEligibility(initiator))
  295. {
  296. _logManager.GetSawmill("admin.votekick").Warning($"User {initiator} attempted a votekick, despite not being eligible to!");
  297. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator}, but they are not eligible to votekick!");
  298. DirtyCanCallVoteAll();
  299. return;
  300. }
  301. var voterEligibility = _cfg.GetCVar(CCVars.VotekickVoterGhostRequirement) ? VoterEligibility.GhostMinimumPlaytime : VoterEligibility.MinimumPlaytime;
  302. if (_cfg.GetCVar(CCVars.VotekickIgnoreGhostReqInLobby) && _gameTicker!.RunLevel == GameRunLevel.PreRoundLobby)
  303. voterEligibility = VoterEligibility.MinimumPlaytime;
  304. var eligibleVoterNumberRequirement = _cfg.GetCVar(CCVars.VotekickEligibleNumberRequirement);
  305. var eligibleVoterNumber = CalculateEligibleVoterNumber(voterEligibility);
  306. string target = args[0];
  307. string reason = args[1];
  308. // Start by getting all relevant target data
  309. var located = await _locator.LookupIdByNameOrIdAsync(target);
  310. if (located == null)
  311. {
  312. _logManager.GetSawmill("admin.votekick")
  313. .Warning($"Votekick attempted for player {target} but they couldn't be found!");
  314. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player string {target}, but they could not be found!");
  315. DirtyCanCallVoteAll();
  316. return;
  317. }
  318. var targetUid = located.UserId;
  319. var targetHWid = located.LastHWId;
  320. (IPAddress, int)? targetIP = null;
  321. if (located.LastAddress is not null)
  322. {
  323. targetIP = located.LastAddress.AddressFamily is AddressFamily.InterNetwork
  324. ? (located.LastAddress, 32) // People with ipv4 addresses get a /32 address so we ban that
  325. : (located.LastAddress, 64); // This can only be an ipv6 address. People with ipv6 address should get /64 addresses so we ban that.
  326. }
  327. if (!_playerManager.TryGetSessionById(located.UserId, out ICommonSession? targetSession))
  328. {
  329. _logManager.GetSawmill("admin.votekick")
  330. .Warning($"Votekick attempted for player {target} but their session couldn't be found!");
  331. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player string {target}, but they could not be found!");
  332. DirtyCanCallVoteAll();
  333. return;
  334. }
  335. string targetEntityName = located.Username; // Target's player-facing name when voting; uses the player's username as fallback if no entity name is found
  336. if (targetSession.AttachedEntity is { Valid: true } attached && _votingSystem != null)
  337. targetEntityName = _votingSystem.GetPlayerVoteListName(attached);
  338. var isAntagSafe = false;
  339. var targetMind = targetSession.GetMind();
  340. var playtime = _playtimeManager.GetPlayTimes(targetSession);
  341. // Check whether the target is an antag, and if they are, give them protection against the Raider votekick if they have the requisite hours.
  342. if (targetMind != null &&
  343. _roleSystem != null &&
  344. _roleSystem.MindIsAntagonist(targetMind) &&
  345. playtime.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out TimeSpan overallTime) &&
  346. overallTime >= TimeSpan.FromHours(_cfg.GetCVar(CCVars.VotekickAntagRaiderProtection)))
  347. {
  348. isAntagSafe = true;
  349. }
  350. // Don't let a user votekick themselves
  351. if (initiator == targetSession)
  352. {
  353. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for themselves? Votekick cancelled.");
  354. DirtyCanCallVoteAll();
  355. return;
  356. }
  357. // Cancels the vote if there's not enough voters; only the person initiating the vote gets a return message.
  358. if (eligibleVoterNumber < eligibleVoterNumberRequirement)
  359. {
  360. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player {targetSession}, but there were not enough ghost roles! {eligibleVoterNumberRequirement} required, {eligibleVoterNumber} found.");
  361. if (initiator != null)
  362. {
  363. var message = Loc.GetString("ui-vote-votekick-not-enough-eligible", ("voters", eligibleVoterNumber.ToString()), ("requirement", eligibleVoterNumberRequirement.ToString()));
  364. var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message));
  365. _chatManager.ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, initiator.Channel);
  366. }
  367. DirtyCanCallVoteAll();
  368. return;
  369. }
  370. // 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.
  371. if (_votingSystem != null && !_votingSystem.CheckVotekickTargetEligibility(targetSession))
  372. {
  373. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick attempted by {initiator} for player {targetSession}, but they are not eligible to be votekicked!");
  374. DirtyCanCallVoteAll();
  375. return;
  376. }
  377. // Create the vote object
  378. string voteTitle = "";
  379. NetEntity? targetNetEntity = _entityManager.GetNetEntity(targetSession.AttachedEntity);
  380. var initiatorName = initiator != null ? initiator.Name : Loc.GetString("ui-vote-votekick-unknown-initiator");
  381. voteTitle = Loc.GetString("ui-vote-votekick-title", ("initiator", initiatorName), ("targetEntity", targetEntityName), ("reason", reason));
  382. var options = new VoteOptions
  383. {
  384. Title = voteTitle,
  385. Options =
  386. {
  387. (Loc.GetString("ui-vote-votekick-yes"), "yes"),
  388. (Loc.GetString("ui-vote-votekick-no"), "no"),
  389. (Loc.GetString("ui-vote-votekick-abstain"), "abstain")
  390. },
  391. Duration = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VotekickTimer)),
  392. InitiatorTimeout = TimeSpan.FromMinutes(_cfg.GetCVar(CCVars.VotekickTimeout)),
  393. VoterEligibility = voterEligibility,
  394. DisplayVotes = false,
  395. TargetEntity = targetNetEntity
  396. };
  397. WirePresetVoteInitiator(options, initiator);
  398. var vote = CreateVote(options);
  399. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} ({targetEntityName}) due to {reason} started, initiated by {initiator}.");
  400. // Create Discord webhook
  401. var webhookState = _voteWebhooks.CreateWebhookIfConfigured(options, _cfg.GetCVar(CCVars.DiscordVotekickWebhook), Loc.GetString("votekick-webhook-name"), options.Title + "\n" + Loc.GetString("votekick-webhook-description", ("initiator", initiatorName), ("target", targetSession)));
  402. // Time out the vote now that we know it will happen
  403. TimeoutStandardVote(StandardVoteType.Votekick);
  404. vote.OnFinished += (_, eventArgs) =>
  405. {
  406. var votesYes = vote.VotesPerOption["yes"];
  407. var votesNo = vote.VotesPerOption["no"];
  408. var total = votesYes + votesNo;
  409. // Get the voters, for logging purposes.
  410. List<ICommonSession> yesVoters = new();
  411. List<ICommonSession> noVoters = new();
  412. foreach (var (voter, castVote) in vote.CastVotes)
  413. {
  414. if (castVote == 0)
  415. {
  416. yesVoters.Add(voter);
  417. }
  418. if (castVote == 1)
  419. {
  420. noVoters.Add(voter);
  421. }
  422. }
  423. var yesVotersString = string.Join(", ", yesVoters);
  424. var noVotersString = string.Join(", ", noVoters);
  425. var ratioRequired = _cfg.GetCVar(CCVars.VotekickRequiredRatio);
  426. if (total > 0 && votesYes / (float)total >= ratioRequired)
  427. {
  428. // Some conditions that cancel the vote want to let the vote run its course first and then cancel it
  429. // so we check for that here
  430. // Check if an admin is online, and ignore the vote if the cvar is enabled
  431. if (_cfg.GetCVar(CCVars.VotekickNotAllowedWhenAdminOnline) && _adminMgr.ActiveAdmins.Count() != 0)
  432. {
  433. _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}");
  434. AnnounceCancelledVotekickForVoters(targetEntityName);
  435. _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-admin-online"));
  436. return;
  437. }
  438. // Check if the target is an antag and the vote reason is raiding (this is to prevent false positives)
  439. else if (isAntagSafe && reason == VotekickReasonType.Raiding.ToString())
  440. {
  441. _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.");
  442. AnnounceCancelledVotekickForVoters(targetEntityName);
  443. _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-antag-target"));
  444. return;
  445. }
  446. // Check if the target is an admin/de-admined admin
  447. else if (targetSession.AttachedEntity != null && _adminMgr.IsAdmin(targetSession.AttachedEntity.Value, true))
  448. {
  449. _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.");
  450. AnnounceCancelledVotekickForVoters(targetEntityName);
  451. _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-admin-target"));
  452. return;
  453. }
  454. else
  455. {
  456. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} succeeded: Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}");
  457. _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-votekick-success", ("target", targetEntityName), ("reason", reason)));
  458. if (!Enum.TryParse(_cfg.GetCVar(CCVars.VotekickBanDefaultSeverity), out NoteSeverity severity))
  459. {
  460. _logManager.GetSawmill("admin.votekick")
  461. .Warning("Votekick ban severity could not be parsed from config! Defaulting to high.");
  462. severity = NoteSeverity.High;
  463. }
  464. // Discord webhook, success
  465. _voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs);
  466. uint minutes = (uint)_cfg.GetCVar(CCVars.VotekickBanDuration);
  467. _bans.CreateServerBan(targetUid, target, null, targetIP, targetHWid, minutes, severity, Loc.GetString("votekick-ban-reason", ("reason", reason)));
  468. }
  469. }
  470. else
  471. {
  472. // Discord webhook, failure
  473. _voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs);
  474. _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick failed: Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}");
  475. _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-votekick-failure", ("target", targetEntityName), ("reason", reason)));
  476. }
  477. };
  478. if (initiator != null)
  479. {
  480. // Cast yes vote if created the vote yourself.
  481. vote.CastVote(initiator, 0);
  482. }
  483. }
  484. private void AnnounceCancelledVotekickForVoters(string target)
  485. {
  486. foreach (var player in _playerManager.Sessions)
  487. {
  488. if (CheckVoterEligibility(player, VoterEligibility.GhostMinimumPlaytime))
  489. {
  490. var message = Loc.GetString("ui-vote-votekick-server-cancelled", ("target", target));
  491. var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message));
  492. _chatManager.ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, player.Channel);
  493. }
  494. }
  495. }
  496. private void TimeoutStandardVote(StandardVoteType type)
  497. {
  498. var timeout = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.VoteSameTypeTimeout));
  499. _standardVoteTimeout[type] = _timing.RealTime + timeout;
  500. DirtyCanCallVoteAll();
  501. }
  502. private Dictionary<string, string> GetGamePresets()
  503. {
  504. var presets = new Dictionary<string, string>();
  505. foreach (var preset in _prototypeManager.EnumeratePrototypes<GamePresetPrototype>())
  506. {
  507. if (!preset.ShowInVote)
  508. continue;
  509. if (_playerManager.PlayerCount < (preset.MinPlayers ?? int.MinValue))
  510. continue;
  511. if (_playerManager.PlayerCount > (preset.MaxPlayers ?? int.MaxValue))
  512. continue;
  513. presets[preset.ID] = preset.ModeTitle;
  514. }
  515. return presets;
  516. }
  517. }
  518. }