diff --git a/lang/en.json b/lang/en.json index 67264e9..8749a37 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,10 +1,33 @@ { - "prefix": "{red}GANGS {darkred}> {grey}", - "command.gang.not_in_gang": "%prefix%You are not in a gang. Type {gold}/gang create [name]{grey} to create one.", - "generic.player.not_found": "%prefix%Could not find a player using {darkred}{0}{grey}.", + "color.default": "{grey}", + "color.emph": "{white}", + "color.number": "{yellow}", + "color.special": "{lightblue}", + "color.command": "{blue}", + "color.currency": "{gold}", + "color.target": "{green}", + "prefix": "{red}GANGS {darkred}>%color.default% ", + "command.gang.not_in_gang": "%prefix%You are not in a gang. Type %color.blue%/gang create [name]%color.default% to create one.", + "command.balance": "%prefix%You have %color.currency%{0} %currency.player%{grey}.", + "command.balance.plural": "%prefix%You have %color.currency%{0} %currency.player.plural%{grey}.", + "command.balance.none": "%prefix%You have no %currency.player.plural%.", + "command.balance.other": "%prefix%%color.target%{0}%color.default% has %color.currency%{1} %currency.player%%color.default%.", + "command.balance.other.plural": "%prefix%%color.target%{0}%color.default% has %color.currency%{1} %currency.player.plural%%color.default%.", + "command.balance.other.none": "%prefix%%color.target%{0}%color.default% has no %currency.player.plural%.", + "command.balance.set": "%prefix%Set %color.target%{0}%color.default%'s %color.currency%%currency.player.plural% to %color.currency%{1} %color.default%.", + "command.usage": "%prefix%Usage: %color.command%{0}", + "command.invalid_parameter": "%prefix%Invalid parameter %color.emph%\"{0}\"%color.default%, expected %color.special%{1}%color.default%.", + "generic.player.not_found": "%prefix%Could not find a player using {darkred}\"{0}\"{grey}.", + "generic.player.found_multiple": "%prefix%Found multiple players using {darkred}\"{0}\"{grey}.", "generic.soontm": "%prefix%{grey}SoonTM!", "generic.player.only": "%prefix%{red}Only players can use this.", "generic.no_permission": "%prefix%{red}You do not have permission to use this command.", "generic.no_permission.node": "%prefix%{red}You are missing the {darkred}{0}{red} permission.", - "generic.no_permission.rank": "%prefix%{red}You must be {darkred}{0}{red} to use this command." + "generic.no_permission.rank": "%prefix%{red}You must be {darkred}{0}{red} to use this command.", + "generic.error": "%prefix%%color.error%An unknown error occured.", + "generic.error.info": "%prefix%%color.error%An error occured: {0}.", + "currency.player": "credit", + "currency.gang": "%currency.player%", + "currency.player.plural": "credits", + "currency.gang.plural": "%currency.player.plural%" } \ No newline at end of file diff --git a/src/CS2/Commands/BalanceCommand.cs b/src/CS2/Commands/BalanceCommand.cs new file mode 100644 index 0000000..d9c8cd3 --- /dev/null +++ b/src/CS2/Commands/BalanceCommand.cs @@ -0,0 +1,96 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Commands.Targeting; +using CounterStrikeSharp.API.Modules.Utils; +using GangsAPI; +using GangsAPI.Data; +using GangsAPI.Data.Command; +using GangsAPI.Services.Commands; +using GangsAPI.Services.Player; +using Microsoft.Extensions.Localization; +using Stats; + +namespace Commands; + +public class BalanceCommand(IPlayerStatManager playerMgr, + IStringLocalizer testLocalizer) : ICommand { + public string Name => "css_balance"; + + public string[] Usage => ["", "", " "]; + public string[] Aliases => ["css_balance", "css_credit", "css_credits"]; + + private string id = new BalanceStat().StatId; + private IStringLocalizer localizer = testLocalizer; + + public void Start(BasePlugin? plugin, bool hotReload) { + if (plugin != null) localizer = plugin.Localizer; + } + + public async Task Execute(PlayerWrapper? executor, + CommandInfoWrapper info) { + if (executor == null) return CommandResult.PLAYER_ONLY; + + if (info.ArgCount == 1 || !executor.HasFlags("@css/ban")) { + var (success, balance) = + await playerMgr.GetForPlayer(executor.Steam, id); + + if (!success) { + info.ReplySync(localizer.Get(MSG.COMMAND_BALANCE_NONE)); + return CommandResult.SUCCESS; + } + + info.ReplySync(localizer.Get( + balance == 1 ? MSG.COMMAND_BALANCE : MSG.COMMAND_BALANCE_PLURAL, + balance)); + return CommandResult.SUCCESS; + } + + // TODO: Add Unit Test Support + // Would require a mock of some type of Server state + // for Utilities to wrap around. + var target = new Target(info[1]); + var result = target.GetTarget(null).Players; + if (result.Count != 1) { + info.ReplySync(localizer.Get( + result.Count > 1 ? + MSG.GENERIC_PLAYER_FOUND_MULTIPLE : + MSG.GENERIC_PLAYER_NOT_FOUND, info[1])); + return CommandResult.INVALID_ARGS; + } + + var subject = result[0]; + + if (info.ArgCount == 2 || !executor.HasFlags("@css/root")) { + var (success, balance) = + await playerMgr.GetForPlayer(subject.SteamID, id); + + if (!success) { + info.ReplySync(localizer.Get(MSG.COMMAND_BALANCE_NONE)); + return CommandResult.SUCCESS; + } + + info.ReplySync(localizer.Get( + balance == 1 ? + MSG.COMMAND_BALANCE_OTHER : + MSG.COMMAND_BALANCE_OTHER_PLURAL, balance)); + } + + + if (info.ArgCount != 3) return CommandResult.PRINT_USAGE; + + if (!int.TryParse(info[2], out var amount)) { + info.ReplySync(localizer.Get(MSG.COMMAND_INVALID_PARAM, info[2], + "an integer")); + return CommandResult.INVALID_ARGS; + } + + var pass = await playerMgr.SetForPlayer(subject.SteamID, id, amount); + if (!pass) { + info.ReplySync(localizer.Get(MSG.GENERIC_ERROR)); + return CommandResult.ERROR; + } + + info.ReplySync(localizer.Get(MSG.COMMAND_BALANCE_SET, subject.PlayerName, + amount)); + return CommandResult.SUCCESS; + } +} \ No newline at end of file diff --git a/src/CS2/Commands/CommandManager.cs b/src/CS2/Commands/CommandManager.cs index be516b2..bc12148 100644 --- a/src/CS2/Commands/CommandManager.cs +++ b/src/CS2/Commands/CommandManager.cs @@ -1,4 +1,5 @@ -using CounterStrikeSharp.API; +using Commands.Gang; +using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Commands; using GangsAPI; @@ -6,12 +7,14 @@ using GangsAPI.Data.Command; using GangsAPI.Services.Commands; using GangsAPI.Services.Gang; +using GangsAPI.Services.Player; using Microsoft.Extensions.Localization; using Mock; namespace Commands; -public class CommandManager(IGangManager gangMgr, IStringLocalizer locale) +public class CommandManager(IGangManager gangMgr, + IPlayerStatManager playerStatMgr, IStringLocalizer locale) : MockCommandManager(locale), IPluginBehavior { private BasePlugin? plugin; private bool hotReload; @@ -21,6 +24,7 @@ public void Start(BasePlugin? basePlugin, bool hotReload) { this.hotReload = hotReload; RegisterCommand(new GangCommand(gangMgr, Locale)); + RegisterCommand(new BalanceCommand(playerStatMgr, Locale)); } public override bool RegisterCommand(ICommand command) { diff --git a/src/CS2/Commands/Commands.csproj b/src/CS2/Commands/Commands.csproj index 980c1bd..341cc8a 100644 --- a/src/CS2/Commands/Commands.csproj +++ b/src/CS2/Commands/Commands.csproj @@ -13,6 +13,7 @@ + diff --git a/src/CS2/Commands/GangCommand.cs b/src/CS2/Commands/GangCommand.cs index f33e395..d7e0268 100644 --- a/src/CS2/Commands/GangCommand.cs +++ b/src/CS2/Commands/GangCommand.cs @@ -54,7 +54,7 @@ public async Task Execute(PlayerWrapper? executor, var gang = await gangMgr.GetGang(executor.Steam); if (gang == null) { - info.ReplySync(myLocale[NOT_IN_GANG.Key()]); + info.ReplySync(myLocale[COMMAND_GANG_NOTINGANG.Key()]); return CommandResult.SUCCESS; } diff --git a/src/GangsAPI/Data/Command/CommandResult.cs b/src/GangsAPI/Data/Command/CommandResult.cs index 376ac93..3e0acbe 100644 --- a/src/GangsAPI/Data/Command/CommandResult.cs +++ b/src/GangsAPI/Data/Command/CommandResult.cs @@ -22,6 +22,7 @@ public enum CommandResult { /// no sufficient arguments /// INVALID_ARGS, + PRINT_USAGE, /// /// The executor of the command did not have diff --git a/src/GangsAPI/MSG.cs b/src/GangsAPI/MSG.cs index 70e16f7..145b307 100644 --- a/src/GangsAPI/MSG.cs +++ b/src/GangsAPI/MSG.cs @@ -3,33 +3,85 @@ namespace GangsAPI; public enum MSG { - NOT_IN_GANG, + COMMAND_GANG_NOTINGANG, + COMMAND_BALANCE_NONE, + COMMAND_BALANCE, + COMMAND_BALANCE_PLURAL, + COMMAND_BALANCE_OTHER_NONE, + COMMAND_BALANCE_OTHER, + COMMAND_BALANCE_OTHER_PLURAL, + COMMAND_BALANCE_SET, + COMMAND_USAGE, + COMMAND_INVALID_PARAM, PREFIX, GENERIC_PLAYER_NOT_FOUND, + GENERIC_PLAYER_FOUND_MULTIPLE, SOONTM, GENERIC_PLAYER_ONLY, GENERIC_NOPERM, GENERIC_NOPERM_NODE, - GENERIC_NOPERM_RANK -} + GENERIC_NOPERM_RANK, + GENERIC_ERROR, + GENERIC_ERROR_INFO, + PLAYER_CURRENCY, + PLAYER_CURRENCY_PLURAL, + GANG_CURRENCY, + GANG_CURRENCY_PLURAL, + COLOR_DEFAULT, + COLOR_EMPHASIS, + COLOR_NUMBERL, + COLOR_SPECIAL, + COLOR_COMMAND, + COLOR_CURRENCY, + COLOR_TARGET, } public static class LocaleExtensions { public static string Key(this MSG msg) { return msg switch { - MSG.NOT_IN_GANG => "command.gang.not_in_gang", - MSG.PREFIX => "prefix", - MSG.GENERIC_PLAYER_NOT_FOUND => "generic.player.not_found", - MSG.SOONTM => "generic.soontm", - MSG.GENERIC_PLAYER_ONLY => "generic.player.only", - MSG.GENERIC_NOPERM => "generic.no_permission", - MSG.GENERIC_NOPERM_NODE => "generic.no_permission.node", - MSG.GENERIC_NOPERM_RANK => "generic.no_permission.rank", + MSG.COMMAND_GANG_NOTINGANG => "command.gang.not_in_gang", + MSG.COMMAND_BALANCE_NONE => "command.balance.none", + MSG.COMMAND_BALANCE => "command.balance", + MSG.COMMAND_BALANCE_OTHER => "command.balance.other", + MSG.COMMAND_BALANCE_OTHER_PLURAL => "command.balance.other.plural", + MSG.COMMAND_BALANCE_OTHER_NONE => "command.balance.other.none", + MSG.COMMAND_BALANCE_PLURAL => "command.balance.plural", + MSG.COMMAND_BALANCE_SET => "command.balance.set", + MSG.COMMAND_USAGE => "command.usage", + MSG.COMMAND_INVALID_PARAM => "command.invalid_parameter", + MSG.PREFIX => "prefix", + MSG.GENERIC_PLAYER_NOT_FOUND => "generic.player.not_found", + MSG.SOONTM => "generic.soontm", + MSG.GENERIC_PLAYER_ONLY => "generic.player.only", + MSG.GENERIC_NOPERM => "generic.no_permission", + MSG.GENERIC_NOPERM_NODE => "generic.no_permission.node", + MSG.GENERIC_NOPERM_RANK => "generic.no_permission.rank", + MSG.GENERIC_PLAYER_FOUND_MULTIPLE => "generic.player.found_multiple", + MSG.GENERIC_ERROR => "generic.error", + MSG.GENERIC_ERROR_INFO => "generic.error.info", + MSG.PLAYER_CURRENCY => "currency.player", + MSG.PLAYER_CURRENCY_PLURAL => "currency.player.plural", + MSG.GANG_CURRENCY => "currency.gang", + MSG.GANG_CURRENCY_PLURAL => "currency.gang.plural", + MSG.COLOR_DEFAULT => "color.default", + MSG.COLOR_EMPHASIS => "color.emph", + MSG.COLOR_NUMBERL => "color.number", + MSG.COLOR_SPECIAL => "color.special", + MSG.COLOR_COMMAND => "color.command", + MSG.COLOR_CURRENCY => "color.currency", + MSG.COLOR_TARGET => "color.target", + + _ => throw new ArgumentOutOfRangeException(nameof(msg), msg, null) }; } public static string Get(this IStringLocalizer localizer, MSG msg, params object[] args) { - return localizer[msg.Key(), args].Value; + try { return localizer[msg.Key(), args].Value; } catch (FormatException e) { + throw new FormatException( + $"There was an error formatting {msg.Key()} ({localizer[msg.Key()]})", + e); + return msg.Key(); + } } } \ No newline at end of file diff --git a/src/GangsAPI/Services/Commands/ICommand.cs b/src/GangsAPI/Services/Commands/ICommand.cs index 82f4936..3115350 100644 --- a/src/GangsAPI/Services/Commands/ICommand.cs +++ b/src/GangsAPI/Services/Commands/ICommand.cs @@ -1,5 +1,6 @@ using GangsAPI.Data; using GangsAPI.Data.Command; +using Microsoft.Extensions.Localization; namespace GangsAPI.Services.Commands; @@ -7,7 +8,7 @@ public interface ICommand : IPluginBehavior { string Name { get; } string? Description => null; - string Usage => ""; + string[] Usage => []; string[] RequiredFlags => []; string[] RequiredGroups => []; string[] Aliases => [Name]; @@ -21,4 +22,9 @@ bool CanExecute(PlayerWrapper? executor) { } Task Execute(PlayerWrapper? executor, CommandInfoWrapper info); + + // void PrintUsage(IStringLocalizer localizer, PlayerWrapper? executor) { + // foreach (var use in Usage) + // localizer.Get(MSG.COMMAND_USAGE, $"{Name} {use}"); + // } } \ No newline at end of file diff --git a/src/GangsImpl/Mock/MockCommandManager.cs b/src/GangsImpl/Mock/MockCommandManager.cs index a46c112..67dadce 100644 --- a/src/GangsImpl/Mock/MockCommandManager.cs +++ b/src/GangsImpl/Mock/MockCommandManager.cs @@ -48,8 +48,17 @@ await Task.Run(async () => { result = await command.Execute(executor, sourceInfo); }); - if (result == CommandResult.PLAYER_ONLY) - sourceInfo.ReplySync(Locale.Get(MSG.GENERIC_PLAYER_ONLY)); + switch (result) { + case CommandResult.PLAYER_ONLY: + sourceInfo.ReplySync(Locale.Get(MSG.GENERIC_PLAYER_ONLY)); + break; + case CommandResult.PRINT_USAGE: { + foreach (var use in command.Usage) + sourceInfo.ReplySync(Locale.Get(MSG.COMMAND_USAGE, + $"{command.Name} {use}")); + break; + } + } return result; } diff --git a/src/GangsTest/API/Services/Commands/Command/Concrete/BalanceTests.cs b/src/GangsTest/API/Services/Commands/Command/Concrete/BalanceTests.cs new file mode 100644 index 0000000..b8d5292 --- /dev/null +++ b/src/GangsTest/API/Services/Commands/Command/Concrete/BalanceTests.cs @@ -0,0 +1,48 @@ +using Commands; +using GangsAPI; +using GangsAPI.Data.Command; +using GangsAPI.Services.Commands; +using GangsAPI.Services.Player; +using GangsTest.TestLocale; +using Microsoft.Extensions.Localization; +using Stats; + +namespace GangsTest.API.Services.Commands.Command.Concrete; + +public class BalanceTests(ICommandManager commands, IPlayerStatManager statMgr, + IStringLocalizer locale) : TestParent(commands, + new BalanceCommand(statMgr, StringLocalizer.Instance)) { + private static readonly string STAT_ID = new BalanceStat().StatId; + + [Fact] + public async Task None() { + Assert.Equal("css_balance", Command.Name); + Assert.Equal(CommandResult.SUCCESS, + await Commands.ProcessCommand(TestPlayer, Command.Name)); + Assert.Contains(locale.Get(MSG.COMMAND_BALANCE_NONE), + TestPlayer.ConsoleOutput); + } + + [Fact] + public async Task One() { + await statMgr.SetForPlayer(TestPlayer.Steam, STAT_ID, 1); + Assert.Equal(CommandResult.SUCCESS, + await Commands.ProcessCommand(TestPlayer, Command.Name)); + Assert.Contains(locale.Get(MSG.COMMAND_BALANCE, 1), + TestPlayer.ConsoleOutput); + } + + [Theory] + [InlineData(2)] + [InlineData(5)] + [InlineData(10000)] + [InlineData(-1)] + [InlineData(-1000)] + public async Task Multiple(int bal) { + await statMgr.SetForPlayer(TestPlayer.Steam, STAT_ID, bal); + Assert.Equal(CommandResult.SUCCESS, + await Commands.ProcessCommand(TestPlayer, Command.Name)); + Assert.Contains(locale.Get(MSG.COMMAND_BALANCE_PLURAL, bal), + TestPlayer.ConsoleOutput); + } +} \ No newline at end of file diff --git a/src/GangsTest/API/Services/Commands/Command/FieldTests.cs b/src/GangsTest/API/Services/Commands/Command/FieldTests.cs index 37353fb..d64b94a 100644 --- a/src/GangsTest/API/Services/Commands/Command/FieldTests.cs +++ b/src/GangsTest/API/Services/Commands/Command/FieldTests.cs @@ -7,8 +7,9 @@ public class FieldTests { [ClassData(typeof(TestData))] public void Fields_Name(ICommand cmd) { Assert.NotEmpty(cmd.Name); - Assert.False(cmd.Usage.StartsWith(cmd.Name), - "Command usage should not start with the command name"); + // Assert.False(cmd.Usage.StartsWith(cmd.Name), + // "Command usage should not start with the command name"); + Assert.DoesNotContain(cmd.Usage, usage => usage.StartsWith(cmd.Name)); } [Theory] diff --git a/src/GangsTest/API/Services/Commands/Command/TestData.cs b/src/GangsTest/API/Services/Commands/Command/TestData.cs index e888b9d..2832a22 100644 --- a/src/GangsTest/API/Services/Commands/Command/TestData.cs +++ b/src/GangsTest/API/Services/Commands/Command/TestData.cs @@ -13,9 +13,13 @@ public class TestData : IEnumerable { private static readonly IPlayerManager playerMgr = new MockPlayerManager(); private static readonly IGangManager manager = new MockGangManager(playerMgr); + private static readonly IPlayerStatManager statMgr = + new MockInstanceStatManager(); + private readonly IBehavior[] behaviors = [ new CreateCommand(manager), new HelpCommand(), - new GangCommand(manager, StringLocalizer.Instance) + new GangCommand(manager, StringLocalizer.Instance), + new BalanceCommand(statMgr, StringLocalizer.Instance) ]; public TestData() { diff --git a/src/GangsTest/API/Services/Commands/CommandManager/TestData.cs b/src/GangsTest/API/Services/Commands/CommandManager/TestData.cs index 0dfb7ab..23cb675 100644 --- a/src/GangsTest/API/Services/Commands/CommandManager/TestData.cs +++ b/src/GangsTest/API/Services/Commands/CommandManager/TestData.cs @@ -9,10 +9,13 @@ namespace GangsTest.API.Services.Commands.CommandManager; public class TestData : IEnumerable { private static readonly IPlayerManager playerMgr = new MockPlayerManager(); + private static readonly IPlayerStatManager playerStatMgr = + new MockInstanceStatManager(); + private readonly IBehavior[] behaviors = [ new MockCommandManager(StringLocalizer.Instance), new global::Commands.CommandManager(new MockGangManager(playerMgr), - StringLocalizer.Instance) + playerStatMgr, StringLocalizer.Instance) ]; public TestData() { diff --git a/src/GangsTest/TestLocale/FormatTests.cs b/src/GangsTest/TestLocale/FormatTests.cs index 124fce6..37103aa 100644 --- a/src/GangsTest/TestLocale/FormatTests.cs +++ b/src/GangsTest/TestLocale/FormatTests.cs @@ -22,7 +22,7 @@ public void Brackets_Contain_DigitsOnly(string _, string val) { [Theory] [ClassData(typeof(LocaleFileKVData))] - public void Brackets_Open_And_Close(string _, string val) { + public void Parity_Brackets(string _, string val) { // For each opening bracket, make sure there is a closing bracket var brackets = 0; foreach (var c in val) { @@ -33,6 +33,14 @@ public void Brackets_Open_And_Close(string _, string val) { Assert.Equal(0, brackets); } + [Theory] + [ClassData(typeof(LocaleFileKVData))] + public void Parity_Percents(string _, string val) { + // For each opening bracket, make sure there is a closing bracket + var percs = val.Count(c => c == '%'); + Assert.Equal(0, percs % 2); + } + [Theory] [ClassData(typeof(LocaleFileKVData))] public void No_Empty_Values(string _, string val) { @@ -48,9 +56,8 @@ public void Keys_Use_Subchars(string key, string _) { [Theory] [ClassData(typeof(LocaleFileKVData))] public void Ends_In_Punctuation(string key, string val) { - if (key == "prefix") - return; // TODO: Once xUnit 3.0 is released, use Assert.Skip - Assert.Matches(@"[.,!?]$", val); + if (!key.Contains(' ')) return; + Assert.Matches(@"[.,!? ]$", val); } [GeneratedRegex("{(.*?)}")] diff --git a/src/GangsTest/TestLocale/StringLocalizer.cs b/src/GangsTest/TestLocale/StringLocalizer.cs index 8fe77be..5d7702c 100644 --- a/src/GangsTest/TestLocale/StringLocalizer.cs +++ b/src/GangsTest/TestLocale/StringLocalizer.cs @@ -38,7 +38,7 @@ private LocalizedString GetString(string name) { // Check if the key exists try { var key = match.Groups[0].Value; - value = value.Replace(key, localizer[key[1..^1]].Value); + value = value.Replace(key, this[key[1..^1]].Value); } catch (NullReferenceException) { } return new LocalizedString(name, value);