diff --git a/AsyncVoteManager.cs b/AsyncVoteManager.cs new file mode 100644 index 0000000..2e71c8a --- /dev/null +++ b/AsyncVoteManager.cs @@ -0,0 +1,51 @@ + +namespace cs2_rockthevote +{ + public enum VoteResult { + Added, + AlreadyAddedBefore, + VotesAlreadyReached, + VotesReached + } + + public class AsyncVoteManager + { + private List votes = new(); + public int VoteCount => votes.Count; + public int RequiredVotes => VoteValidator.RequiredVotes; + + public AsyncVoteManager(AsyncVoteValidator voteValidator) + { + VoteValidator = voteValidator; + } + + private readonly AsyncVoteValidator VoteValidator; + + public bool VotesAlreadyReached { get; set; } = false; + + public VoteResult AddVote(int userId) + { + if (VotesAlreadyReached) + return VoteResult.VotesAlreadyReached; + + if (votes.IndexOf(userId) != -1) + return VoteResult.AlreadyAddedBefore; + + votes.Add(userId); + if(VoteValidator.CheckVotes(votes.Count)) + { + VotesAlreadyReached = true; + return VoteResult.VotesReached; + } + + return VoteResult.Added; + } + + public void RemoveVote(int userId) + { + var index = votes.IndexOf(userId); + if(index > -1) + votes.RemoveAt(index); + } + } +} diff --git a/AsyncVoteValidator.cs b/AsyncVoteValidator.cs new file mode 100644 index 0000000..b0ae0ff --- /dev/null +++ b/AsyncVoteValidator.cs @@ -0,0 +1,22 @@ +namespace cs2_rockthevote +{ + public class AsyncVoteValidator + { + private float VotePercentage = 0F; + + private readonly ServerManager Server; + + public int RequiredVotes { get => (int)Math.Ceiling(Server.ValidPlayerCount * VotePercentage); } + + public AsyncVoteValidator(int votePercentage, ServerManager server) + { + VotePercentage = votePercentage / 100F; + Server = server; + } + + public bool CheckVotes(int numberOfVotes) + { + return numberOfVotes >= RequiredVotes; + } + } +} diff --git a/Config.cs b/Config.cs index 296c077..b632b99 100644 --- a/Config.cs +++ b/Config.cs @@ -1,19 +1,12 @@ using CounterStrikeSharp.API.Core; -using System.Text.Json.Serialization; namespace cs2_rockthevote { public class Config : IBasePluginConfig { - [JsonPropertyName("MinPlayers")] - public int MinPlayers { get; set; } = 0; - - [JsonPropertyName("VotePercentage")] - public decimal VotePercentage { get; set; } = 0.6M; - - [JsonPropertyName("Language")] - public string Language { get; set; } = "en"; - - public int Version { get; set; } = 1; + public int Version { get; set; } = 2; + public int RtvVotePercentage { get; set; } = 60; + public int RtvMinPlayers { get; set; } = 0; + public bool DisableVotesInWarmup { get; set; } = false; } } diff --git a/NominationManager.cs b/NominationManager.cs new file mode 100644 index 0000000..25272ad --- /dev/null +++ b/NominationManager.cs @@ -0,0 +1,25 @@ + +namespace cs2_rockthevote +{ + public class NominationManager + { + Dictionary Nominations = new(); + + public void Nominate(int userId, string map) + { + Nominations[userId] = map; + } + + public List Votes() + { + return Nominations + .Select(x => x.Value) + .Distinct() + .Select(map => new KeyValuePair(map, Nominations.Select(x => x.Value == map).Count())) + .OrderByDescending(x => x.Value) + .Select(x => x.Key) + .Take(5) + .ToList(); + } + } +} diff --git a/README.md b/README.md index fd4ac88..b56c439 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # CS2 Rock The Vote -Players can type rtv to request the map to be changed, once a number of votes is reached (by default 60% of players in the server) the map will end in the end of the round and trigger a next map vote (that you need to configure yourself using CS2 built in nextmap vote system) +Players can type rtv to request the map to be changed, once a number of votes is reached (by default 60% of players in the server) a vote will start for the next map, this vote lasts up to 30 seconds (hardcoded for now), in the end server changes to the winner map. + +# Features +- Reads from a custom maplist +- nominate command # Limitations - I haven't tested this with a server with more than 1 player, so this is a WIP version, I will address all feedback and issues that appear. - - This is intended to be used alongside the built in map vote system in CS2 so you need to configure end of map vote in CS2 yourself. - - For now only English and Brazilian Portuguese are supported languages, adding translations require recompiling the plugin for now, this will likely change in the near future, feel fre to open a PR adding a new language, I will be glad to review and recompile the plugin myself. + - Previous version relied on the official CS2 vote system, I pivoted this idea in favor of adding nominate, I will probably create another plugin with the original idea as soon as I figure out how to do the nominate command that way. + # Requirements [Latest release of Counter Strike Sharp](https://github.com/roflmuffin/CounterStrikeSharp) @@ -20,9 +24,16 @@ Players can type rtv to request the map to be changed, once a number of votes is ```json { - "MinPlayers": 0, // Number of players required to enable the command - "VotePercentage": 0.6, // Percentage of votes required to change the map - "Language": "en", // The language, for now only en and pt are valid values - "Version": 1 // Don't chang this + "Version": 2, + "RtvVotePercentage": 60, + "RtvMinPlayers": 0, + "DisableVotesInWarmup": false } ``` + +Maps that will be used in RTV are located in addons/counterstrikesharp/configs/plugins/RockTheVote/maplist.txt + +# TODO +- Add minimum rounds to use commands. +- Add votemap. +- Translations support diff --git a/RockTheVote.cs b/RockTheVote.cs index 9237064..c9d0c31 100644 --- a/RockTheVote.cs +++ b/RockTheVote.cs @@ -1,32 +1,28 @@ using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using System.ComponentModel.Design; using static CounterStrikeSharp.API.Core.Listeners; namespace cs2_rockthevote { public class RockTheVote : BasePlugin, IPluginConfig { - public override string ModuleName => "AbNeR Rock The Vote"; - public override string ModuleVersion => "0.0.1"; - public override string ModuleAuthor => "AbNeR_CSS"; + public override string ModuleName => "RockTheVote"; + public override string ModuleVersion => "0.0.2"; + public override string ModuleAuthor => "abnerfs"; public override string ModuleDescription => "You know what it is, rtv"; CCSGameRules? _gameRules = null; + ServerManager ServerManager = new(); + NominationManager NominationManager = new(); + AsyncVoteManager Rtv = null; + List Maps = new(); - RtvManager? _rtvManager; - Translations? _translations { get; set; } public Config? Config { get; set; } - public void OnConfigParsed(Config config) - { - Console.WriteLine("RockTheVote Config parsed"); - Config = config; - _translations = new Translations(config.Language); - SetupRtvManager(); - } - public bool WarmupRunning { get @@ -38,79 +34,170 @@ public bool WarmupRunning } } - void NewVoteCallback(object? _e, NewVoteArgs args) + void LoadMaps() { - var player = Utilities.GetPlayerFromUserid(args.UserId); - if (player.IsValid) - if (args.AlreadyVoted) - Server.PrintToChatAll(_translations!.ParseMessage(Translations.AlreadyVoted, new TranslationParams { Voted = args.Votes, VotesNeeded = args.VotesNeeded })); - else - Server.PrintToChatAll(_translations!.ParseMessage(Translations.Voted, new TranslationParams { PlayerName = player.PlayerName, Voted = args.Votes, VotesNeeded = args.VotesNeeded })); + Maps = new List(); + string mapsFile = Path.Combine(ModuleDirectory, "maplist.txt"); + if (!File.Exists(mapsFile)) + throw new FileNotFoundException(mapsFile); + + Maps = File.ReadAllText(mapsFile) + .Replace("\r\n", "\n") + .Split("\n") + .Select(x => x.Trim()) + .Where(x => !x.StartsWith("//")) + .Where(x => Server.IsMapValid(x)) + .ToList(); } - void VotesReachedCallback(object? _e, EventArgs args) + public override void Load(bool hotReload) { - Server.ExecuteCommand("mp_timelimit 0.01"); - Server.ExecuteCommand("mp_maxrounds 0"); - Server.PrintToChatAll(_translations!.ParseMessage(Translations.VotesReached, new TranslationParams())); + Init(); + RegisterListener((_mapname) => Init()); } - void SetupRtvManager() + void Init() { - _rtvManager = new(Config!); - _rtvManager.NewVoteEvent += NewVoteCallback; - _rtvManager.VotesReachedEvent += VotesReachedCallback; + NominationManager = new(); + LoadMaps(); + _gameRules = null; + AddTimer(1.0F, SetGameRules); + if (Config is not null) + { + AsyncVoteValidator validator = new(Config!.RtvVotePercentage, ServerManager); + Rtv = new AsyncVoteManager(validator); + } } - public override void Load(bool hotReload) + void SetGameRules() => _gameRules = Utilities.FindAllEntitiesByDesignerName("cs_gamerules").First().GameRules!; + + bool ValidateCommand(CCSPlayerController? player) { - OnMapStart(Server.MapName); - RegisterListener(OnMapStart); - } + if (player is null || !player.IsValid) return false; + if (WarmupRunning && Config!.DisableVotesInWarmup) + { + player.PrintToChat("[RockTheVote] Command disabled during warmup."); + return false; + } - void SetGameRules() => _gameRules = Utilities.FindAllEntitiesByDesignerName("cs_gamerules").First().GameRules!; - void OnMapStart(string _mapname) - { - _gameRules = null; - AddTimer(1.0F, SetGameRules); - SetupRtvManager(); - } + if (ServerManager.ValidPlayerCount < Config!.RtvMinPlayers) + { + player.PrintToChat($"[RockTheVote] Minimum players to use this command is {Config.RtvMinPlayers}"); + return false; + } + return true; + } [GameEventHandler(HookMode.Pre)] public HookResult EventPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo @eventInfo) { var userId = @event.Userid.UserId!.Value; - _rtvManager!.RemovePlayer(userId); + Rtv.RemoveVote(userId); return HookResult.Continue; } + void NominateHandler(CCSPlayerController? player, string map) + { + if (string.IsNullOrEmpty(map)) + { + player!.PrintToChat($"[RockTheVote] Usage: nominate "); + } + else if (Server.IsMapValid(map)) + { + if (map == Server.MapName) + { + player!.PrintToChat($"[RockTheVote] You can't nominate the current map"); + return; + } + + NominationManager.Nominate(player.UserId!.Value, map); + Server.PrintToChatAll($"[RockTheVote] Player {player.PlayerName} nominated map {map}"); + } + else + { + player!.PrintToChat($"[RockTheVote] Invalid map"); + } + } + + [ConsoleCommand("nominate", "nominate a map to rtv")] + public void OnNominate(CCSPlayerController? player, CommandInfo command) + { + if (!ValidateCommand(player)) + return; + + string map = command.GetArg(1); + NominateHandler(player, map); + } + + IList Shuffle(Random rng, IList array) + { + int n = array.Count; + while (n > 1) + { + int k = rng.Next(n--); + T temp = array[n]; + array[n] = array[k]; + array[k] = temp; + } + return array; + } + + + [ConsoleCommand("rtv", "Votes to rock the vote")] + public void OnRTV(CCSPlayerController? player, CommandInfo? command) + { + if (!ValidateCommand(player)) + return; + + VoteResult result = Rtv.AddVote(player!.UserId!.Value); + switch (result) + { + case VoteResult.Added: + Server.PrintToChatAll($"[RockTheVote] {player.PlayerName} wants to rock the vote ({Rtv.VoteCount} voted, {Rtv.RequiredVotes} needed)"); + break; + case VoteResult.AlreadyAddedBefore: + player.PrintToChat($"[RockTheVote] You already rocked the vote ({Rtv.VoteCount} voted, {Rtv.RequiredVotes} needed)"); + break; + case VoteResult.VotesReached: + Server.PrintToChatAll("[RockTheVote] Number of votes reached, the vote for the next map will start"); + var mapsScrambled = Shuffle(new Random(), Maps.Where(x => x != Server.MapName).ToList()); + var maps = NominationManager.Votes().Concat(mapsScrambled).Distinct().ToList(); + VoteManager manager = new(maps!, this, 30, ServerManager.ValidPlayerCount); + manager.StartVote(); + break; + } + } [GameEventHandler(HookMode.Post)] public HookResult OnChat(EventPlayerChat @event, GameEventInfo info) { var player = Utilities.GetPlayerFromUserid(@event.Userid); - if (player is null || !player.IsValid || _rtvManager is null || Config is null) + if (!ValidateCommand(player)) return HookResult.Continue; + var text = @event.Text.Trim().ToLower(); if (@event.Text.Trim() == "rtv") { - if (WarmupRunning) - { - player.PrintToChat(_translations!.ParseMessage(Translations.WarmupRunning, new TranslationParams())); - } - else if (_rtvManager.NumberOfPlayers < Config.MinPlayers) - { - player.PrintToChat(_translations!.ParseMessage(Translations.MinimumPlayers, new TranslationParams() { MinPlayers = Config.MinPlayers })); - } - else - { - _rtvManager!.AddPlayer(player.UserId!.Value); - } + OnRTV(player, null); + } + else if (text.StartsWith("nominate")) + { + + var split = text.Split("nominate"); + var map = split.Length > 1 ? split[1].Trim() : ""; + NominateHandler(player, map); } + return HookResult.Continue; } + + public void OnConfigParsed(Config config) + { + Config = config; + Init(); + } } -} \ No newline at end of file +} diff --git a/RockTheVote.csproj b/RockTheVote.csproj index 4bb5dd0..7673c5b 100644 --- a/RockTheVote.csproj +++ b/RockTheVote.csproj @@ -14,4 +14,10 @@ + + + PreserveNewest + + + diff --git a/RtvManager.cs b/RtvManager.cs deleted file mode 100644 index 21233a4..0000000 --- a/RtvManager.cs +++ /dev/null @@ -1,72 +0,0 @@ -using CounterStrikeSharp.API; - -namespace cs2_rockthevote -{ - public class NewVoteArgs - { - public int UserId { get; init; } - public int Votes { get; init; } - public int VotesNeeded { get; init; } - public bool AlreadyVoted { get; init; } - - public NewVoteArgs(int userId, int votes, int requiredVotes, bool alreadyVoted) - { - UserId = userId; - Votes = votes; - VotesNeeded = requiredVotes; - AlreadyVoted = alreadyVoted; - } - } - - public class RtvManager - { - public event EventHandler? NewVoteEvent; - public event EventHandler? VotesReachedEvent; - public int NumberOfPlayers - { - get => Utilities.GetPlayers() - .Where(x => !x.IsBot) - .Count(); - } - - private Config _config; - - public bool VotesReached { get => Votes.Count >= RequiredVotes; } - - public RtvManager(Config config) - { - _config = config; - } - - int RequiredVotes { get => (int)Math.Ceiling(NumberOfPlayers * _config.VotePercentage); } - - List Votes { get; set; } = new(); - - void CheckVotes() - { - if (VotesReached) - VotesReachedEvent?.Invoke(this, new EventArgs()); - } - - public void AddPlayer(int userId) - { - if (VotesReached) - return; - - bool alreadyVoted = Votes.IndexOf(userId) > -1; - if (!alreadyVoted) - Votes.Add(userId); - - NewVoteEvent?.Invoke(this, new NewVoteArgs(userId, Votes.Count, RequiredVotes, alreadyVoted)); - CheckVotes(); - } - - public void RemovePlayer(int userId) - { - var index = Votes.IndexOf(userId); - if (index > -1) - Votes.RemoveAt(index); - CheckVotes(); - } - } -} diff --git a/ServerManager.cs b/ServerManager.cs new file mode 100644 index 0000000..0e3f95a --- /dev/null +++ b/ServerManager.cs @@ -0,0 +1,14 @@ +using CounterStrikeSharp.API; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace cs2_rockthevote +{ + public class ServerManager + { + public int ValidPlayerCount { get => Utilities.GetPlayers().Where(x => x.IsValid && !x.IsBot).Count(); } + } +} diff --git a/Translations.cs b/Translations.cs deleted file mode 100644 index 0325a08..0000000 --- a/Translations.cs +++ /dev/null @@ -1,99 +0,0 @@ -using CounterStrikeSharp.API.Modules.Utils; -using System.Text; - -namespace cs2_rockthevote -{ - public class TranslationParams - { - public string? PlayerName { get; set; } - public int? Voted { get; set; } - public int? VotesNeeded { get; set; } - public int? MinPlayers { get; set; } - } - - public class Translations - { - static Dictionary ColorDictionary = new() - { - {"{DEFAULT}", ChatColors.Default.ToString()}, - {"{WHITE}", ChatColors.White.ToString()}, - {"{DARKRED}", ChatColors.Darkred.ToString()}, - {"{GREEN}", ChatColors.Green.ToString()}, - {"{LIGHTYELLOW}", ChatColors.LightYellow.ToString()}, - {"{LIGHTBLUE}", ChatColors.LightBlue.ToString()}, - {"{OLIVE}", ChatColors.Olive.ToString()}, - {"{LIME}", ChatColors.Lime.ToString()}, - {"{RED}", ChatColors.Red.ToString()}, - {"{PURPLE}", ChatColors.Purple.ToString()}, - {"{GREY}", ChatColors.Grey.ToString()}, - {"{YELLOW}", ChatColors.Yellow.ToString()}, - {"{GOLD}", ChatColors.Gold.ToString()}, - {"{SILVER}", ChatColors.Silver.ToString()}, - {"{BLUE}", ChatColors.Blue.ToString()}, - {"{DARKBLUE}", ChatColors.DarkBlue.ToString()}, - {"{BLUEGREY}", ChatColors.BlueGrey.ToString()}, - {"{MAGENTA}", ChatColors.Magenta.ToString()}, - {"{LIGHTRED}", ChatColors.LightRed.ToString()}, - }; - private string _language; - - public static string Prefix { get; set; } = " {GREEN}[AbNeR RockTheVote]{DEFAULT} "; - - public static Dictionary Voted { get; } = new Dictionary() - { - {"en", "{PLAYER} wants to rock the vote ({VOTES} voted, {VOTES_NEEDED} needed)" }, - { "pt", "{PLAYER} quer trocar de mapa ({VOTES} votos, {VOTES_NEEDED} necessários)"} - }; - - public static Dictionary AlreadyVoted { get; } = new Dictionary() - { - {"en", "You already rocked the vote ({VOTES} voted, {VOTES_NEEDED} needed)" }, - {"pt", "Você já votou para trocar de mapa ({VOTES} votos, {VOTES_NEEDED} necessários)" } - }; - - public static Dictionary MinimumPlayers { get; } = new() - { - {"en", "Minimum players to use rtv is {MINPLAYERS}" }, - { "pt", "O mínimo de jogadores para usar o rtv é {MINPLAYERS}"} - }; - - public static Dictionary VotesReached { get; set; } = new() - { - {"en", "Number of votes reached, this is the last round!" }, - {"pt", "Número de votos atingido, essa é a última rodada!" } - }; - - public static Dictionary WarmupRunning { get; } = new() - { - {"en", "RTV disabled during wamup" }, - {"pt", "RTV desativado durante o aquecimento" } - }; - - static string ReplaceColors(string message) - { - foreach (var kv in ColorDictionary) - message = message.Replace(kv.Key, kv.Value); - - return message; - } - - public Translations(string language) - { - _language = language; - } - - public string ParseMessage(Dictionary message, TranslationParams @params) - { - var messageStr = message.ContainsKey(_language) ? message[_language] : message["en"]; - StringBuilder builder = new(); - builder.Append(Prefix); - builder.Append(messageStr - .Replace("{PLAYER}", @params.PlayerName ?? "") - .Replace("{VOTES}", @params.Voted.ToString() ?? "0") - .Replace("{VOTES_NEEDED}", @params.VotesNeeded.ToString() ?? "0") - .Replace("{MINPLAYERS}", @params.MinPlayers.ToString() ?? "0")); - - return ReplaceColors(builder.ToString()); - } - } -} diff --git a/VoteManager.cs b/VoteManager.cs new file mode 100644 index 0000000..a326c8c --- /dev/null +++ b/VoteManager.cs @@ -0,0 +1,123 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Menu; +using CounterStrikeSharp.API.Modules.Timers; +using System.Text; + +namespace cs2_rockthevote +{ + public class VoteManager + { + public VoteManager(List maps, RockTheVote plugin, int voteDuration, int canVoteCount) + { + Maps = maps; + Plugin = plugin; + Duration = voteDuration; + CanVoteCount = canVoteCount; + } + + private List Maps { get; } + private RockTheVote Plugin { get; } + private int Duration { get; set; } + public int CanVoteCount { get; } + private CounterStrikeSharp.API.Modules.Timers.Timer? Timer { get; set; } + + Dictionary Votes = new(); + int TimeLeft = 0; + + public void MapVoted(CCSPlayerController player, string mapName) + { + Votes[mapName] += 1; + player.PrintToChat($"[RockTheVote] You voted in {mapName}"); + VoteDisplayTick(TimeLeft); + if (Votes.Select(x => x.Value).Sum() >= CanVoteCount) + { + EndVote(); + } + } + + void KillTimer() + { + if(Timer is not null) + { + Timer!.Kill(); + Timer = null; + } + } + + void PrintCenterTextAll(string text) + { + foreach (var player in Utilities.GetPlayers()) + { + if (player.IsValid) + { + player.PrintToCenter(text); + } + } + } + + void VoteDisplayTick(int time) + { + int index = 1; + StringBuilder stringBuilder = new(); + stringBuilder.AppendLine($"Vote for the next map: {time}s"); + foreach (var kv in Votes.OrderByDescending(x => x.Value).Take(2)) { + if(kv.Value > 0) + stringBuilder.AppendLine($"{index++} {kv.Key} ({kv.Value})"); + } + + PrintCenterTextAll(stringBuilder.ToString()); + } + + void EndVote() + { + KillTimer(); + var winner = Votes.OrderByDescending(x => x.Value).First(); + var totalVotes = Votes.Select(x => x.Value).Sum(); + var percent = totalVotes > 0 ? (winner.Value / totalVotes) * 100 : 0; + if(percent > 0) + Server.PrintToChatAll($"[RockTheVote] Vote ended, the next map will be {winner.Key} ({percent}% of {totalVotes} vote(s))"); + else + { + var rnd = new Random(); + winner = Votes.ElementAt(rnd.Next(0, Votes.Count)); + Server.PrintToChatAll($"[RockTheVote] No votes, the next map will be {winner.Key}"); + } + PrintCenterTextAll($"Vote finished, next map: {winner.Key}"); + + Plugin.AddTimer(4.0F, () => + { + Server.ExecuteCommand($"changelevel {winner.Key}"); + }); + } + + public void StartVote() + { + ChatMenu menu = new("Vote for the next map:"); + foreach(var map in Maps.Take(5)) + { + Votes[map] = 0; + menu.AddMenuOption(map, (player, option) => MapVoted(player, map)); + } + + foreach (var player in Utilities.GetPlayers()) + { + if (player.IsValid) + { + ChatMenus.OpenMenu(player, menu); + } + } + TimeLeft = Duration; + Timer = Plugin.AddTimer(1.0F, () => + { + if (TimeLeft <= 0) + { + EndVote(); + + } + else + VoteDisplayTick(TimeLeft--); + }, TimerFlags.REPEAT); + } + } +} diff --git a/maplist.txt b/maplist.txt new file mode 100644 index 0000000..c471487 --- /dev/null +++ b/maplist.txt @@ -0,0 +1,11 @@ +//Put your map list here +de_vertigo +cs_italy +de_inferno +cs_office +de_mirage +de_ancient +de_nuke +de_anubis +de_overpass +de_dust2 \ No newline at end of file