* Basic voting

* Rewrite lobby in XAML.
Working lobby voting.

* Escape menu is now XAML.

* Vote menu works, custom votes, gamemode votes.

* Vote timeouts & administration.

Basically done now.

* I will now pretend I was never planning to code voting hotkeys.

* Make vote call UI a bit... funny.

* Fix exception on round restart.

* Fix some vote command definitions.
This commit is contained in:
Pieter-Jan Briers
2021-02-16 15:07:17 +01:00
committed by GitHub
parent db290fd91e
commit cea87d6985
35 changed files with 2001 additions and 413 deletions

View File

@@ -0,0 +1,18 @@
using Robust.Server.Player;
namespace Content.Server.Voting
{
public interface IVoteHandle
{
int Id { get; }
string Title { get; }
string InitiatorText { get; }
bool Finished { get; }
bool Cancelled { get; }
event VoteFinishedEventHandler OnFinished;
bool IsValidOption(int optionId);
void CastVote(IPlayerSession session, int? optionId);
void Cancel();
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Robust.Server.Player;
#nullable enable
namespace Content.Server.Voting
{
public interface IVoteManager
{
IEnumerable<IVoteHandle> ActiveVotes { get; }
bool TryGetVote(int voteId, [NotNullWhen(true)] out IVoteHandle? vote);
bool CanCallVote(IPlayerSession initiator);
void CreateRestartVote(IPlayerSession? initiator);
void CreatePresetVote(IPlayerSession? initiator);
IVoteHandle CreateVote(VoteOptions options);
void Initialize();
void Update();
}
}

View File

@@ -0,0 +1,210 @@
using System;
using System.Linq;
using System.Text;
using Content.Server.Administration;
using Content.Server.Interfaces.Chat;
using Content.Shared.Administration;
using Robust.Server.Player;
using Robust.Shared.Console;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
#nullable enable
namespace Content.Server.Voting
{
[AnyCommand]
public sealed class CreateVoteCommand : IConsoleCommand
{
public string Command => "createvote";
public string Description => "Creates a vote";
public string Help => "Usage: createvote <'restart'|'preset'>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length != 1)
{
shell.WriteError("Need exactly one argument!");
return;
}
var type = args[0];
var mgr = IoCManager.Resolve<IVoteManager>();
if (shell.Player != null && !mgr.CanCallVote((IPlayerSession) shell.Player))
{
shell.WriteError("You can't call a vote right now!");
return;
}
switch (type)
{
case "restart":
mgr.CreateRestartVote((IPlayerSession?) shell.Player);
break;
case "preset":
mgr.CreatePresetVote((IPlayerSession?) shell.Player);
break;
default:
shell.WriteError("Invalid vote type!");
break;
}
}
}
[AdminCommand(AdminFlags.Fun)]
public sealed class CreateCustomCommand : IConsoleCommand
{
public string Command => "customvote";
public string Description => "Creates a custom vote";
public string Help => "customvote <title> <option1> <option2> [option3...]";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length < 3 || args.Length > 10)
{
shell.WriteError("Need 3 to 10 arguments!");
return;
}
var title = args[0];
var mgr = IoCManager.Resolve<IVoteManager>();
var options = new VoteOptions
{
Title = title,
Duration = TimeSpan.FromSeconds(30),
};
for (var i = 1; i < args.Length; i++)
{
options.Options.Add((args[i], i));
}
options.SetInitiatorOrServer((IPlayerSession?) shell.Player);
var vote = mgr.CreateVote(options);
vote.OnFinished += (_, eventArgs) =>
{
var chatMgr = IoCManager.Resolve<IChatManager>();
if (eventArgs.Winner == null)
{
var ties = string.Join(", ", eventArgs.Winners.Select(c => args[(int) c]));
chatMgr.DispatchServerAnnouncement(Loc.GetString("Tie between {0}!", ties));
}
else
{
chatMgr.DispatchServerAnnouncement(Loc.GetString("{0} wins!", args[(int) eventArgs.Winner]));
}
};
}
}
[AnyCommand]
public sealed class VoteCommand : IConsoleCommand
{
public string Command => "vote";
public string Description => "Votes on an active vote";
public string Help => "vote <voteId> <option>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (shell.Player == null)
{
shell.WriteError("Must be a player");
return;
}
if (args.Length != 2)
{
shell.WriteError("Expected two arguments.");
return;
}
if (!int.TryParse(args[0], out var voteId))
{
shell.WriteError("Invalid vote ID");
return;
}
if (!int.TryParse(args[1], out var voteOption))
{
shell.WriteError("Invalid vote options");
return;
}
var mgr = IoCManager.Resolve<IVoteManager>();
if (!mgr.TryGetVote(voteId, out var vote))
{
shell.WriteError("Invalid vote");
return;
}
int? optionN;
if (voteOption == -1)
{
optionN = null;
}
else if (vote.IsValidOption(voteOption))
{
optionN = voteOption;
}
else
{
shell.WriteError("Invalid option");
return;
}
vote.CastVote((IPlayerSession) shell.Player!, optionN);
}
}
[AnyCommand]
public sealed class ListVotesCommand : IConsoleCommand
{
public string Command => "listvotes";
public string Description => "Lists currently active votes";
public string Help => "Usage: listvotes";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
var mgr = IoCManager.Resolve<IVoteManager>();
foreach (var vote in mgr.ActiveVotes)
{
shell.WriteLine($"[{vote.Id}] {vote.InitiatorText}: {vote.Title}");
}
}
}
[AdminCommand(AdminFlags.Admin)]
public sealed class CancelVoteCommand : IConsoleCommand
{
public string Command => "cancelvote";
public string Description => "Cancels an active vote";
public string Help => "Usage: cancelvote <id>\nYou can get the ID from the listvotes command.";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
var mgr = IoCManager.Resolve<IVoteManager>();
if (args.Length < 1)
{
shell.WriteError("Missing ID");
return;
}
if (!int.TryParse(args[0], out var id) || !mgr.TryGetVote(id, out var vote))
{
shell.WriteError("Invalid vote ID");
return;
}
vote.Cancel();
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Immutable;
#nullable enable
namespace Content.Server.Voting
{
public sealed class VoteFinishedEventArgs : EventArgs
{
/// <summary>
/// Null if stalemate.
/// </summary>
public readonly object? Winner;
/// <summary>
/// Winners. More than one if there was a stalemate.
/// </summary>
public readonly ImmutableArray<object> Winners;
public VoteFinishedEventArgs(object? winner, ImmutableArray<object> winners)
{
Winner = winner;
Winners = winners;
}
}
}

View File

@@ -0,0 +1,7 @@
#nullable enable
namespace Content.Server.Voting
{
public delegate void VoteFinishedEventHandler(IVoteHandle sender, VoteFinishedEventArgs args);
public delegate void VoteCancelledEventHandler(IVoteHandle sender);
}

View File

@@ -0,0 +1,114 @@
#nullable enable
using System;
using System.Collections.Generic;
using Robust.Server.Player;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Random;
namespace Content.Server.Voting
{
public sealed partial class VoteManager
{
public void CreateRestartVote(IPlayerSession? initiator)
{
var alone = _playerManager.PlayerCount == 1 && initiator != null;
var options = new VoteOptions
{
Title = Loc.GetString("Restart round"),
Options =
{
(Loc.GetString("Yes"), true),
(Loc.GetString("No"), false)
},
Duration = alone
? TimeSpan.FromSeconds(10)
: TimeSpan.FromSeconds(30)
};
if (alone)
options.InitiatorTimeout = TimeSpan.FromSeconds(10);
WirePresetVoteInitiator(options, initiator);
var vote = CreateVote(options);
vote.OnFinished += (_, args) =>
{
if (args.Winner == null)
{
_chatManager.DispatchServerAnnouncement(Loc.GetString("Restart vote failed due to tie."));
return;
}
var win = (bool) args.Winner;
if (win)
{
_chatManager.DispatchServerAnnouncement(Loc.GetString("Restart vote succeeded."));
_ticker.RestartRound();
}
else
{
_chatManager.DispatchServerAnnouncement(Loc.GetString("Restart vote failed."));
}
};
if (initiator != null)
{
// Cast yes vote if created the vote yourself.
vote.CastVote(initiator, 0);
}
}
public void CreatePresetVote(IPlayerSession? initiator)
{
var presets = new Dictionary<string, string>
{
["traitor"] = "Traitor",
["extended"] = "Extended",
["sandbox"] = "Sandbox",
["suspicion"] = "Suspicion"
};
var alone = _playerManager.PlayerCount == 1 && initiator != null;
var options = new VoteOptions
{
Title = Loc.GetString("Next gamemode"),
Duration = alone
? TimeSpan.FromSeconds(10)
: TimeSpan.FromSeconds(30)
};
if (alone)
options.InitiatorTimeout = TimeSpan.FromSeconds(10);
foreach (var (k, v) in presets)
{
options.Options.Add((Loc.GetString(v), k));
}
WirePresetVoteInitiator(options, initiator);
var vote = CreateVote(options);
vote.OnFinished += (_, args) =>
{
string picked;
if (args.Winner == null)
{
picked = (string) IoCManager.Resolve<IRobustRandom>().Pick(args.Winners);
_chatManager.DispatchServerAnnouncement(
Loc.GetString("Tie for gamemode vote! Picking... {0}", Loc.GetString(presets[picked])));
}
else
{
picked = (string) args.Winner;
_chatManager.DispatchServerAnnouncement(
Loc.GetString("{0} won the gamemode vote!", Loc.GetString(presets[picked])));
}
_ticker.SetStartPreset(picked);
};
}
}
}

View File

@@ -0,0 +1,428 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Administration;
using Content.Server.Interfaces.Chat;
using Content.Server.Interfaces.GameTicking;
using Content.Shared.Administration;
using Content.Shared.Network.NetMessages;
using Content.Shared.Utility;
using Robust.Server.Player;
using Robust.Shared.Enums;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Network;
using Robust.Shared.Timing;
#nullable enable
namespace Content.Server.Voting
{
public sealed partial class VoteManager : IVoteManager
{
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IGameTicker _ticker = default!;
[Dependency] private readonly IAdminManager _adminMgr = default!;
private int _nextVoteId = 1;
private readonly Dictionary<int, VoteReg> _votes = new();
private readonly Dictionary<int, VoteHandle> _voteHandles = new();
private readonly Dictionary<NetUserId, TimeSpan> _voteTimeout = new();
private readonly HashSet<IPlayerSession> _playerCanCallVoteDirty = new();
public void Initialize()
{
_netManager.RegisterNetMessage<MsgVoteData>(MsgVoteData.NAME);
_netManager.RegisterNetMessage<MsgVoteCanCall>(MsgVoteCanCall.NAME);
_playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
_adminMgr.OnPermsChanged += AdminPermsChanged;
}
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, IPlayerSession 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 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);
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.GetAllPlayers())
{
SendSingleUpdate(v, player);
}
v.VotesDirty.Clear();
v.Dirty = false;
}
private void SendSingleUpdate(VoteReg v, IPlayerSession player)
{
var msg = _netManager.CreateNetMessage<MsgVoteData>();
msg.VoteId = v.Id;
msg.VoteActive = !v.Finished;
if (!v.Finished)
{
msg.VoteTitle = v.Title;
msg.VoteInitiator = v.InitiatorText;
msg.StartTime = v.StartTime;
msg.EndTime = v.EndTime;
}
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;
}
}
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);
}
player.ConnectedClient.SendMessage(msg);
}
private void DirtyCanCallVoteAll()
{
_playerCanCallVoteDirty.UnionWith(_playerManager.GetAllPlayers());
}
private void SendUpdateCanCallVote(IPlayerSession player)
{
var msg = _netManager.CreateNetMessage<MsgVoteCanCall>();
msg.CanCall = CanCallVote(player);
_netManager.ServerSendMessage(msg, player.ConnectedClient);
}
public bool CanCallVote(IPlayerSession player)
{
// Admins can always call votes.
if (_adminMgr.HasAdminFlag(player, AdminFlags.Admin))
{
return true;
}
// Cannot start vote if vote is already active (as non-admin).
if (_votes.Count != 0)
{
return false;
}
return !_voteTimeout.ContainsKey(player.UserId);
}
private void EndVote(VoteReg v)
{
if (v.Finished)
{
return;
}
// Find winner or stalemate.
var winners = v.Entries
.GroupBy(e => e.Votes)
.OrderByDescending(g => g.Key)
.First()
.Select(e => e.Data)
.ToImmutableArray();
v.Finished = true;
v.Dirty = true;
var args = new VoteFinishedEventArgs(winners.Length == 1 ? winners[0] : null, winners);
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 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(IPlayerSession player)
{
_playerCanCallVoteDirty.Add(player);
}
#region Preset Votes
private void WirePresetVoteInitiator(VoteOptions options, IPlayerSession? player)
{
if (player != null)
{
options.SetInitiator(player);
}
else
{
options.InitiatorText = Loc.GetString("The server");
}
}
#endregion
#region Vote Data
private sealed class VoteReg
{
public readonly int Id;
public readonly Dictionary<IPlayerSession, 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<IPlayerSession> VotesDirty = new();
public bool Cancelled;
public bool Finished;
public bool Dirty = true;
public VoteFinishedEventHandler? OnFinished;
public VoteCancelledEventHandler? OnCancelled;
public IPlayerSession? Initiator { get; }
public VoteReg(int id, VoteEntry[] entries, string title, string initiatorText,
IPlayerSession? initiator, TimeSpan start, TimeSpan end)
{
Id = id;
Entries = entries;
Title = title;
InitiatorText = initiatorText;
Initiator = initiator;
StartTime = start;
EndTime = end;
}
}
private struct VoteEntry
{
public object Data;
public string Text;
public int Votes;
public VoteEntry(object data, string text)
{
Data = data;
Text = text;
Votes = 0;
}
}
#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 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;
}
public bool IsValidOption(int optionId)
{
return _mgr.IsValidOption(_reg, optionId);
}
public void CastVote(IPlayerSession session, int? optionId)
{
_mgr.CastVote(_reg, session, optionId);
}
public void Cancel()
{
_mgr.CancelVote(_reg);
}
}
#endregion
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using Robust.Server.Player;
using Robust.Shared.Localization;
#nullable enable
namespace Content.Server.Voting
{
/// <summary>
/// Options for creating a vote.
/// </summary>
public sealed class VoteOptions
{
/// <summary>
/// The text that is shown for "who called the vote".
/// </summary>
public string InitiatorText { get; set; } = "<placeholder>";
/// <summary>
/// The player that started the vote. Used to keep track of player cooldowns to avoid vote spam.
/// </summary>
public IPlayerSession? InitiatorPlayer { get; set; }
/// <summary>
/// The shown title of the vote.
/// </summary>
public string Title { get; set; } = "<somebody forgot to fill this in lol>";
/// <summary>
/// How long the vote lasts.
/// </summary>
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// How long the initiator should be timed out from calling votes. Defaults to duration * 2;
/// </summary>
public TimeSpan? InitiatorTimeout { get; set; }
/// <summary>
/// The options of the vote. Each entry is a tuple of the player-shown text,
/// and a data object that can be used to keep track of options later.
/// </summary>
public List<(string text, object data)> Options { get; set; } = new();
/// <summary>
/// Sets <see cref="InitiatorPlayer"/> and <see cref="InitiatorText"/>
/// by setting the latter to the player's name.
/// </summary>
public void SetInitiator(IPlayerSession player)
{
InitiatorPlayer = player;
InitiatorText = player.Name;
}
public void SetInitiatorOrServer(IPlayerSession? player)
{
if (player != null)
{
SetInitiator(player);
}
else
{
InitiatorText = Loc.GetString("The server");
}
}
}
}