diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a89a6a3..bff4eec 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - dotnet-version: [ 6.0.x, 7.0.x, 8.0.x, 9.0.x ] # Test on multiple .NET versions + dotnet-version: [ 8.0.x, 9.0.x ] steps: - uses: actions/checkout@v4 - name: Setup .NET diff --git a/Gangs.sln b/Gangs.sln index e3fb607..a6d0f84 100644 --- a/Gangs.sln +++ b/Gangs.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatsTracker", "src\StatsTr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EcoRewards", "src\EcoRewards\EcoRewards.csproj", "{253C7948-3411-4860-BDDE-B1CA23FCE4DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Raffle", "Raffle\Raffle.csproj", "{05B36B8C-F430-411B-9B8D-61EB14D5E5FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -78,6 +80,10 @@ Global {253C7948-3411-4860-BDDE-B1CA23FCE4DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {253C7948-3411-4860-BDDE-B1CA23FCE4DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {253C7948-3411-4860-BDDE-B1CA23FCE4DC}.Release|Any CPU.Build.0 = Release|Any CPU + {05B36B8C-F430-411B-9B8D-61EB14D5E5FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05B36B8C-F430-411B-9B8D-61EB14D5E5FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05B36B8C-F430-411B-9B8D-61EB14D5E5FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05B36B8C-F430-411B-9B8D-61EB14D5E5FC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {74B15261-4B12-4EF6-859A-E46B315E7DD3} = {3AB7703F-880F-4A41-96EE-B891FA888C65} @@ -90,5 +96,6 @@ Global {1899055E-62B8-4907-85A2-DFE22531729E} = {32D392D5-C809-45A1-A5FB-58C941D86057} {B850CFA3-AFE8-4012-8BC2-9A4BC12B9748} = {AC07CD29-5C9D-4AD1-99C7-01DABAB8D0EC} {253C7948-3411-4860-BDDE-B1CA23FCE4DC} = {AC07CD29-5C9D-4AD1-99C7-01DABAB8D0EC} + {05B36B8C-F430-411B-9B8D-61EB14D5E5FC} = {AC07CD29-5C9D-4AD1-99C7-01DABAB8D0EC} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 14fac54..958502c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,6 @@ Some example perks are: There is currently no configuration support, and only a MySQL database format has been tested (though, in theory, an SQLite database could work). -![time spent](https://waka.msws.xyz/api/badge/msws/interval:all/project:Gangs?label=Dev%20Time) +![time spent](https://waka.msws.xyz/api/badge/msws/interval:any/project:Gangs?label=Dev%20Time) ![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/MSWS/72f982ea80cb7dabb6e91f21d6594ba8/raw/code-coverage.json) -[![CodeQL](https://github.com/edgegamers/Gangs/actions/workflows/codeql.yml/badge.svg)](https://github.com/edgegamers/Gangs/actions/workflows/codeql.yml) \ No newline at end of file +[![CodeQL](https://github.com/edgegamers/Gangs/actions/workflows/codeql.yml/badge.svg)](https://github.com/edgegamers/Gangs/actions/workflows/codeql.yml) diff --git a/Raffle/IRaffleManager.cs b/Raffle/IRaffleManager.cs new file mode 100644 index 0000000..18cb68f --- /dev/null +++ b/Raffle/IRaffleManager.cs @@ -0,0 +1,10 @@ +namespace Raffle; + +public interface IRaffleManager { + Raffle? Raffle { get; } + + bool StartRaffle(int buyIn); + bool AreEntriesOpen(); + void SetEntriesOpen(float seconds); + void DrawWinner(); +} \ No newline at end of file diff --git a/Raffle/Raffle.cs b/Raffle/Raffle.cs new file mode 100644 index 0000000..41d8dff --- /dev/null +++ b/Raffle/Raffle.cs @@ -0,0 +1,27 @@ +namespace Raffle; + +public class Raffle(int buyIn) { + private readonly HashSet players = []; + + public int BuyIn => buyIn; + + public int Value => players.Count * buyIn; + + public int TotalPlayers => players.Count; + + public void AddPlayer(ulong player) { players.Add(player); } + + /// + /// Get the winner of the raffle + /// + /// The winner, or null if there are no players + public ulong? GetWinner() { + if (players.Count == 0) return null; + var random = new Random(); + var winnerIndex = random.Next(players.Count); + // remove the winner from the list of players + var winner = players.ElementAt(winnerIndex); + players.Remove(winner); + return winner; + } +} \ No newline at end of file diff --git a/Raffle/Raffle.csproj b/Raffle/Raffle.csproj new file mode 100644 index 0000000..2271541 --- /dev/null +++ b/Raffle/Raffle.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/Raffle/RaffleCollection.cs b/Raffle/RaffleCollection.cs new file mode 100644 index 0000000..bb48363 --- /dev/null +++ b/Raffle/RaffleCollection.cs @@ -0,0 +1,12 @@ +using GangsAPI.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace Raffle; + +public static class RaffleCollection { + public static void RegisterRaffle(this IServiceCollection provider) { + provider.AddPluginBehavior(); + provider.AddPluginBehavior(); + provider.AddPluginBehavior(); + } +} \ No newline at end of file diff --git a/Raffle/RaffleCommand.cs b/Raffle/RaffleCommand.cs new file mode 100644 index 0000000..83b985a --- /dev/null +++ b/Raffle/RaffleCommand.cs @@ -0,0 +1,37 @@ +using GangsAPI.Data; +using GangsAPI.Data.Command; +using GangsAPI.Services; +using GangsAPI.Services.Commands; +using Microsoft.Extensions.DependencyInjection; + +namespace Raffle; + +public class RaffleCommand(IServiceProvider provider) : ICommand { + private readonly IEcoManager eco = provider.GetRequiredService(); + + private readonly IRaffleManager raffle = + provider.GetRequiredService(); + + public string Name => "css_raffle"; + + public async Task Execute(PlayerWrapper? executor, + CommandInfoWrapper info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + if (info.ArgCount != 1) return CommandResult.PRINT_USAGE; + + if (raffle.Raffle == null) + // no raffle is currently running + return CommandResult.SUCCESS; + + if (!raffle.AreEntriesOpen()) + // entries are closed + return CommandResult.SUCCESS; + + if (await eco.TryPurchase(executor, raffle.Raffle.BuyIn, true, "Raffle Ticket", + true) < 0) + return CommandResult.SUCCESS; + + raffle.Raffle.AddPlayer(executor.Steam); + return CommandResult.SUCCESS; + } +} \ No newline at end of file diff --git a/Raffle/RaffleManager.cs b/Raffle/RaffleManager.cs new file mode 100644 index 0000000..33fc491 --- /dev/null +++ b/Raffle/RaffleManager.cs @@ -0,0 +1,105 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Cvars; +using GangsAPI; +using Microsoft.Extensions.Localization; +using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; + +namespace Raffle; + +public class RaffleManager(IStringLocalizer locale) + : IPluginBehavior, IRaffleManager { + public static FakeConVar CV_RAFFLE_CHANCE = + new("cs2_gangs_raffle_chance", "The chance of a raffle starting per round", + 0.1f); + + public static FakeConVar CV_RAFFLE_COOLDOWN = + new("cs2_gangs_raffle_cooldown", "Minimum number of rounds between raffles", + 2); + + public static FakeConVar CV_RAFFLE_MINIMUM = + new("cs2_gangs_raffle_min", "Minimum amount per player", 2); + + public static FakeConVar CV_RAFFLE_MAXIMUM = + new("cs2_gangs_raffle_max", "Maximum amount per player", 2); + + public static FakeConVar CV_RAFFLE_DURATION = + new("cs2_gangs_raffle_duration", "Time to give playeres to enter raffle", + 20); + + private static readonly Random rng = new(); + private int cooldownRounds; + + private Timer? entryTimer; + private BasePlugin? plugin; + + public void Start(BasePlugin? plugin, bool hotReload) { + if (plugin == null) return; + this.plugin = plugin; + } + + public Raffle? Raffle { get; private set; } + + public bool StartRaffle(int buyIn) { + if (Raffle != null || plugin == null) return false; + Raffle = new Raffle(buyIn); + + SetEntriesOpen(CV_RAFFLE_DURATION.Value); + return true; + } + + public bool AreEntriesOpen() { return entryTimer != null; } + + public void SetEntriesOpen(float seconds) { + entryTimer?.Kill(); + if (plugin == null || Raffle == null) return; + Server.PrintToChatAll(locale.Get(MSG.RAFFLE_BEGIN, Raffle?.BuyIn ?? 0)); + + entryTimer = plugin.AddTimer(seconds, () => { + entryTimer = null; + if (Raffle == null) return; + + Server.PrintToChatAll(locale.Get(MSG.RAFFLE_PRE_ANNOUNCE, Raffle.Value, + Raffle.TotalPlayers)); + + plugin.AddTimer(5, DrawWinner); + }); + } + + public void DrawWinner() { + if (Raffle == null || plugin == null) return; + ulong? winner; + do { winner = Raffle.GetWinner(); } while (winner != null + && Raffle.TotalPlayers > 0); + + if (winner == null) { + Server.PrintToChatAll(locale.Get(MSG.GENERIC_ERROR_INFO, + "Could not find a winner")); + Raffle = null; + return; + } + + var name = Utilities.GetPlayerFromSteamId(winner.Value)?.PlayerName + ?? winner.ToString() ?? ""; + + Server.PrintToChatAll(locale.Get(MSG.RAFFLE_WINNER, name, + (1.0f / (Raffle.TotalPlayers + 1)).ToString("P1"))); + Raffle = null; + } + + [GameEventHandler] + public HookResult OnRoundStart(EventRoundStart ev, GameEventInfo info) { + if (RoundUtil.IsWarmup()) return HookResult.Continue; + if (cooldownRounds > 0) { + cooldownRounds--; + return HookResult.Continue; + } + + if (rng.NextDouble() > CV_RAFFLE_CHANCE.Value) return HookResult.Continue; + var amo = rng.Next(CV_RAFFLE_MINIMUM.Value, CV_RAFFLE_MAXIMUM.Value); + StartRaffle(amo); + cooldownRounds = CV_RAFFLE_COOLDOWN.Value; + return HookResult.Continue; + } +} \ No newline at end of file diff --git a/Raffle/RoundUtil.cs b/Raffle/RoundUtil.cs new file mode 100644 index 0000000..dc808ca --- /dev/null +++ b/Raffle/RoundUtil.cs @@ -0,0 +1,42 @@ +using CounterStrikeSharp.API; + +namespace Raffle; + +public static class RoundUtil { + public static int GetTimeElapsed() { + var gamerules = ServerExtensions.GetGameRules(); + if (gamerules == null) return 0; + var freezeTime = gamerules.FreezeTime; + return (int)(Server.CurrentTime - gamerules.RoundStartTime - freezeTime); + } + + public static int GetTimeRemaining() { + var gamerules = ServerExtensions.GetGameRules(); + if (gamerules == null) return 0; + return gamerules.RoundTime - GetTimeElapsed(); + } + + public static void SetTimeRemaining(int seconds) { + var gamerules = ServerExtensions.GetGameRules(); + if (gamerules == null) return; + gamerules.RoundTime = GetTimeElapsed() + seconds; + var proxy = ServerExtensions.GetGameRulesProxy(); + if (proxy == null) return; + Utilities.SetStateChanged(proxy, "CCSGameRulesProxy", "m_pGameRules"); + } + + public static void AddTimeRemaining(int time) { + var gamerules = ServerExtensions.GetGameRules(); + if (gamerules == null) return; + gamerules.RoundTime += time; + + var proxy = ServerExtensions.GetGameRulesProxy(); + if (proxy == null) return; + Utilities.SetStateChanged(proxy, "CCSGameRulesProxy", "m_pGameRules"); + } + + public static bool IsWarmup() { + var rules = ServerExtensions.GetGameRules(); + return rules == null || rules.WarmupPeriod; + } +} \ No newline at end of file diff --git a/Raffle/ServerExtensions.cs b/Raffle/ServerExtensions.cs new file mode 100644 index 0000000..13c4466 --- /dev/null +++ b/Raffle/ServerExtensions.cs @@ -0,0 +1,25 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; + +namespace Raffle; + +public static class ServerExtensions { + /// + /// Get the current CCSGameRules for the server + /// + /// + public static CCSGameRules? GetGameRules() { + // From killstr3ak + return Utilities + .FindAllEntitiesByDesignerName("cs_gamerules") + .First() + .GameRules; + } + + public static CCSGameRulesProxy? GetGameRulesProxy() { + // From killstr3ak + return Utilities + .FindAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault(); + } +} \ No newline at end of file diff --git a/Raffle/StartRaffleCommand.cs b/Raffle/StartRaffleCommand.cs new file mode 100644 index 0000000..e7dd4c3 --- /dev/null +++ b/Raffle/StartRaffleCommand.cs @@ -0,0 +1,26 @@ +using GangsAPI.Data; +using GangsAPI.Data.Command; +using GangsAPI.Services.Commands; +using Microsoft.Extensions.DependencyInjection; + +namespace Raffle; + +public class StartRaffleCommand(IServiceProvider provider) : ICommand { + private readonly IRaffleManager raffle = + provider.GetRequiredService(); + + public string Name => "css_startraffle"; + public string[] RequiredFlags => ["@css/root"]; + public string[] Usage => ["", ""]; + + public Task Execute(PlayerWrapper? executor, + CommandInfoWrapper info) { + var amo = 100; + if (info.ArgCount == 2) + if (!int.TryParse(info.Args[1], out amo)) + return Task.FromResult(CommandResult.PRINT_USAGE); + + raffle.StartRaffle(amo); + return Task.FromResult(CommandResult.SUCCESS); + } +} \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 5668647..52ab152 100644 --- a/lang/en.json +++ b/lang/en.json @@ -106,5 +106,8 @@ "rank.cannot.owner": "%prefix%You cannot {0} as the owner, use %color.command%/gang transfer %color.default% to transfer ownership.", "command.invite.doorpolicy": "%prefix%Your door policy must be set to invite-only to send invites.", "menu.format.invitation": "%color.special%{0}%color.default% invited %color.target%{1}%color.default% on %color.emph%{2}%color.default%.", - "menu.format.request": "%color.target%{0}%color.default% requested to join on %color.emph%{1}%color.default%." + "menu.format.request": "%color.target%{0}%color.default% requested to join on %color.emph%{1}%color.default%.", + "raffle.begin": "%prefix%A raffle for %color.currency%{0} %currency%%s%%color.default% has begun. Type %color.command%/raffle %color.default% to enter.", + "raffle.preannounce": "%prefix%Raffle closed! Collected %color.currency%{0} %currency%%s% across %color.number%{1}%color.default% entrant%s%.", + "raffle.winner": "%prefix%%color.target%{0}%color.default% won the raffle with a %color.number%{1}%color.default% chance!" } diff --git a/src/CS2/Gangs/GangServiceCollection.cs b/src/CS2/Gangs/GangServiceCollection.cs index 09073c8..ef097e4 100644 --- a/src/CS2/Gangs/GangServiceCollection.cs +++ b/src/CS2/Gangs/GangServiceCollection.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Localization; using Mock; +using Raffle; using SQLImpl; using Stats.Perk; using StatsTracker; @@ -41,6 +42,7 @@ public void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.RegisterStatsTracker(); serviceCollection.RegisterPerks(); serviceCollection.RegisterRewards(); + serviceCollection.RegisterRaffle(); serviceCollection.AddPluginBehavior(); serviceCollection diff --git a/src/CS2/Gangs/Gangs.csproj b/src/CS2/Gangs/Gangs.csproj index d3852d8..001337a 100644 --- a/src/CS2/Gangs/Gangs.csproj +++ b/src/CS2/Gangs/Gangs.csproj @@ -8,6 +8,7 @@ + @@ -16,7 +17,7 @@ - + diff --git a/src/GangsAPI/MSG.cs b/src/GangsAPI/MSG.cs index 681d634..6ab35a0 100644 --- a/src/GangsAPI/MSG.cs +++ b/src/GangsAPI/MSG.cs @@ -110,7 +110,11 @@ public enum MSG { COMMAND_GANG_RESTRICTED, COMMAND_INVITE_DOORPOLICY, MENU_FORMAT_INVITATION, - MENU_FORMAT_REQUEST, } + MENU_FORMAT_REQUEST, + RAFFLE_BEGIN, + RAFFLE_PRE_ANNOUNCE, + RAFFLE_WINNER +} public static class LocaleExtensions { public static string Key(this MSG msg) { @@ -230,6 +234,9 @@ public static string Key(this MSG msg) { MSG.COMMAND_INVITE_DOORPOLICY => "command.invite.doorpolicy", MSG.MENU_FORMAT_INVITATION => "menu.format.invitation", MSG.MENU_FORMAT_REQUEST => "menu.format.request", + MSG.RAFFLE_BEGIN => "raffle.begin", + MSG.RAFFLE_PRE_ANNOUNCE => "raffle.preannounce", + MSG.RAFFLE_WINNER => "raffle.winner", _ => throw new ArgumentOutOfRangeException(nameof(msg), msg, null) }; } diff --git a/src/GangsImpl/SQLite/SQLite.csproj b/src/GangsImpl/SQLite/SQLite.csproj index f541f0b..f90e045 100644 --- a/src/GangsImpl/SQLite/SQLite.csproj +++ b/src/GangsImpl/SQLite/SQLite.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/GangsTest/GangsTest.csproj b/src/GangsTest/GangsTest.csproj index 2c25425..285e72a 100644 --- a/src/GangsTest/GangsTest.csproj +++ b/src/GangsTest/GangsTest.csproj @@ -14,7 +14,7 @@ - +