From 93ed7c0d02de2bd9bb5da2c6b874fbdabd370aea Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 1 May 2025 23:57:54 -0400 Subject: [PATCH 1/8] .help by default lists all the plugins with commands you can use Added a second level of filtering to .help so you can filter commands with a plugin or second filter for the command search .help now has commands and plugins sorted Adds .help-all which functions similar to previous .help except its ordered Added new unit tests --- VCF.Core/Basics/HelpCommand.cs | 72 +++++++++++++++++++++-------- VCF.Tests/AssertReplyContext.cs | 5 ++ VCF.Tests/HelpTests.cs | 81 +++++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 28 deletions(-) diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index b4b02f2..ebbfd32 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -19,7 +19,7 @@ internal static class HelpCommands public static void HelpLegacy(ICommandContext ctx, string search = null) => ctx.SysReply($"Attempting compatible .help {search} for non-VCF mods."); [Command("help")] - public static void HelpCommand(ICommandContext ctx, string search = null) + public static void HelpCommand(ICommandContext ctx, string search = null, string filter = null) { // If search is specified first look for matching assembly, then matching command if (!string.IsNullOrEmpty(search)) @@ -28,7 +28,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null) if (foundAssembly.Value != null) { StringBuilder sb = new(); - PrintAssemblyHelp(ctx, foundAssembly, sb); + PrintAssemblyHelp(ctx, foundAssembly, sb, filter); ctx.SysPaginatedReply(sb); } else @@ -41,7 +41,9 @@ public static void HelpCommand(ICommandContext ctx, string search = null) || x.Value.Contains(search, StringComparer.InvariantCultureIgnoreCase) ); - individualResults = individualResults.Where(kvp => CommandRegistry.CanCommandExecute(ctx, kvp.Key)); + individualResults = individualResults.Where(kvp => CommandRegistry.CanCommandExecute(ctx, kvp.Key)) + .Where(kvp => filter == null || + kvp.Key.Attribute.Name.Contains(filter, StringComparison.InvariantCultureIgnoreCase)); if (!individualResults.Any()) { @@ -60,28 +62,18 @@ public static void HelpCommand(ICommandContext ctx, string search = null) else { var sb = new StringBuilder(); - sb.AppendLine($"Listing {B("all")} commands"); - foreach (var assembly in CommandRegistry.AssemblyCommandMap) + sb.AppendLine($"Listing {B("all")} plugins"); + sb.AppendLine($"Use {B(".help ")} for commands in that plugin"); + // List all plugins they have a command they can execute for + foreach (var assemblyName in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c))) + .Select(x => x.Key.GetName().Name) + .OrderBy(x => x)) { - PrintAssemblyHelp(ctx, assembly, sb); + sb.AppendLine($"{assemblyName}"); } ctx.SysPaginatedReply(sb); } - void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb) - { - var name = assembly.Key.GetName().Name; - name = _trailingLongDashRegex.Replace(name, ""); - - sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline()); - var commands = assembly.Value.Keys.Where(c => CommandRegistry.CanCommandExecute(ctx, c)); - - foreach (var command in commands) - { - sb.AppendLine(PrintShortHelp(command)); - } - } - void GenerateFullHelp(CommandMetadata command, List aliases, StringBuilder sb) { sb.AppendLine($"{B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); @@ -108,6 +100,46 @@ void GenerateFullHelp(CommandMetadata command, List aliases, StringBuild } } + [Command("help-all", description: "Returns all plugin commands")] + public static void HelpAllCommand(ICommandContext ctx, string filter = null) + { + var sb = new StringBuilder(); + if (filter == null) + sb.AppendLine($"Listing {B("all")} commands"); + else + sb.AppendLine($"Listing {B("all")} commands matching filter '{filter}'"); + + var foundAnything = false; + foreach (var assembly in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c) && + (filter == null || + PrintShortHelp(c).Contains(filter, StringComparison.InvariantCultureIgnoreCase))))) + { + PrintAssemblyHelp(ctx, assembly, sb, filter); + foundAnything = true; + } + + if (!foundAnything) + throw ctx.Error($"Could not find any commands for \"{filter}\""); + + ctx.SysPaginatedReply(sb); + } + + static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb, string filter = null) + { + var name = assembly.Key.GetName().Name; + name = _trailingLongDashRegex.Replace(name, ""); + + sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline()); + var commands = assembly.Value.Keys.Where(c => CommandRegistry.CanCommandExecute(ctx, c)); + + foreach (var command in commands.OrderBy(c => (c.GroupAttribute != null ? c.GroupAttribute.Name + " " : "") + c.Attribute.Name)) + { + var helpLine = PrintShortHelp(command); + if (filter == null || helpLine.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) + sb.AppendLine(helpLine); + } + } + internal static string PrintShortHelp(CommandMetadata command) { var attr = command.Attribute; diff --git a/VCF.Tests/AssertReplyContext.cs b/VCF.Tests/AssertReplyContext.cs index 72b0e9c..c42f515 100644 --- a/VCF.Tests/AssertReplyContext.cs +++ b/VCF.Tests/AssertReplyContext.cs @@ -32,6 +32,11 @@ public void AssertReplyContains(string expected) var repliedText = RepliedTextLfAndTrimmed(); Assert.That(repliedText.Contains(expected), Is.True, $"Expected {expected} to be contained in replied: {repliedText}"); } + public void AssertReplyDoesntContain(string expected) + { + var repliedText = RepliedTextLfAndTrimmed(); + Assert.That(repliedText.Contains(expected), Is.False, $"Expected {expected} to not be contained in replied: {repliedText}"); + } public void AssertInternalError() { diff --git a/VCF.Tests/HelpTests.cs b/VCF.Tests/HelpTests.cs index dd6d2c6..c0f276d 100644 --- a/VCF.Tests/HelpTests.cs +++ b/VCF.Tests/HelpTests.cs @@ -29,6 +29,13 @@ public void TestHelp(ICommandContext ctx, SomeEnum someEnum, SomeType? someType { } + + + [Command("searchForCommand")] + public void TestSearchForCommand(ICommandContext ctx) + { + + } } [SetUp] @@ -48,14 +55,13 @@ public void HelpCommand_RegisteredByDefault() } [Test] - public void HelpCommand_Help_ListsAll() + public void HelpCommand_Help_ListsAssemblies() { Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" - [vcf] Listing all commands - Commands from VampireCommandFramework: - .help-legacy [search=] - .help [search=] + [vcf] Listing all plugins + Use .help for commands in that plugin + VampireCommandFramework """); } @@ -65,8 +71,9 @@ public void HelpCommand_Help_ListsAssemblyMatch() Assert.That(CommandRegistry.Handle(AnyCtx, ".help VampireCommandFramework"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" [vcf] Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] .help-legacy [search=] - .help [search=] """); } @@ -87,17 +94,61 @@ public void HelpCommand_Help_ListAll_IncludesNewCommands() CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); - Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" [vcf] Listing all commands Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] .help-legacy [search=] - .help [search=] Commands from VCF.Tests: + .searchForCommand .test-help (someEnum) [someType=] """); } + [Test] + public void HelpCommand_Help_ListAll_Filtered() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all help"), Is.EqualTo(CommandResult.Success)); + AnyCtx.AssertReply($""" + [vcf] Listing all commands matching filter 'help' + Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] + .help-legacy [search=] + Commands from VCF.Tests: + .test-help (someEnum) [someType=] + """); + } + + [Test] + public void HelpCommand_Help_ListAll_FilteredNoMatch() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all trying"), Is.EqualTo(CommandResult.CommandError)); + } + + [Test] + public void HelpCommand_Help_ListAssemblies_IncludesNewCommands() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); + AnyCtx.AssertReply($""" + [vcf] Listing all plugins + Use .help for commands in that plugin + VampireCommandFramework + VCF.Tests + """); + } + [Test] public void GenerateHelpText_UsageSpecified() { @@ -164,4 +215,18 @@ public void FullHelp_Usage_Includes_Enum_Values() Assert.That(CommandRegistry.Handle(ctx, ".help test-help"), Is.EqualTo(CommandResult.Success)); ctx.AssertReplyContains("SomeEnum Values: A, B, C"); } + + [Test] + + public void SpecifiedAssemblyAndSearchedForCommand() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + var ctx = new AssertReplyContext(); + Format.Mode = Format.FormatMode.None; + Assert.That(CommandRegistry.Handle(ctx, ".help vcf.tests seaRCH"), Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("searchForCommand"); + ctx.AssertReplyDoesntContain("test-help"); + } } \ No newline at end of file From 1971a5bf6bd38a3c39491460e4a13ab1efc4f00d Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 3 May 2025 22:32:20 -0400 Subject: [PATCH 2/8] Commands can now be overloaded. If there is ambiguity in which command was intended to be called then you will be presented with the options and asked to pick one with .# Can specify a command for a specific plugin by using the name of it first before the command --- VCF.Core/Basics/HelpCommand.cs | 8 +- VCF.Core/Framework/Color.cs | 1 + VCF.Core/Registry/CacheResult.cs | 27 +- VCF.Core/Registry/CommandCache.cs | 132 ++++++-- VCF.Core/Registry/CommandMetadata.cs | 2 +- VCF.Core/Registry/CommandRegistry.cs | 371 ++++++++++++++++----- VCF.Tests/AssemblyCommandTests.cs | 243 ++++++++++++++ VCF.Tests/CommandAdminAndSelectionTests.cs | 245 ++++++++++++++ VCF.Tests/CommandOverloadingTests.cs | 319 ++++++++++++++++++ VCF.Tests/HelpTests.cs | 12 +- 10 files changed, 1239 insertions(+), 121 deletions(-) create mode 100644 VCF.Tests/AssemblyCommandTests.cs create mode 100644 VCF.Tests/CommandAdminAndSelectionTests.cs create mode 100644 VCF.Tests/CommandOverloadingTests.cs diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index ebbfd32..6225209 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -77,7 +77,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string void GenerateFullHelp(CommandMetadata command, List aliases, StringBuilder sb) { sb.AppendLine($"{B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); - sb.AppendLine(PrintShortHelp(command)); + sb.AppendLine(GetShortHelp(command)); sb.AppendLine($"{B("Aliases").Underline()}: {string.Join(", ", aliases).Italic()}"); // Automatically Display Enum types @@ -112,7 +112,7 @@ public static void HelpAllCommand(ICommandContext ctx, string filter = null) var foundAnything = false; foreach (var assembly in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c) && (filter == null || - PrintShortHelp(c).Contains(filter, StringComparison.InvariantCultureIgnoreCase))))) + GetShortHelp(c).Contains(filter, StringComparison.InvariantCultureIgnoreCase))))) { PrintAssemblyHelp(ctx, assembly, sb, filter); foundAnything = true; @@ -134,13 +134,13 @@ static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair (c.GroupAttribute != null ? c.GroupAttribute.Name + " " : "") + c.Attribute.Name)) { - var helpLine = PrintShortHelp(command); + var helpLine = GetShortHelp(command); if (filter == null || helpLine.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) sb.AppendLine(helpLine); } } - internal static string PrintShortHelp(CommandMetadata command) + internal static string GetShortHelp(CommandMetadata command) { var attr = command.Attribute; var groupPrefix = string.IsNullOrEmpty(command.GroupAttribute?.Name) ? string.Empty : $"{command.GroupAttribute.Name} "; diff --git a/VCF.Core/Framework/Color.cs b/VCF.Core/Framework/Color.cs index 6d54d25..65c1bb3 100644 --- a/VCF.Core/Framework/Color.cs +++ b/VCF.Core/Framework/Color.cs @@ -11,4 +11,5 @@ public static class Color public static string LightGrey = "#ccc"; public static string Yellow = "#dd0"; public static string DarkGreen = "#0c0"; + public static string Command = "#40E0D0"; } diff --git a/VCF.Core/Registry/CacheResult.cs b/VCF.Core/Registry/CacheResult.cs index 3586a96..4dd17a1 100644 --- a/VCF.Core/Registry/CacheResult.cs +++ b/VCF.Core/Registry/CacheResult.cs @@ -1,10 +1,31 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; namespace VampireCommandFramework.Registry; -internal record CacheResult(CommandMetadata Command, string[] Args, IEnumerable PartialMatches) +internal record CacheResult { - internal bool IsMatched => Command != null; + internal IEnumerable Commands { get; } + internal string[] Args { get; } + internal IEnumerable PartialMatches { get; } + + internal bool IsMatched => Commands != null && Commands.Any(); internal bool HasPartial => PartialMatches?.Any() ?? false; + + // Constructor for multiple commands + public CacheResult(IEnumerable commands, string[] args, IEnumerable partialMatches) + { + Commands = commands; + Args = args ?? Array.Empty(); // Ensure Args is never null + PartialMatches = partialMatches; + } + + // Constructor for single command or null + public CacheResult(CommandMetadata command, string[] args, IEnumerable partialMatches) + { + Commands = command != null ? new[] { command } : null; + Args = args ?? Array.Empty(); // Ensure Args is never null + PartialMatches = partialMatches; + } } diff --git a/VCF.Core/Registry/CommandCache.cs b/VCF.Core/Registry/CommandCache.cs index 4acf528..bcb1560 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -10,12 +10,12 @@ internal class CommandCache { private static Dictionary> _commandAssemblyMap = new(); - private Dictionary> _newCache = new(); + // Change dictionary value from CommandMetadata to List + internal Dictionary>> _newCache = new(); internal void AddCommand(string key, ParameterInfo[] parameters, CommandMetadata command) { key = key.ToLowerInvariant(); - var p = parameters.Length; var d = parameters.Where(p => p.HasDefaultValue).Count(); if (!_newCache.ContainsKey(key)) @@ -27,12 +27,14 @@ internal void AddCommand(string key, ParameterInfo[] parameters, CommandMetadata for (var i = p - d; i <= p; i++) { _newCache[key] = _newCache.GetValueOrDefault(key, new()) ?? new(); - if (_newCache[key].ContainsKey(i)) + if (!_newCache[key].ContainsKey(i)) { - Log.Warning($"Command {key} has multiple commands with {i} parameters"); - continue; + _newCache[key][i] = new List(); } - _newCache[key][i] = command; + + // Add new command to the list + _newCache[key][i].Add(command); + var typeKey = command.Method.DeclaringType; var usedParams = _commandAssemblyMap.TryGetValue(typeKey, out var existing) ? existing : new(); @@ -44,33 +46,111 @@ internal void AddCommand(string key, ParameterInfo[] parameters, CommandMetadata internal CacheResult GetCommand(string rawInput) { var lowerRawInput = rawInput.ToLowerInvariant(); - // todo: I think allows for overlap between .foo "bar" and .foo bar List possibleMatches = new(); + List exactMatches = new(); + foreach (var (key, argCounts) in _newCache) { if (lowerRawInput.StartsWith(key)) { - // there's no need to inspect the parameters if the next character isn't a space or the end of the string - // because it means that this was part of a different prefix token - if (lowerRawInput.Length > key.Length && lowerRawInput[key.Length] != ' ') - { - continue; - } + // Check if it's an exact match (no additional text) or if the next character is a space + bool isExactMatch = lowerRawInput.Length == key.Length; + bool hasSpaceAfter = lowerRawInput.Length > key.Length && lowerRawInput[key.Length] == ' '; - var remainder = rawInput.Substring(key.Length).Trim(); - var parameters = Utility.GetParts(remainder).ToArray(); - if (argCounts.TryGetValue(parameters.Length, out var cmd)) + if (isExactMatch || hasSpaceAfter) { - return new CacheResult(cmd, parameters, null); + string remainder = isExactMatch ? "" : rawInput.Substring(key.Length).Trim(); + string[] parameters = remainder.Length > 0 ? Utility.GetParts(remainder).ToArray() : Array.Empty(); + + if (argCounts.TryGetValue(parameters.Length, out var cmds)) + { + // Add all commands that match the exact parameter count + exactMatches.AddRange(cmds); + + // Store the parameters to return + if (exactMatches.Count > 0 && parameters.Length > 0) + { + return new CacheResult(exactMatches, parameters, null); + } + else + { + return new CacheResult(exactMatches, Array.Empty(), null); + } + } + else + { + // Add all possible matches for the command name but different param counts + possibleMatches.AddRange(argCounts.Values.SelectMany(x => x)); + } } - else + } + } + + // If we have exact matches but didn't return early + if (exactMatches.Count > 0) + { + return new CacheResult(exactMatches, Array.Empty(), null); + } + + // Use the explicit single command constructor with null + CommandMetadata nullCommand = null; + return new CacheResult(nullCommand, null, possibleMatches.Distinct()); + } + + // Handle assembly-specific command lookup + internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName) + { + var lowerRawInput = rawInput.ToLowerInvariant(); + List possibleMatches = new(); + List exactMatches = new(); + + foreach (var (key, argCounts) in _newCache) + { + if (lowerRawInput.StartsWith(key)) + { + // Check if it's an exact match (no additional text) or if the next character is a space + bool isExactMatch = lowerRawInput.Length == key.Length; + bool hasSpaceAfter = lowerRawInput.Length > key.Length && lowerRawInput[key.Length] == ' '; + + if (isExactMatch || hasSpaceAfter) { - possibleMatches.AddRange(argCounts.Values); + string remainder = isExactMatch ? "" : rawInput.Substring(key.Length).Trim(); + string[] parameters = remainder.Length > 0 ? Utility.GetParts(remainder).ToArray() : Array.Empty(); + + if (argCounts.TryGetValue(parameters.Length, out var cmds)) + { + // Add all commands that match the exact parameter count and assembly name + exactMatches.AddRange(cmds.Where(cmd => cmd.Assembly.GetName().Name.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); + + // Store the parameters to return + if (exactMatches.Count > 0 && parameters.Length > 0) + { + return new CacheResult(exactMatches, parameters, null); + } + else + { + return new CacheResult(exactMatches, Array.Empty(), null); + } + } + else + { + // Add all possible matches for the command name but different param counts + possibleMatches.AddRange(argCounts.Values.SelectMany(x => x) + .Where(cmd => cmd.Assembly.GetName().Name.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); + } } } } - return new CacheResult(null, null, possibleMatches.Distinct()); + // If we have exact matches but didn't return early + if (exactMatches.Count > 0) + { + return new CacheResult(exactMatches, Array.Empty(), null); + } + + // Use the explicit single command constructor with null + CommandMetadata nullCommand = null; + return new CacheResult(nullCommand, null, possibleMatches.Distinct()); } internal void RemoveCommandsFromType(Type t) @@ -85,7 +165,17 @@ internal void RemoveCommandsFromType(Type t) { continue; } - dict.Remove(index); + + if (dict.TryGetValue(index, out var cmdList)) + { + cmdList.RemoveAll(cmd => cmd.Method.DeclaringType == t); + + // If the list is now empty, remove the entry + if (cmdList.Count == 0) + { + dict.Remove(index); + } + } } _commandAssemblyMap.Remove(t); } diff --git a/VCF.Core/Registry/CommandMetadata.cs b/VCF.Core/Registry/CommandMetadata.cs index 19de2ae..417b0fd 100644 --- a/VCF.Core/Registry/CommandMetadata.cs +++ b/VCF.Core/Registry/CommandMetadata.cs @@ -3,4 +3,4 @@ namespace VampireCommandFramework.Registry; -internal record CommandMetadata(CommandAttribute Attribute, MethodInfo Method, ConstructorInfo Constructor, ParameterInfo[] Parameters, Type ContextType, Type ConstructorType, CommandGroupAttribute GroupAttribute); +internal record CommandMetadata(CommandAttribute Attribute, Assembly Assembly, MethodInfo Method, ConstructorInfo Constructor, ParameterInfo[] Parameters, Type ContextType, Type ConstructorType, CommandGroupAttribute GroupAttribute); diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index 00fb409..3e798a3 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -3,19 +3,23 @@ using System.ComponentModel; using System.Linq; using System.Reflection; +using System.Text; +using VampireCommandFramework.Basics; using VampireCommandFramework.Common; using VampireCommandFramework.Registry; +using static VampireCommandFramework.Format; + namespace VampireCommandFramework; public static class CommandRegistry { internal const string DEFAULT_PREFIX = "."; - private static CommandCache _cache = new(); + internal static CommandCache _cache = new(); /// /// From converting type to (object instance, MethodInfo tryParse, Type contextType) /// - internal static Dictionary _converters = new(); + internal static Dictionary _converters = []; internal static void Reset() { @@ -31,6 +35,9 @@ internal static void Reset() private static List DEFAULT_MIDDLEWARES = new() { new VCF.Core.Basics.BasicAdminCheck() }; public static List Middlewares { get; } = new() { new VCF.Core.Basics.BasicAdminCheck() }; + // Store pending commands for selection + private static Dictionary> _pendingCommands = []; + internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata command) { // Log.Debug($"Executing {Middlewares.Count} CanHandle Middlwares:"); @@ -53,66 +60,192 @@ internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata comm return true; } - public static CommandResult Handle(ICommandContext ctx, string input) + private static void HandleBeforeExecute(ICommandContext ctx, CommandMetadata command) { + Middlewares.ForEach(m => m.BeforeExecute(ctx, command.Attribute, command.Method)); + } + private static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) + { + Middlewares.ForEach(m => m.AfterExecute(ctx, command.Attribute, command.Method)); + } + + public static CommandResult Handle(ICommandContext ctx, string input) + { + // Check if this is a command selection (e.g., .1, .2, etc.) + if (input.StartsWith(DEFAULT_PREFIX) && input.Length > 1) + { + string numberPart = input.Substring(1); + if (int.TryParse(numberPart, out int selectedIndex) && selectedIndex > 0) + { + return HandleCommandSelection(ctx, selectedIndex); + } + } - // todo: rethink, maybe you only want 1 door here, people will confuse these and it's probably possible to collapse - static void HandleBeforeExecute(ICommandContext ctx, CommandMetadata command) + // Ensure the command starts with the prefix + if (!input.StartsWith(DEFAULT_PREFIX)) { - Middlewares.ForEach(m => m.BeforeExecute(ctx, command.Attribute, command.Method)); + return CommandResult.Unmatched; // Not a command } - static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) + // Remove the prefix for processing + string afterPrefix = input.Substring(DEFAULT_PREFIX.Length); + + // Check if this could be an assembly-specific command + string assemblyName = null; + string commandInput = input; // Default to using the entire input + + int spaceIndex = afterPrefix.IndexOf(' '); + if (spaceIndex > 0) { - Middlewares.ForEach(m => m.AfterExecute(ctx, command.Attribute, command.Method)); + string potentialAssemblyName = afterPrefix.Substring(0, spaceIndex); + + // Check if this could be a valid assembly name + bool isValidAssembly = AssemblyCommandMap.Keys.Any(a => + a.GetName().Name.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + + if (isValidAssembly) + { + assemblyName = potentialAssemblyName; + commandInput = "." + afterPrefix.Substring(spaceIndex + 1); + } } + // Get command(s) based on input + CacheResult matchedCommand; + if (assemblyName != null) + { + matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); + } + else + { + matchedCommand = _cache.GetCommand(input); + } - var matchedCommand = _cache.GetCommand(input); - var (command, args) = (matchedCommand.Command, matchedCommand.Args); + var (commands, args) = (matchedCommand.Commands, matchedCommand.Args); if (!matchedCommand.IsMatched) { if (!matchedCommand.HasPartial) return CommandResult.Unmatched; // NOT FOUND - foreach (var possible in matchedCommand.PartialMatches) { - ctx.SysReply(Basics.HelpCommands.PrintShortHelp(possible)); + ctx.SysReply(HelpCommands.GetShortHelp(possible)); } return CommandResult.UsageError; } - // Handle Context Type not matching command - if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) + // If there's only one command, handle it directly + if (commands.Count() == 1) { - Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ContextType.Name} from {ctx?.GetType().Name}"); - return CommandResult.InternalError; + return ExecuteCommand(ctx, commands.First(), args); } - // Then handle this invocation's context not being valid for the command classes custom constructor - if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) + // Multiple commands match, try to convert parameters for each + var successfulCommands = new List<(CommandMetadata Command, object[] Args, string Error)>(); + var failedCommands = new List<(CommandMetadata Command, string Error)>(); + + foreach (var command in commands) { - Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ConstructorType.Name} from {ctx?.GetType().Name}"); - ctx.InternalError(); - return CommandResult.InternalError; + if (!CanCommandExecute(ctx, command)) continue; + + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + if (success) + { + successfulCommands.Add((command, commandArgs, null)); + } + else + { + failedCommands.Add((command, error)); + } + } + + // Case 1: No command succeeded + if (successfulCommands.Count == 0) + { + ctx.Reply($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); + foreach (var (command, error) in failedCommands) + { + string assemblyInfo = command.Assembly.GetName().Name; + ctx.Reply($" - {command.Attribute.Id} ({assemblyInfo}): {error}"); + } + return CommandResult.UsageError; + } + + // Case 2: Only one command succeeded + if (successfulCommands.Count == 1) + { + var (command, commandArgs, _) = successfulCommands[0]; + return ExecuteCommandWithArgs(ctx, command, commandArgs); + } + + // Case 3: Multiple commands succeeded - store and ask user to select + _pendingCommands[ctx.Name] = successfulCommands; + + var sb = new StringBuilder(); + sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); + for (int i = 0; i < successfulCommands.Count; i++) + { + var (command, _, _) = successfulCommands[i]; + var cmdAssembly = command.Assembly.GetName().Name; + var description = command.Attribute.Description; + sb.AppendLine($" {("."+ (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); + sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); } + ctx.SysPaginatedReply(sb); - var argCount = args.Length; + return CommandResult.Success; + } + + // Add these helper methods: + + private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) + { + if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.Count == 0) + { + ctx.Reply($"{"[error]".Color(Color.Red)} No command selection is pending."); + return CommandResult.CommandError; + } + + if (selectedIndex < 1 || selectedIndex > pendingCommands.Count) + { + ctx.Reply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between 1 and {pendingCommands.Count}."); + return CommandResult.UsageError; + } + + var (command, args, _) = pendingCommands[selectedIndex - 1]; + + // Clear pending commands after selection + var result = ExecuteCommandWithArgs(ctx, command, args); + pendingCommands.Clear(); + return result; + } + + private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args) + { + var argCount = args?.Length ?? 0; var paramsCount = command.Parameters.Length; var commandArgs = new object[paramsCount + 1]; commandArgs[0] = ctx; - // Handle default values - if (argCount != paramsCount) + // Special case for commands with no parameters + if (paramsCount == 0 && argCount == 0) + { + return (true, commandArgs, null); + } + + // Handle parameter count mismatch + if (argCount > paramsCount) + { + return (false, null, $"Too many parameters: expected {paramsCount}, got {argCount}"); + } + else if (argCount < paramsCount) { var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); if (!canDefault) { - // todo: error you bad at defaulting values, how you explain to someone? - return CommandResult.UsageError; + return (false, null, $"Missing required parameters: expected {paramsCount}, got {argCount}"); } for (var i = argCount; i < paramsCount; i++) { @@ -120,82 +253,148 @@ static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) } } - // Handle Converting Parameters - for (var i = 0; i < argCount; i++) + // If we have arguments to convert, process them + if (argCount > 0) { - var param = command.Parameters[i]; - var arg = args[i]; - - // Custom Converter - if (_converters.TryGetValue(param.ParameterType, out var customConverter)) + for (var i = 0; i < argCount; i++) { - var (converter, convertMethod, converterContextType) = customConverter; - - if (!converterContextType.IsAssignableFrom(ctx.GetType())) - { - Log.Error($"Converter type {converterContextType.Name} is not assignable from {ctx.GetType().Name}"); - ctx.InternalError(); - return CommandResult.InternalError; - } + var param = command.Parameters[i]; + var arg = args[i]; + bool conversionSuccess = false; + string conversionError = null; - object result; - var tryParseArgs = new object[] { ctx, arg }; try { - result = convertMethod.Invoke(converter, tryParseArgs); - commandArgs[i + 1] = result; - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException e) + // Custom Converter + if (_converters.TryGetValue(param.ParameterType, out var customConverter)) { - // todo: error matched type but failed to convert arg to type - ctx.Reply($"[error] Failed converted parameter: {e.Message}"); - return CommandResult.UsageError; + var (converter, convertMethod, converterContextType) = customConverter; + + // IMPORTANT CHANGE: Return special error code for unassignable context + if (!converterContextType.IsAssignableFrom(ctx.GetType())) + { + // Signal internal error with a special return format + return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name} is not assignable from {ctx.GetType().Name}"); + } + + object result; + var tryParseArgs = new object[] { ctx, arg }; + try + { + result = convertMethod.Invoke(converter, tryParseArgs); + commandArgs[i + 1] = result; + conversionSuccess = true; + } + catch (TargetInvocationException tie) + { + if (tie.InnerException is CommandException e) + { + conversionError = $"Parameter {i + 1} ({param.Name}): {e.Message}"; + } + else + { + conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error converting parameter"; + } + } + catch (Exception) + { + conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error converting parameter"; + } } else { - Log.Warning($"Hit unexpected exception {tie}"); - ctx.InternalError(); - return CommandResult.InternalError; + var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); + try + { + var val = defaultConverter.ConvertFromInvariantString(arg); + + // Separate, more robust enum validation + if (param.ParameterType.IsEnum) + { + bool isDefined = false; + + // For numeric input, we need to check if the value is defined + if (int.TryParse(arg, out int enumIntVal)) + { + isDefined = Enum.IsDefined(param.ParameterType, enumIntVal); + + if (!isDefined) + { + return (false, null, $"Parameter {i + 1} ({param.Name}): Invalid enum value '{arg}' for {param.ParameterType.Name}"); + } + } + } + + commandArgs[i + 1] = val; + conversionSuccess = true; + } + catch (Exception e) + { + conversionError = $"Parameter {i + 1} ({param.Name}): {e.Message}"; + } } } - catch (Exception e) + catch (Exception ex) { - // todo: failed custom converter unhandled - Log.Warning($"Hit unexpected exception {e}"); - ctx.InternalError(); - return CommandResult.InternalError; + conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error: {ex.Message}"; } - } - // Default Converter - else - { - var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); - try - { - var val = defaultConverter.ConvertFromInvariantString(arg); - // ensure enums are valid for #16 - if (defaultConverter is EnumConverter) - { - if (!Enum.IsDefined(param.ParameterType, val)) - { - ctx.Reply($"[error] Invalid value {val} for {param.ParameterType.Name}"); - return CommandResult.UsageError; - } - } - - commandArgs[i + 1] = val; - } - catch (Exception e) + if (!conversionSuccess) { - ctx.Reply($"[error] Failed converted parameter: {e.Message}"); - return CommandResult.UsageError; + return (false, null, conversionError); } } } + return (true, commandArgs, null); + } + + private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata command, string[] args) + { + // Handle Context Type not matching command + if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ContextType.Name} from {ctx?.GetType().Name}"); + return CommandResult.InternalError; + } + + // Try to convert parameters + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + if (!success) + { + // Check for special internal error flag + if (error != null && error.StartsWith("INTERNAL_ERROR:")) + { + string actualError = error.Substring("INTERNAL_ERROR:".Length); + Log.Warning(actualError); + ctx.InternalError(); + return CommandResult.InternalError; + } + + ctx.Reply($"{"[error]".Color(Color.Red)} {error}"); + return CommandResult.UsageError; + } + + return ExecuteCommandWithArgs(ctx, command, commandArgs); + } + + private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, CommandMetadata command, object[] commandArgs) + { + // Handle Context Type not matching command + if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ContextType.Name} from {ctx?.GetType().Name}"); + return CommandResult.InternalError; + } + + // Then handle this invocation's context not being valid for the command classes custom constructor + if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ConstructorType.Name} from {ctx?.GetType().Name}"); + ctx.InternalError(); + return CommandResult.InternalError; + } + object instance = null; // construct command's type with context if declared only in a non-static class and on a non-static method if (!command.Method.IsStatic && !(command.Method.DeclaringType.IsAbstract && command.Method.DeclaringType.IsSealed)) @@ -222,7 +421,7 @@ static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) // Handle Middlewares if (!CanCommandExecute(ctx, command)) { - ctx.Reply($"[denied] {command.Attribute.Id}"); + ctx.Reply($"{"[denied]".Color(Color.Red)} {command.Attribute.Id}"); return CommandResult.Denied; } @@ -235,7 +434,7 @@ static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) } catch (TargetInvocationException tie) when (tie.InnerException is CommandException e) { - ctx.Reply($"[error] {e.Message}"); + ctx.Reply($"{"[error]".Color(Color.Red)} {e.Message}"); return CommandResult.CommandError; } catch (Exception e) @@ -403,7 +602,7 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou var constructorType = customConstructor?.GetParameters().Single().ParameterType; - var command = new CommandMetadata(commandAttr, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); + var command = new CommandMetadata(commandAttr, assembly, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); // todo include prefix and group in here, this shoudl be a string match // todo handle collisons here diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs new file mode 100644 index 0000000..2c43609 --- /dev/null +++ b/VCF.Tests/AssemblyCommandTests.cs @@ -0,0 +1,243 @@ +using NUnit.Framework; +using System.Reflection; +using System.Collections.Generic; +using VampireCommandFramework; +using VampireCommandFramework.Registry; +using System.Reflection.Emit; + +namespace VCF.Tests +{ + public class AssemblyCommandTests + { + [SetUp] + public void Setup() + { + CommandRegistry.Reset(); + Format.Mode = Format.FormatMode.None; + } + + #region Test Command Classes + + // Define commands for our mock "MyMod1" assembly + public class MyMod1Commands + { + [Command("test", description: "MyMod1 test command")] + public void TestCommand(ICommandContext ctx) + { + ctx.Reply("MyMod1 test command executed"); + } + + [Command("echo", description: "MyMod1 echo command")] + public void EchoCommand(ICommandContext ctx, string message) + { + ctx.Reply($"MyMod1 echo: {message}"); + } + } + + // Define commands for our mock "MyMod2" assembly + public class MyMod2Commands + { + [Command("test", description: "MyMod2 test command")] + public void TestCommand(ICommandContext ctx) + { + ctx.Reply("MyMod2 test command executed"); + } + + [Command("add", description: "MyMod2 add command")] + public void AddCommand(ICommandContext ctx, int a, int b) + { + ctx.Reply($"MyMod2 sum: {a + b}"); + } + } + + #endregion + + #region Helper Methods + + /// + /// Helper method to register commands and associate them with a mock assembly name + /// + private void RegisterCommandsWithMockAssembly(System.Type commandType, string mockAssemblyName) + { + // Register the command type normally + CommandRegistry.RegisterCommandType(commandType); + + // Get the actual assembly + var realAssembly = commandType.Assembly; + + // Check if commands were registered for the real assembly + if (CommandRegistry.AssemblyCommandMap.TryGetValue(realAssembly, out var commandCache)) + { + // Create a dynamic assembly with our mock name + var asmName = new AssemblyName(mockAssemblyName); + var mockAssembly = AssemblyBuilder.DefineDynamicAssembly( + asmName, + AssemblyBuilderAccess.Run); + + // Create a new command cache for the mock assembly + var mockCommandCache = new Dictionary>(); + + // Create a new command cache for the real assembly (without the commands we're moving) + var newRealCommandCache = new Dictionary>(); + + // Sort each command to either the mock or real assembly cache + foreach (var entry in commandCache) + { + if (entry.Key.Method.DeclaringType == commandType) + { + // This command belongs to the commandType we're registering + // Move it to the mock assembly + var newCommandMetadata = entry.Key with { Assembly = mockAssembly }; + mockCommandCache[newCommandMetadata] = entry.Value; + + // Update CommandCache + foreach(var cacheEntry in CommandRegistry._cache._newCache.Values) + { + foreach(var commandList in cacheEntry.Values) + { + for (int i = 0; i < commandList.Count; i++) + { + if (commandList[i].Method == entry.Key.Method) + { + commandList[i] = newCommandMetadata; + } + } + } + } + } + else + { + // This command belongs to a different type + // Keep it in the real assembly + newRealCommandCache[entry.Key] = entry.Value; + } + } + + // Update the registry with our new caches + CommandRegistry.AssemblyCommandMap[mockAssembly] = mockCommandCache; + CommandRegistry.AssemblyCommandMap[realAssembly] = newRealCommandCache; + } + } + + #endregion + + #region Tests + + [Test] + public void AssemblySpecificCommand_RequiresPrefix() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + RegisterCommandsWithMockAssembly(typeof(MyMod2Commands), "MyMod2"); + + var ctx = new AssertReplyContext(); + + // Try without prefix - should fail + var result1 = CommandRegistry.Handle(ctx, "MyMod1 test"); + Assert.That(result1, Is.EqualTo(CommandResult.Unmatched)); + + // Try with prefix - should succeed + var result2 = CommandRegistry.Handle(ctx, ".MyMod1 test"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 test command executed"); + } + + [Test] + public void AssemblySpecificCommand_ExecutesCorrectCommand() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + RegisterCommandsWithMockAssembly(typeof(MyMod2Commands), "MyMod2"); + + // Test MyMod1 command + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".MyMod1 test"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("MyMod1 test command executed"); + + // Test MyMod2 command + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".MyMod2 test"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("MyMod2 test command executed"); + } + + [Test] + public void AssemblySpecificCommand_ExecutesAssemblyNameCasing() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + RegisterCommandsWithMockAssembly(typeof(MyMod2Commands), "MyMod2"); + + // Test MyMod1 command + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".mymod1 test"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("MyMod1 test command executed"); + + // Test MyMod2 command + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".MYMOD2 test"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("MyMod2 test command executed"); + } + + [Test] + public void AssemblySpecificCommand_WithParameters() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + RegisterCommandsWithMockAssembly(typeof(MyMod2Commands), "MyMod2"); + + // Test MyMod1 command with parameter + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".MyMod1 echo \"Hello world\""); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("MyMod1 echo: Hello world"); + + // Test MyMod2 command with parameters + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".MyMod2 add 10 20"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("MyMod2 sum: 30"); + } + + [Test] + public void InvalidAssemblyName_FallsBackToRegularCommand() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + + var ctx = new AssertReplyContext(); + + // Use an invalid assembly name - should try to interpret as a regular command + var result = CommandRegistry.Handle(ctx, ".NonExistentMod test hello"); + + Assert.That(result, Is.EqualTo(CommandResult.Unmatched)); + } + + [Test] + public void SameCommandNameInDifferentAssemblies_UsesCorrectOne() + { + // Register commands with mock assemblies + RegisterCommandsWithMockAssembly(typeof(MyMod1Commands), "MyMod1"); + RegisterCommandsWithMockAssembly(typeof(MyMod2Commands), "MyMod2"); + + // Both assemblies have a 'test' command, but they should be kept separate + + // Test MyMod1 test command + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".MyMod1 test"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("MyMod1 test command executed"); + + // Test MyMod2 test command + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".MyMod2 test"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("MyMod2 test command executed"); + } + + #endregion + } +} diff --git a/VCF.Tests/CommandAdminAndSelectionTests.cs b/VCF.Tests/CommandAdminAndSelectionTests.cs new file mode 100644 index 0000000..92407ef --- /dev/null +++ b/VCF.Tests/CommandAdminAndSelectionTests.cs @@ -0,0 +1,245 @@ +using NUnit.Framework; +using VampireCommandFramework; + +namespace VCF.Tests +{ + public class CommandAdminAndSelectionTests + { + [SetUp] + public void Setup() + { + CommandRegistry.Reset(); + Format.Mode = Format.FormatMode.None; + } + + #region Test Commands + + // Define admin-only commands + public class AdminCommands + { + [Command("admin", adminOnly: true, description: "Admin-only command")] + public void AdminOnly(ICommandContext ctx) + { + ctx.Reply("Admin command executed"); + } + + [Command("admin-int", adminOnly: true, description: "Admin-only int command")] + public void AdminInt(ICommandContext ctx, int value) + { + ctx.Reply($"Admin int command executed with: {value}"); + } + } + + // Define regular commands with same name but different params + public class RegularCommands + { + [Command("admin", description: "Regular user command")] + public void RegularAdmin(ICommandContext ctx, string value) + { + ctx.Reply($"Regular command executed with: {value}"); + } + + [Command("dual")] + public void DualCommand(ICommandContext ctx, int value) + { + ctx.Reply($"Dual int command executed with: {value}"); + } + + [Command("dual")] + public void DualCommand(ICommandContext ctx, float value) + { + ctx.Reply($"Dual float command executed with: {value}"); + } + } + + #endregion + + #region Admin Access Tests + + [Test] + public void AdminCommand_WhenUserIsAdmin_ExecutesCommand() + { + // Register admin command + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + + var ctx = new AssertReplyContext { IsAdmin = true }; + var result = CommandRegistry.Handle(ctx, ".admin"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Admin command executed"); + } + + [Test] + public void AdminCommand_WhenUserIsNotAdmin_DeniesAccess() + { + // Register admin command + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + + var ctx = new AssertReplyContext { IsAdmin = false }; + var result = CommandRegistry.Handle(ctx, ".admin"); + + Assert.That(result, Is.EqualTo(CommandResult.Denied)); + ctx.AssertReplyContains("[denied]"); + } + + [Test] + public void OverloadedCommands_AdminAndRegular_ExecutesCorrectlyBasedOnAccess() + { + // Register both admin and regular commands + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // Test non-admin user - should execute regular command + var nonAdminCtx = new AssertReplyContext { IsAdmin = false }; + var nonAdminResult = CommandRegistry.Handle(nonAdminCtx, ".admin test"); + + Assert.That(nonAdminResult, Is.EqualTo(CommandResult.Success)); + nonAdminCtx.AssertReplyContains("Regular command executed with: test"); + + // Test admin user - admin command has no params, so should get ambiguity + var adminCtx = new AssertReplyContext { IsAdmin = true }; + var adminResult = CommandRegistry.Handle(adminCtx, ".admin"); + + Assert.That(adminResult, Is.EqualTo(CommandResult.Success)); + adminCtx.AssertReplyContains("Admin command executed"); + } + + [Test] + public void OverloadedCommands_AdminAndRegularWithParams_AdminSelectsCorrectly() + { + // Register both admin and regular commands + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // Admin user with a parameter that only matches regular command + var adminCtx = new AssertReplyContext { IsAdmin = true }; + var adminResult = CommandRegistry.Handle(adminCtx, ".admin test"); + + Assert.That(adminResult, Is.EqualTo(CommandResult.Success)); + adminCtx.AssertReplyContains("Regular command executed with: test"); + } + + [Test] + public void OverloadedCommands_AdminInt_AdminCanAccess() + { + // Register both admin and regular commands + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // Admin user with int parameter + var adminCtx = new AssertReplyContext { IsAdmin = true }; + var adminResult = CommandRegistry.Handle(adminCtx, ".admin-int 42"); + + Assert.That(adminResult, Is.EqualTo(CommandResult.Success)); + adminCtx.AssertReplyContains("Admin int command executed with: 42"); + } + + [Test] + public void OverloadedCommands_AdminInt_NonAdminCannotAccess() + { + // Register both admin and regular commands + CommandRegistry.RegisterCommandType(typeof(AdminCommands)); + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // Non-admin user trying to access admin-int command + var nonAdminCtx = new AssertReplyContext { IsAdmin = false }; + var nonAdminResult = CommandRegistry.Handle(nonAdminCtx, ".admin-int 42"); + + Assert.That(nonAdminResult, Is.EqualTo(CommandResult.Denied)); + nonAdminCtx.AssertReplyContains("[denied]"); + } + + #endregion + + #region Command Selection Isolation Tests + + [Test] + public void CommandSelection_DifferentUsers_IsolatedSelections() + { + // Register commands that would trigger selection + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // First user gets dual command options + var user1 = new AssertReplyContext { Name = "User1" }; + var result1 = CommandRegistry.Handle(user1, ".dual 42"); + + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + user1.AssertReplyContains("Multiple commands match this input"); + + // Second user should not be able to select from first user's options + var user2 = new AssertReplyContext { Name = "User2" }; + var result2 = CommandRegistry.Handle(user2, ".1"); + + Assert.That(result2, Is.Not.EqualTo(CommandResult.Success)); + user2.AssertReplyContains("No command selection is pending"); + } + + [Test] + public void CommandSelection_SameCommandDifferentUsers_IndependentSelections() + { + // Register commands that would trigger selection + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // First user gets dual command options + var user1 = new AssertReplyContext { Name = "User1" }; + CommandRegistry.Handle(user1, ".dual 42"); + + // Second user also gets dual command options + var user2 = new AssertReplyContext { Name = "User2" }; + CommandRegistry.Handle(user2, ".dual 99"); + + // First user selects option 1 + var result1 = CommandRegistry.Handle(user1, ".1"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + user1.AssertReplyContains("with: 42"); // Should have the original value + + // Second user selects option 1 + var result2 = CommandRegistry.Handle(user2, ".1"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + user2.AssertReplyContains("with: 99"); // Should have their own value + } + + [Test] + public void CommandSelection_DisappearsAfterExecution() + { + // Register commands that would trigger selection + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // User gets dual command options + var user = new AssertReplyContext { Name = "User" }; + CommandRegistry.Handle(user, ".dual 42"); + + // User selects option 1 + var result1 = CommandRegistry.Handle(user, ".1"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + + // Selection should be cleared after execution + var result2 = CommandRegistry.Handle(user, ".1"); + Assert.That(result2, Is.Not.EqualTo(CommandResult.Success)); + user.AssertReplyContains("No command selection is pending"); + } + + [Test] + public void CommandSelection_InvalidSelection_DoesNotClearOptions() + { + // Register commands that would trigger selection + CommandRegistry.RegisterCommandType(typeof(RegularCommands)); + + // User gets dual command options + var user = new AssertReplyContext { Name = "User" }; + CommandRegistry.Handle(user, ".dual 42"); + + // User selects invalid option + var result1 = CommandRegistry.Handle(user, ".99"); + Assert.That(result1, Is.EqualTo(CommandResult.UsageError)); + user.AssertReplyContains("Invalid selection"); + + // Selection should still be available + var result2 = CommandRegistry.Handle(user, ".1"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + user.AssertReplyContains("with: 42"); + } + + #endregion + } +} diff --git a/VCF.Tests/CommandOverloadingTests.cs b/VCF.Tests/CommandOverloadingTests.cs new file mode 100644 index 0000000..f926b95 --- /dev/null +++ b/VCF.Tests/CommandOverloadingTests.cs @@ -0,0 +1,319 @@ +using NUnit.Framework; +using VampireCommandFramework; + +namespace VCF.Tests +{ + public class CommandOverloadingTests + { + [SetUp] + public void Setup() + { + CommandRegistry.Reset(); + Format.Mode = Format.FormatMode.None; + } + + #region Test Commands + + // Define command classes within each test to avoid registration conflicts + + public class StringParameterCommands + { + [Command("test", description: "String parameter command")] + public void TestString(ICommandContext ctx, string value) + { + ctx.Reply($"String command executed with: {value}"); + } + + [Command("mixed")] + public void MixedParams(ICommandContext ctx, string text, int number) + { + ctx.Reply($"Mixed command with string: {text}, int: {number}"); + } + } + + public class IntParameterCommands + { + [Command("test", description: "Int parameter command")] + public void TestInt(ICommandContext ctx, int value) + { + ctx.Reply($"Int command executed with: {value}"); + } + + [Command("selection")] + public void Selection(ICommandContext ctx, int value) + { + ctx.Reply($"Int selection command with: {value}"); + } + } + + public class FloatParameterCommands + { + [Command("test", description: "Float parameter command")] + public void TestFloat(ICommandContext ctx, float value) + { + ctx.Reply($"Float command executed with: {value}"); + } + + [Command("selection")] + public void Selection(ICommandContext ctx, float value) + { + ctx.Reply($"Float selection command with: {value}"); + } + } + + #endregion + + #region Basic Overloading Tests + + [Test] + public void OverloadedCommand_StringParameter_ExecutesCorrectCommand() + { + // Register just string command + CommandRegistry.RegisterCommandType(typeof(StringParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test hello"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("String command executed with: hello"); + } + + [Test] + public void OverloadedCommand_IntParameter_ExecutesCorrectCommand() + { + // Register just int command + CommandRegistry.RegisterCommandType(typeof(IntParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test 123"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Int command executed with: 123"); + } + + [Test] + public void OverloadedCommand_FloatParameter_ExecutesCorrectCommand() + { + // Register just float command + CommandRegistry.RegisterCommandType(typeof(FloatParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test 123.45"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Float command executed with: 123.45"); + } + + #endregion + + #region Multiple Valid Commands Tests + + [Test] + public void MultipleValidCommands_ShowsSelectionOptions() + { + // Register both int and float commands + CommandRegistry.RegisterCommandType(typeof(IntParameterCommands)); + CommandRegistry.RegisterCommandType(typeof(FloatParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".selection 42"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Multiple commands match this input"); + ctx.AssertReplyContains("selection"); + } + + [Test] + public void CommandSelection_SelectsCorrectCommand() + { + // Register both command types + CommandRegistry.RegisterCommandType(typeof(IntParameterCommands)); + CommandRegistry.RegisterCommandType(typeof(FloatParameterCommands)); + + var ctx = new AssertReplyContext(); + // First trigger selection + CommandRegistry.Handle(ctx, ".selection 42"); + + // Now select the first command + var result = CommandRegistry.Handle(ctx, ".1"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("selection command with: 42"); + } + + [Test] + public void CommandSelection_InvalidIndex_ReturnsError() + { + // Register both command types + CommandRegistry.RegisterCommandType(typeof(IntParameterCommands)); + CommandRegistry.RegisterCommandType(typeof(FloatParameterCommands)); + + var ctx = new AssertReplyContext(); + // First trigger selection + CommandRegistry.Handle(ctx, ".selection 42"); + + // Try an invalid selection + var result = CommandRegistry.Handle(ctx, ".99"); + + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); + ctx.AssertReplyContains("Invalid selection"); + } + + #endregion + + #region Conversion Failure Tests + + // Custom commands for the conversion failure test + public class FailingCommandClass + { + public class CustomType { } + + [Command("failing")] + public void FailingCommand(ICommandContext ctx, CustomType value) + { + // This implementation will never be called + ctx.Reply("This shouldn't execute"); + } + } + + [Test] + public void ConversionFailure_ShowsError() + { + CommandRegistry.RegisterCommandType(typeof(FailingCommandClass)); + + var ctx = new AssertReplyContext(); + // Provide a command where no converter exists + var result = CommandRegistry.Handle(ctx, ".failing 123"); + + Assert.That(result, Is.EqualTo(CommandResult.Unmatched)); + } + + #endregion + + #region Complex Parameter Tests + + [Test] + public void MixedParameterTypes_CorrectlyConverted() + { + CommandRegistry.RegisterCommandType(typeof(StringParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".mixed hello 42"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Mixed command with string: hello, int: 42"); + } + + [Test] + public void MixedParameterTypes_ConversionFailure() + { + CommandRegistry.RegisterCommandType(typeof(StringParameterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".mixed hello world"); + + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); + ctx.AssertReplyContains("[error]"); + } + + #endregion + + #region Command Overloading Edge Cases + + public class CommandsWithVaryingParameterCounts + { + [Command("params")] + public void NoParams(ICommandContext ctx) + { + ctx.Reply("No parameters"); + } + + [Command("params")] + public void OneParam(ICommandContext ctx, int value) + { + ctx.Reply($"One parameter: {value}"); + } + + [Command("params")] + public void TwoParams(ICommandContext ctx, int a, int b) + { + ctx.Reply($"Two parameters: {a}, {b}"); + } + } + + [Test] + public void DifferentParameterCounts_SelectsCorrectOverload() + { + CommandRegistry.RegisterCommandType(typeof(CommandsWithVaryingParameterCounts)); + + var ctx = new AssertReplyContext(); + + // Test with no parameters + var result1 = CommandRegistry.Handle(ctx, ".params"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("No parameters"); + + // Test with one parameter + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".params 42"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("One parameter: 42"); + + // Test with two parameters + var ctx3 = new AssertReplyContext(); + var result3 = CommandRegistry.Handle(ctx3, ".params 10 20"); + Assert.That(result3, Is.EqualTo(CommandResult.Success)); + ctx3.AssertReplyContains("Two parameters: 10, 20"); + } + + [Test] + public void TooManyParameters_ShowsError() + { + CommandRegistry.RegisterCommandType(typeof(CommandsWithVaryingParameterCounts)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".params 10 20 30"); + + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); + } + + #endregion + + #region Optional Parameter Tests + + public class CommandsWithOptionalParams + { + [Command("optional")] + public void OptionalParam(ICommandContext ctx, string required, int optional = 42) + { + ctx.Reply($"Optional command: {required}, {optional}"); + } + } + + [Test] + public void OptionalParameters_CanBeOmitted() + { + CommandRegistry.RegisterCommandType(typeof(CommandsWithOptionalParams)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".optional hello"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Optional command: hello, 42"); + } + + [Test] + public void OptionalParameters_CanBeProvided() + { + CommandRegistry.RegisterCommandType(typeof(CommandsWithOptionalParams)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".optional hello 99"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Optional command: hello, 99"); + } + + #endregion + } +} diff --git a/VCF.Tests/HelpTests.cs b/VCF.Tests/HelpTests.cs index c0f276d..1d81745 100644 --- a/VCF.Tests/HelpTests.cs +++ b/VCF.Tests/HelpTests.cs @@ -154,8 +154,8 @@ public void GenerateHelpText_UsageSpecified() { var (commandName, usage, description) = Any.ThreeStrings(); - var command = new CommandMetadata(new CommandAttribute(commandName, usage: usage, description: description), null, null, null, null, null, null); - var text = HelpCommands.PrintShortHelp(command); + var command = new CommandMetadata(new CommandAttribute(commandName, null, usage: usage, description: description), null, null, null, null, null, null, null); + var text = HelpCommands.GetShortHelp(command); Assert.That(text, Is.EqualTo($".{commandName} {usage}")); } @@ -167,9 +167,9 @@ public void GenerateHelpText_GeneratesUsage_NormalParam() var paramName = Any.String(); A.CallTo(() => param.Name).Returns(paramName); - var command = new CommandMetadata(new CommandAttribute(commandName, usage: null, description: description), null, null, new[] { param }, null, null, null); + var command = new CommandMetadata(new CommandAttribute(commandName, null, usage: null, description: description), null, null, null, new[] { param }, null, null, null); - var text = HelpCommands.PrintShortHelp(command); + var text = HelpCommands.GetShortHelp(command); Assert.That(text, Is.EqualTo($".{commandName} ({paramName})")); } @@ -185,9 +185,9 @@ public void GenerateHelpText_GeneratesUsage_DefaultParam() A.CallTo(() => param.DefaultValue).Returns(paramValue); A.CallTo(() => param.HasDefaultValue).Returns(true); - var command = new CommandMetadata(new CommandAttribute(commandName, usage: null, description: description), null, null, new[] { param }, null, null, null); + var command = new CommandMetadata(new CommandAttribute(commandName, usage: null, description: description), null, null, null, new[] { param }, null, null, null); - var text = HelpCommands.PrintShortHelp(command); + var text = HelpCommands.GetShortHelp(command); Assert.That(text, Is.EqualTo($".{commandName} [{paramName}={paramValue}]")); } From 89edcd0d28a50c0cadf6caa8ebea3b46375934d8 Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 1 May 2025 23:57:54 -0400 Subject: [PATCH 3/8] .help by default lists all the plugins with commands you can use Added a second level of filtering to .help so you can filter commands with a plugin or second filter for the command search .help now has commands and plugins sorted Adds .help-all which functions similar to previous .help except its ordered Added new unit tests --- VCF.Core/Basics/HelpCommand.cs | 72 +++++++++++++++++++++-------- VCF.Tests/AssertReplyContext.cs | 5 ++ VCF.Tests/HelpTests.cs | 81 +++++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 28 deletions(-) diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index b4b02f2..ebbfd32 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -19,7 +19,7 @@ internal static class HelpCommands public static void HelpLegacy(ICommandContext ctx, string search = null) => ctx.SysReply($"Attempting compatible .help {search} for non-VCF mods."); [Command("help")] - public static void HelpCommand(ICommandContext ctx, string search = null) + public static void HelpCommand(ICommandContext ctx, string search = null, string filter = null) { // If search is specified first look for matching assembly, then matching command if (!string.IsNullOrEmpty(search)) @@ -28,7 +28,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null) if (foundAssembly.Value != null) { StringBuilder sb = new(); - PrintAssemblyHelp(ctx, foundAssembly, sb); + PrintAssemblyHelp(ctx, foundAssembly, sb, filter); ctx.SysPaginatedReply(sb); } else @@ -41,7 +41,9 @@ public static void HelpCommand(ICommandContext ctx, string search = null) || x.Value.Contains(search, StringComparer.InvariantCultureIgnoreCase) ); - individualResults = individualResults.Where(kvp => CommandRegistry.CanCommandExecute(ctx, kvp.Key)); + individualResults = individualResults.Where(kvp => CommandRegistry.CanCommandExecute(ctx, kvp.Key)) + .Where(kvp => filter == null || + kvp.Key.Attribute.Name.Contains(filter, StringComparison.InvariantCultureIgnoreCase)); if (!individualResults.Any()) { @@ -60,28 +62,18 @@ public static void HelpCommand(ICommandContext ctx, string search = null) else { var sb = new StringBuilder(); - sb.AppendLine($"Listing {B("all")} commands"); - foreach (var assembly in CommandRegistry.AssemblyCommandMap) + sb.AppendLine($"Listing {B("all")} plugins"); + sb.AppendLine($"Use {B(".help ")} for commands in that plugin"); + // List all plugins they have a command they can execute for + foreach (var assemblyName in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c))) + .Select(x => x.Key.GetName().Name) + .OrderBy(x => x)) { - PrintAssemblyHelp(ctx, assembly, sb); + sb.AppendLine($"{assemblyName}"); } ctx.SysPaginatedReply(sb); } - void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb) - { - var name = assembly.Key.GetName().Name; - name = _trailingLongDashRegex.Replace(name, ""); - - sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline()); - var commands = assembly.Value.Keys.Where(c => CommandRegistry.CanCommandExecute(ctx, c)); - - foreach (var command in commands) - { - sb.AppendLine(PrintShortHelp(command)); - } - } - void GenerateFullHelp(CommandMetadata command, List aliases, StringBuilder sb) { sb.AppendLine($"{B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); @@ -108,6 +100,46 @@ void GenerateFullHelp(CommandMetadata command, List aliases, StringBuild } } + [Command("help-all", description: "Returns all plugin commands")] + public static void HelpAllCommand(ICommandContext ctx, string filter = null) + { + var sb = new StringBuilder(); + if (filter == null) + sb.AppendLine($"Listing {B("all")} commands"); + else + sb.AppendLine($"Listing {B("all")} commands matching filter '{filter}'"); + + var foundAnything = false; + foreach (var assembly in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c) && + (filter == null || + PrintShortHelp(c).Contains(filter, StringComparison.InvariantCultureIgnoreCase))))) + { + PrintAssemblyHelp(ctx, assembly, sb, filter); + foundAnything = true; + } + + if (!foundAnything) + throw ctx.Error($"Could not find any commands for \"{filter}\""); + + ctx.SysPaginatedReply(sb); + } + + static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb, string filter = null) + { + var name = assembly.Key.GetName().Name; + name = _trailingLongDashRegex.Replace(name, ""); + + sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline()); + var commands = assembly.Value.Keys.Where(c => CommandRegistry.CanCommandExecute(ctx, c)); + + foreach (var command in commands.OrderBy(c => (c.GroupAttribute != null ? c.GroupAttribute.Name + " " : "") + c.Attribute.Name)) + { + var helpLine = PrintShortHelp(command); + if (filter == null || helpLine.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) + sb.AppendLine(helpLine); + } + } + internal static string PrintShortHelp(CommandMetadata command) { var attr = command.Attribute; diff --git a/VCF.Tests/AssertReplyContext.cs b/VCF.Tests/AssertReplyContext.cs index 72b0e9c..c42f515 100644 --- a/VCF.Tests/AssertReplyContext.cs +++ b/VCF.Tests/AssertReplyContext.cs @@ -32,6 +32,11 @@ public void AssertReplyContains(string expected) var repliedText = RepliedTextLfAndTrimmed(); Assert.That(repliedText.Contains(expected), Is.True, $"Expected {expected} to be contained in replied: {repliedText}"); } + public void AssertReplyDoesntContain(string expected) + { + var repliedText = RepliedTextLfAndTrimmed(); + Assert.That(repliedText.Contains(expected), Is.False, $"Expected {expected} to not be contained in replied: {repliedText}"); + } public void AssertInternalError() { diff --git a/VCF.Tests/HelpTests.cs b/VCF.Tests/HelpTests.cs index dd6d2c6..c0f276d 100644 --- a/VCF.Tests/HelpTests.cs +++ b/VCF.Tests/HelpTests.cs @@ -29,6 +29,13 @@ public void TestHelp(ICommandContext ctx, SomeEnum someEnum, SomeType? someType { } + + + [Command("searchForCommand")] + public void TestSearchForCommand(ICommandContext ctx) + { + + } } [SetUp] @@ -48,14 +55,13 @@ public void HelpCommand_RegisteredByDefault() } [Test] - public void HelpCommand_Help_ListsAll() + public void HelpCommand_Help_ListsAssemblies() { Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" - [vcf] Listing all commands - Commands from VampireCommandFramework: - .help-legacy [search=] - .help [search=] + [vcf] Listing all plugins + Use .help for commands in that plugin + VampireCommandFramework """); } @@ -65,8 +71,9 @@ public void HelpCommand_Help_ListsAssemblyMatch() Assert.That(CommandRegistry.Handle(AnyCtx, ".help VampireCommandFramework"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" [vcf] Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] .help-legacy [search=] - .help [search=] """); } @@ -87,17 +94,61 @@ public void HelpCommand_Help_ListAll_IncludesNewCommands() CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); - Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" [vcf] Listing all commands Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] .help-legacy [search=] - .help [search=] Commands from VCF.Tests: + .searchForCommand .test-help (someEnum) [someType=] """); } + [Test] + public void HelpCommand_Help_ListAll_Filtered() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all help"), Is.EqualTo(CommandResult.Success)); + AnyCtx.AssertReply($""" + [vcf] Listing all commands matching filter 'help' + Commands from VampireCommandFramework: + .help [search=] [filter=] + .help-all [filter=] + .help-legacy [search=] + Commands from VCF.Tests: + .test-help (someEnum) [someType=] + """); + } + + [Test] + public void HelpCommand_Help_ListAll_FilteredNoMatch() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help-all trying"), Is.EqualTo(CommandResult.CommandError)); + } + + [Test] + public void HelpCommand_Help_ListAssemblies_IncludesNewCommands() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + Assert.That(CommandRegistry.Handle(AnyCtx, ".help"), Is.EqualTo(CommandResult.Success)); + AnyCtx.AssertReply($""" + [vcf] Listing all plugins + Use .help for commands in that plugin + VampireCommandFramework + VCF.Tests + """); + } + [Test] public void GenerateHelpText_UsageSpecified() { @@ -164,4 +215,18 @@ public void FullHelp_Usage_Includes_Enum_Values() Assert.That(CommandRegistry.Handle(ctx, ".help test-help"), Is.EqualTo(CommandResult.Success)); ctx.AssertReplyContains("SomeEnum Values: A, B, C"); } + + [Test] + + public void SpecifiedAssemblyAndSearchedForCommand() + { + CommandRegistry.RegisterConverter(typeof(SomeTypeConverter)); + CommandRegistry.RegisterCommandType(typeof(HelpTestCommands)); + + var ctx = new AssertReplyContext(); + Format.Mode = Format.FormatMode.None; + Assert.That(CommandRegistry.Handle(ctx, ".help vcf.tests seaRCH"), Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("searchForCommand"); + ctx.AssertReplyDoesntContain("test-help"); + } } \ No newline at end of file From 064175e827251ec7e0e41cfab611b0f10d7f0566 Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 17 May 2025 14:20:09 -0400 Subject: [PATCH 4/8] When you submit a command but its not quite right up to three of the closest possible command matches are returned. Updating presentation of messages and help. Updating unit tests for the new presentation of messages --- VCF.Core/Basics/BepInExConfigCommands.cs | 4 +- VCF.Core/Basics/HelpCommand.cs | 15 +- VCF.Core/Breadstone/ChatHook.cs | 30 ++-- VCF.Core/Common/Utility.cs | 8 +- VCF.Core/Framework/Color.cs | 21 ++- VCF.Core/Plugin.cs | 2 +- VCF.Core/Registry/CommandRegistry.cs | 186 +++++++++++++++++---- VCF.Tests/CommandArgumentConverterTests.cs | 4 +- VCF.Tests/CommandContextTests.cs | 6 +- VCF.Tests/HelpTests.cs | 2 +- 10 files changed, 209 insertions(+), 69 deletions(-) diff --git a/VCF.Core/Basics/BepInExConfigCommands.cs b/VCF.Core/Basics/BepInExConfigCommands.cs index 409eb3b..a743f50 100644 --- a/VCF.Core/Basics/BepInExConfigCommands.cs +++ b/VCF.Core/Basics/BepInExConfigCommands.cs @@ -1,4 +1,4 @@ -using BepInEx.IL2CPP; +using BepInEx.IL2CPP; using System; using System.Linq; using System.Text; @@ -76,7 +76,7 @@ private static void DumpConfig(ICommandContext ctx, string guid, BasePlugin plug sb.AppendLine($"[{section.Key}]"); foreach (var (def, entry) in section) { - sb.AppendLine($"{def.Key.Color(Color.White)} = {entry.BoxedValue.ToString().Color(Color.LightGrey)}"); + sb.AppendLine($"{def.Key.Color(Color.Beige)} = {entry.BoxedValue.ToString().Color(Color.LightGrey)}"); } } ctx.SysPaginatedReply(sb); diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index 6225209..6ccfceb 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -38,6 +38,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string var individualResults = commands.Where(x => string.Equals(x.Key.Attribute.Id, search, StringComparison.InvariantCultureIgnoreCase) || string.Equals(x.Key.Attribute.Name, search, StringComparison.InvariantCultureIgnoreCase) + || (x.Key.GroupAttribute != null && string.Equals(x.Key.GroupAttribute.Name, search, StringComparison.InvariantCultureIgnoreCase)) || x.Value.Contains(search, StringComparer.InvariantCultureIgnoreCase) ); @@ -47,7 +48,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string if (!individualResults.Any()) { - throw ctx.Error($"Could not find any commands for \"{search}\""); + throw ctx.Error($"Could not find any commands for \"{search.Color(Color.Gold)}\""); } var sb = new StringBuilder(); @@ -63,28 +64,28 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string { var sb = new StringBuilder(); sb.AppendLine($"Listing {B("all")} plugins"); - sb.AppendLine($"Use {B(".help ")} for commands in that plugin"); + sb.AppendLine($"Use {B(".help ").Color(Color.Gold)} for commands in that plugin"); // List all plugins they have a command they can execute for foreach (var assemblyName in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c))) .Select(x => x.Key.GetName().Name) .OrderBy(x => x)) { - sb.AppendLine($"{assemblyName}"); + sb.AppendLine($"{assemblyName.Color(Color.Lilac)}"); } ctx.SysPaginatedReply(sb); } void GenerateFullHelp(CommandMetadata command, List aliases, StringBuilder sb) { - sb.AppendLine($"{B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); + sb.AppendLine($"{B(command.Attribute.Name).Color(Color.LightRed)} {command.Attribute.Description.Color(Color.Grey)}"); sb.AppendLine(GetShortHelp(command)); - sb.AppendLine($"{B("Aliases").Underline()}: {string.Join(", ", aliases).Italic()}"); + sb.AppendLine($"{B("Aliases").Underline().Color(Color.Pink)}: {string.Join(", ", aliases).Italic()}"); // Automatically Display Enum types var enums = command.Parameters.Select(p => p.ParameterType).Distinct().Where(t => t.IsEnum); foreach (var e in enums) { - sb.AppendLine($"{Format.Bold($"{e.Name} Values").Underline()}: {string.Join(", ", Enum.GetNames(e))}"); + sb.AppendLine($"{Format.Bold($"{e.Name} Values").Underline().Color(Color.Pink)}: {string.Join(", ", Enum.GetNames(e)).Color(Color.Command)}"); } // Check CommandRegistry for types that can be converted and further for IConverterUsage @@ -150,7 +151,7 @@ internal static string GetShortHelp(CommandMetadata command) string usageText = GetOrGenerateUsage(command); var prefix = CommandRegistry.DEFAULT_PREFIX.Color(Color.Yellow); - var commandString = fullCommandName.Color(Color.White); + var commandString = fullCommandName.Color(Color.Beige); return $"{prefix}{commandString}{usageText}"; } diff --git a/VCF.Core/Breadstone/ChatHook.cs b/VCF.Core/Breadstone/ChatHook.cs index 377cf11..f0f512f 100644 --- a/VCF.Core/Breadstone/ChatHook.cs +++ b/VCF.Core/Breadstone/ChatHook.cs @@ -4,6 +4,9 @@ using HarmonyLib; using Unity.Collections; using System; +using System.Linq; +using VampireCommandFramework.Common; +using System.Text; namespace VampireCommandFramework.Breadstone; @@ -12,7 +15,7 @@ namespace VampireCommandFramework.Breadstone; [HarmonyPatch(typeof(ChatMessageSystem), nameof(ChatMessageSystem.OnUpdate))] public static class ChatMessageSystem_Patch { - public static bool Prefix(ChatMessageSystem __instance) + public static void Prefix(ChatMessageSystem __instance) { if (__instance.__query_661171423_0 != null) { @@ -25,6 +28,8 @@ public static bool Prefix(ChatMessageSystem __instance) var messageText = chatEventData.MessageText.ToString(); + if (!messageText.StartsWith(".") || messageText.StartsWith("..")) continue; + VChatEvent ev = new VChatEvent(fromData.User, fromData.Character, messageText, chatEventData.MessageType, userData); var ctx = new ChatCommandContext(ev); @@ -35,7 +40,7 @@ public static bool Prefix(ChatMessageSystem __instance) } catch (Exception e) { - Common.Log.Error($"Error while handling chat message {e}"); + Log.Error($"Error while handling chat message {e}"); continue; } @@ -44,18 +49,23 @@ public static bool Prefix(ChatMessageSystem __instance) { chatEventData.MessageText = messageText.Replace("-legacy", string.Empty); __instance.EntityManager.SetComponentData(entity, chatEventData); - return true; + continue; } - - else if (result != CommandResult.Unmatched) + else if (result == CommandResult.Unmatched) { - //__instance.EntityManager.AddComponent(entity); - VWorld.Server.EntityManager.DestroyEntity(entity); - return true; - } + var sb = new StringBuilder(); + + sb.AppendLine($"Command not found: {messageText.Color(Color.Command)}"); + var closeMatches = CommandRegistry.FindCloseMatches(ctx, messageText).ToArray(); + if (closeMatches.Length > 0) + { + sb.AppendLine($"Did you mean: {string.Join(", ", closeMatches.Select(c => c.Color(Color.Command)))}"); + } + ctx.SysReply(sb.ToString()); + } + VWorld.Server.EntityManager.DestroyEntity(entity); } } - return true; } } \ No newline at end of file diff --git a/VCF.Core/Common/Utility.cs b/VCF.Core/Common/Utility.cs index 2bf4ad7..463e283 100644 --- a/VCF.Core/Common/Utility.cs +++ b/VCF.Core/Common/Utility.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -71,7 +71,7 @@ internal static List GetParts(string input) internal static void InternalError(this ICommandContext ctx) => ctx.SysReply("An internal error has occurred."); - internal static void SysReply(this ICommandContext ctx, string input) => ctx.Reply($"[vcf] ".Color(Color.Primary) + input.Color(Color.White)); + internal static void SysReply(this ICommandContext ctx, string input) => ctx.Reply($"[vcf] ".Color(Color.Primary) + input.Color(Color.Beige)); internal static void SysPaginatedReply(this ICommandContext ctx, StringBuilder input) => SysPaginatedReply(ctx, input.ToString()); @@ -102,7 +102,7 @@ internal static string[] SplitIntoPages(string rawText, int pageSize = MAX_MESSA { var pages = new List(); var page = new StringBuilder(); - var rawLines = rawText.Split(Environment.NewLine); // todo: does this work on both platofrms? + var rawLines = rawText.Split("\n"); // todo: does this work on both platofrms? var lines = new List(); // process rawLines -> lines of length <= pageSize @@ -116,7 +116,7 @@ internal static string[] SplitIntoPages(string rawText, int pageSize = MAX_MESSA { // find the last space before the page size within 5% of pageSize buffer var splitIndex = remaining.LastIndexOf(' ', pageSize - (int)(pageSize * 0.05)); - if (splitIndex < 0) + if (splitIndex <= 0) { splitIndex = Math.Min(pageSize - 1, remaining.Length); } diff --git a/VCF.Core/Framework/Color.cs b/VCF.Core/Framework/Color.cs index 65c1bb3..3ade848 100644 --- a/VCF.Core/Framework/Color.cs +++ b/VCF.Core/Framework/Color.cs @@ -6,10 +6,23 @@ public static class Color { public static string Red = "red"; - public static string Primary = "#b0b"; + public static string Primary = "#d25"; public static string White = "#eee"; - public static string LightGrey = "#ccc"; + public static string LightGrey = "#bbf"; public static string Yellow = "#dd0"; - public static string DarkGreen = "#0c0"; - public static string Command = "#40E0D0"; + public static string DarkGreen = "#4c4"; + public static string Command = "#4ED"; + public static string Beige = "#fed"; + public static string Gold = "#eb0"; + public static string Magenta = "#c5d"; + public static string Pink = "#faf"; + public static string Purple = "#52d"; + public static string LightBlue = "#3ac"; + public static string LightRed = "#f45"; + public static string LightPurple = "#84c"; + public static string Lilac = "#b9f"; + public static string LightPink = "#EED"; + public static string Grey = "#eef"; + + } diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index 589f3c8..75780b4 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -1,4 +1,4 @@ -using BepInEx; +using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index 3e798a3..a810a14 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -53,12 +53,124 @@ internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata comm } catch (Exception e) { - Log.Error($"Error executing {middleware.GetType().Name} {e}"); + Log.Error($"Error executing {middleware.GetType().Name.Color(Color.Gold)} {e}"); return false; } } return true; + } + + internal static IEnumerable FindCloseMatches(ICommandContext ctx, string input) + { + // Look for the closest matches to the input command + const int maxResults = 3; + + // Set a more reasonable max distance + const int maxFixedDistance = 3; // For short to medium commands + const double maxRelativeDistance = 0.5; // Max 50% of command length can be different + + // Ensure we have a valid input + if (string.IsNullOrWhiteSpace(input)) + { + return Enumerable.Empty(); + } + + // Remove the prefix if it exists to match command names better + var normalizedInput = input[1..].ToLowerInvariant(); + + var maxDistance = Math.Max(maxFixedDistance, + (int)Math.Ceiling(normalizedInput.Length * maxRelativeDistance)); + + // Get all registered commands for comparison + var allCommands = AssemblyCommandMap.SelectMany(a => a.Value.Keys).ToList(); + + // Calculate edit distances and select the closest matches + var matches = allCommands + .Where(c => CanCommandExecute(ctx, c)) + .SelectMany(cmd => + { + // Get all possible combinations of group and command names + var groupNames = cmd.GroupAttribute == null + ? [""] + : cmd.GroupAttribute.ShortHand == null + ? [cmd.GroupAttribute.Name + " "] + : new[] { cmd.GroupAttribute.Name + " ", cmd.GroupAttribute.ShortHand + " " }; + + var commandNames = cmd.Attribute.ShortHand == null + ? [cmd.Attribute.Name] + : new[] { cmd.Attribute.Name, cmd.Attribute.ShortHand }; + + return groupNames.SelectMany(group => + commandNames.Select(name => new + { + FullName = (group + name).ToLowerInvariant(), + Command = cmd + })); + }) + .Select(cmdInfo => + { + // Calculate the Damerau-Levenshtein distance + var distance = DamerauLevenshteinDistance(normalizedInput, cmdInfo.FullName); + + var maxCmdDistance = Math.Max(maxDistance, (int)Math.Ceiling(normalizedInput.Length * maxRelativeDistance)); + + return new { Command = cmdInfo.FullName, Distance = distance, MaxDistance = maxCmdDistance }; + }) + .Where(x => x.Distance <= x.MaxDistance) // Apply adaptive threshold + .OrderBy(x => x.Distance) + .DistinctBy(x => x.Command) + .Take(maxResults) + .Select(x => "." + x.Command); + + return matches; } + + private static float DamerauLevenshteinDistance(string s, string t) + { + // Handle edge cases + if (string.IsNullOrEmpty(s)) + return string.IsNullOrEmpty(t) ? 0 : t.Length; + if (string.IsNullOrEmpty(t)) + return s.Length; + + // Create distance matrix + float[,] matrix = new float[s.Length + 1, t.Length + 1]; + + // Initialize first row and column + for (int i = 0; i <= s.Length; i++) + matrix[i, 0] = i; + + for (int j = 0; j <= t.Length; j++) + matrix[0, j] = j; + + // Calculate distances + for (int i = 1; i <= s.Length; i++) + { + for (int j = 1; j <= t.Length; j++) + { + int cost = (s[i - 1] == t[j - 1]) ? 0 : 1; + + // Standard Levenshtein operations: deletion, insertion, substitution + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, // Deletion + matrix[i, j - 1] + 1), // Insertion + matrix[i - 1, j - 1] + 1.5f*cost); // Substitution (slightly higher than just missing/extra letters) + + // Add transposition check (swap) + if (i > 1 && j > 1 && + s[i - 1] == t[j - 2] && + s[i - 2] == t[j - 1]) + { + matrix[i, j] = Math.Min(matrix[i, j], + matrix[i - 2, j - 2] + cost); // Transposition + } + } + } + + return matrix[s.Length, t.Length]; + } + + private static void HandleBeforeExecute(ICommandContext ctx, CommandMetadata command) { @@ -164,12 +276,14 @@ public static CommandResult Handle(ICommandContext ctx, string input) // Case 1: No command succeeded if (successfulCommands.Count == 0) { - ctx.Reply($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); + var sb = new StringBuilder(); + sb.AppendLine($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); foreach (var (command, error) in failedCommands) { string assemblyInfo = command.Assembly.GetName().Name; - ctx.Reply($" - {command.Attribute.Id} ({assemblyInfo}): {error}"); + sb.AppendLine($" - {command.Attribute.Name} ({assemblyInfo}): {error}"); } + ctx.SysPaginatedReply(sb); return CommandResult.UsageError; } @@ -183,17 +297,19 @@ public static CommandResult Handle(ICommandContext ctx, string input) // Case 3: Multiple commands succeeded - store and ask user to select _pendingCommands[ctx.Name] = successfulCommands; - var sb = new StringBuilder(); - sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); - for (int i = 0; i < successfulCommands.Count; i++) { - var (command, _, _) = successfulCommands[i]; - var cmdAssembly = command.Assembly.GetName().Name; - var description = command.Attribute.Description; - sb.AppendLine($" {("."+ (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} ({command.Attribute.Id}) {command.Attribute.Description}"); - sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); + var sb = new StringBuilder(); + sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); + for (int i = 0; i < successfulCommands.Count; i++) + { + var (command, _, _) = successfulCommands[i]; + var cmdAssembly = command.Assembly.GetName().Name; + var description = command.Attribute.Description; + sb.AppendLine($" {("." + (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} {command.Attribute.Description}"); + sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); + } + ctx.SysPaginatedReply(sb); } - ctx.SysPaginatedReply(sb); return CommandResult.Success; } @@ -204,13 +320,13 @@ private static CommandResult HandleCommandSelection(ICommandContext ctx, int sel { if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.Count == 0) { - ctx.Reply($"{"[error]".Color(Color.Red)} No command selection is pending."); + ctx.SysReply($"{"[error]".Color(Color.Red)} No command selection is pending."); return CommandResult.CommandError; } if (selectedIndex < 1 || selectedIndex > pendingCommands.Count) { - ctx.Reply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between 1 and {pendingCommands.Count}."); + ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between {"1".Color(Color.Gold)} and {pendingCommands.Count.ToString().Color(Color.Gold)}."); return CommandResult.UsageError; } @@ -238,14 +354,14 @@ private static (bool Success, object[] Args, string Error) TryConvertParameters( // Handle parameter count mismatch if (argCount > paramsCount) { - return (false, null, $"Too many parameters: expected {paramsCount}, got {argCount}"); + return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); } else if (argCount < paramsCount) { var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); if (!canDefault) { - return (false, null, $"Missing required parameters: expected {paramsCount}, got {argCount}"); + return (false, null, $"Missing required parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); } for (var i = argCount; i < paramsCount; i++) { @@ -274,7 +390,7 @@ private static (bool Success, object[] Args, string Error) TryConvertParameters( if (!converterContextType.IsAssignableFrom(ctx.GetType())) { // Signal internal error with a special return format - return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name} is not assignable from {ctx.GetType().Name}"); + return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); } object result; @@ -289,16 +405,16 @@ private static (bool Success, object[] Args, string Error) TryConvertParameters( { if (tie.InnerException is CommandException e) { - conversionError = $"Parameter {i + 1} ({param.Name}): {e.Message}"; + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; } else { - conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error converting parameter"; + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; } } catch (Exception) { - conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error converting parameter"; + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; } } else @@ -320,7 +436,7 @@ private static (bool Success, object[] Args, string Error) TryConvertParameters( if (!isDefined) { - return (false, null, $"Parameter {i + 1} ({param.Name}): Invalid enum value '{arg}' for {param.ParameterType.Name}"); + return (false, null, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); } } } @@ -330,13 +446,13 @@ private static (bool Success, object[] Args, string Error) TryConvertParameters( } catch (Exception e) { - conversionError = $"Parameter {i + 1} ({param.Name}): {e.Message}"; + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; } } } catch (Exception ex) { - conversionError = $"Parameter {i + 1} ({param.Name}): Unexpected error: {ex.Message}"; + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"; } if (!conversionSuccess) @@ -354,7 +470,7 @@ private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata // Handle Context Type not matching command if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) { - Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ContextType.Name} from {ctx?.GetType().Name}"); + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); return CommandResult.InternalError; } @@ -371,7 +487,7 @@ private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata return CommandResult.InternalError; } - ctx.Reply($"{"[error]".Color(Color.Red)} {error}"); + ctx.SysReply($"{"[error]".Color(Color.Red)} {error}"); return CommandResult.UsageError; } @@ -383,14 +499,14 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command // Handle Context Type not matching command if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) { - Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ContextType.Name} from {ctx?.GetType().Name}"); + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); return CommandResult.InternalError; } // Then handle this invocation's context not being valid for the command classes custom constructor if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) { - Log.Warning($"Matched [{command.Attribute.Id}] but can not assign {command.ConstructorType.Name} from {ctx?.GetType().Name}"); + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ConstructorType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); ctx.InternalError(); return CommandResult.InternalError; } @@ -421,7 +537,7 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command // Handle Middlewares if (!CanCommandExecute(ctx, command)) { - ctx.Reply($"{"[denied]".Color(Color.Red)} {command.Attribute.Id}"); + ctx.SysReply($"{"[denied]".Color(Color.Red)} {command.Attribute.Name.ToString().Color(Color.Gold)}"); return CommandResult.Denied; } @@ -434,12 +550,12 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command } catch (TargetInvocationException tie) when (tie.InnerException is CommandException e) { - ctx.Reply($"{"[error]".Color(Color.Red)} {e.Message}"); + ctx.SysReply($"{"[error]".Color(Color.Red)} {e.Message}"); return CommandResult.CommandError; } catch (Exception e) { - Log.Warning($"Hit unexpected exception executing command {command.Attribute.Id}\n: {e}"); + Log.Warning($"Hit unexpected exception executing command {command.Attribute.Id.ToString().Color(Color.Gold)}\n: {e}"); ctx.InternalError(); return CommandResult.InternalError; } @@ -460,7 +576,7 @@ public static void UnregisterConverter(Type converter) var convertFrom = args.FirstOrDefault(); if (convertFrom == null) { - Log.Warning($"Could not resolve converter type {converter.Name}"); + Log.Warning($"Could not resolve converter type {converter.Name.ToString().Color(Color.Gold)}"); return; } @@ -471,7 +587,7 @@ public static void UnregisterConverter(Type converter) } else { - Log.Warning($"Call to UnregisterConverter for a converter that was not registered. Type: {converter.Name}"); + Log.Warning($"Call to UnregisterConverter for a converter that was not registered. Type: {converter.Name.ToString().Color(Color.Gold)}"); } } @@ -573,7 +689,7 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou var first = paramInfos.FirstOrDefault(); if (first == null || first.ParameterType is ICommandContext) { - Log.Error($"Method {method.Name} has no CommandContext as first argument"); + Log.Error($"Method {method.Name.ToString().Color(Color.Gold)} has no CommandContext as first argument"); return; } @@ -583,7 +699,7 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou { if (_converters.ContainsKey(param.ParameterType)) { - Log.Debug($"Method {method.Name} has a parameter of type {param.ParameterType.Name} which is registered as a converter"); + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a parameter of type {param.ParameterType.Name.ToString().Color(Color.Gold)} which is registered as a converter"); return true; } @@ -591,7 +707,7 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou if (converter == null || !converter.CanConvertFrom(typeof(string))) { - Log.Warning($"Parameter {param.Name} could not be converted, so {method.Name} will be ignored."); + Log.Warning($"Parameter {param.Name.ToString().Color(Color.Gold)} could not be converted, so {method.Name.ToString().Color(Color.Gold)} will be ignored."); return false; } diff --git a/VCF.Tests/CommandArgumentConverterTests.cs b/VCF.Tests/CommandArgumentConverterTests.cs index 6378c5f..32f179f 100644 --- a/VCF.Tests/CommandArgumentConverterTests.cs +++ b/VCF.Tests/CommandArgumentConverterTests.cs @@ -1,4 +1,4 @@ -using FakeItEasy; +using FakeItEasy; using NUnit.Framework; using VampireCommandFramework; @@ -38,7 +38,7 @@ internal class SecondaryContext : ICommandContext { public IServiceProvider Services => throw new NotImplementedException(); - public string Name => throw new NotImplementedException(); + public string Name => "TestName"; public bool IsAdmin => true; diff --git a/VCF.Tests/CommandContextTests.cs b/VCF.Tests/CommandContextTests.cs index f4fb7c8..23d05bd 100644 --- a/VCF.Tests/CommandContextTests.cs +++ b/VCF.Tests/CommandContextTests.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using VampireCommandFramework; using VampireCommandFramework.Common; @@ -121,7 +121,7 @@ public class GoodContext : ICommandContext { public IServiceProvider Services => throw new NotImplementedException(); - public string Name => throw new NotImplementedException(); + public string Name => "GoodName"; public bool IsAdmin { get; set; } = true; @@ -138,7 +138,7 @@ public class BadContext : ICommandContext { public IServiceProvider Services => throw new NotImplementedException(); - public string Name => throw new NotImplementedException(); + public string Name => "Bad Name"; public bool IsAdmin { get; set; } = true; diff --git a/VCF.Tests/HelpTests.cs b/VCF.Tests/HelpTests.cs index 1d81745..9da6e0a 100644 --- a/VCF.Tests/HelpTests.cs +++ b/VCF.Tests/HelpTests.cs @@ -82,7 +82,7 @@ public void HelpCommand_Help_ShowSpecificCommand() { Assert.That(CommandRegistry.Handle(AnyCtx, ".help help-legacy"), Is.EqualTo(CommandResult.Success)); AnyCtx.AssertReply($""" - [vcf] help-legacy (help-legacy) Passes through a .help command that is compatible with other mods that don't use VCF. + [vcf] help-legacy Passes through a .help command that is compatible with other mods that don't use VCF. .help-legacy [search=] Aliases: .help-legacy """); From 080d649e10a298a7c9340448ba43be268d17ebe1 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 27 May 2025 13:22:23 -0400 Subject: [PATCH 5/8] Command History and Recall Feature (#33) * When you submit a command but its not quite right up to three of the closest possible command matches are returned. Updating presentation of messages and help. Updating unit tests for the new presentation of messages * Adds the ability to repeat any of the last 10 commands. Can use .! to repeat the previous one, .! [#] to repeat the # back command, and .! list to list out the last 10 command history for the user. * Unit tests added for repeat commands --- VCF.Core/Basics/RepeatCommands.cs | 29 + VCF.Core/Plugin.cs | 1 + VCF.Core/Registry/CommandRegistry.cs | 1387 ++++++++++++++------------ VCF.Tests/RepeatCommandsTests.cs | 245 +++++ 4 files changed, 1008 insertions(+), 654 deletions(-) create mode 100644 VCF.Core/Basics/RepeatCommands.cs create mode 100644 VCF.Tests/RepeatCommandsTests.cs diff --git a/VCF.Core/Basics/RepeatCommands.cs b/VCF.Core/Basics/RepeatCommands.cs new file mode 100644 index 0000000..29cc46c --- /dev/null +++ b/VCF.Core/Basics/RepeatCommands.cs @@ -0,0 +1,29 @@ +using VampireCommandFramework; + +namespace VampireCommandFramework.Basics +{ + public static class RepeatCommands + { + [Command("!", description: "Repeats the most recently executed command")] + public static void RepeatLastCommand(ICommandContext ctx) + { + // This is just a placeholder for the help system + // The actual implementation is in CommandRegistry.HandleCommandHistory + ctx.Error("This command is only a placeholder for the help system."); + } + + [Command("! list", shortHand: "! l", description: "Lists up to the last 10 commands you used.")] + public static void ListCommandHistory(ICommandContext ctx) + { + // This is just a placeholder for the help system + // The actual implementation is in CommandRegistry.HandleCommandHistory + ctx.Error("This command is only a placeholder for the help system."); + } + + [Command("!", description: "Executes a specific command from your history by its number")] + public static void ExecuteHistoryCommand(ICommandContext ctx, int previousXCommand) + { + ctx.Error("This command is only a placeholder for the help system."); + } + } +} diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index 75780b4..de3536f 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -25,6 +25,7 @@ public override void Load() CommandRegistry.RegisterCommandType(typeof(Basics.HelpCommands)); CommandRegistry.RegisterCommandType(typeof(Basics.BepInExConfigCommands)); + CommandRegistry.RegisterCommandType(typeof(Basics.RepeatCommands)); IL2CPPChainloader.Instance.Plugins.TryGetValue(PluginInfo.PLUGIN_GUID, out var info); diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index a810a14..d79fce2 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -1,63 +1,66 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reflection; -using System.Text; -using VampireCommandFramework.Basics; -using VampireCommandFramework.Common; -using VampireCommandFramework.Registry; - -using static VampireCommandFramework.Format; - -namespace VampireCommandFramework; - -public static class CommandRegistry -{ - internal const string DEFAULT_PREFIX = "."; - internal static CommandCache _cache = new(); - /// - /// From converting type to (object instance, MethodInfo tryParse, Type contextType) - /// - internal static Dictionary _converters = []; - - internal static void Reset() - { - // testability and a bunch of static crap, I know... - Middlewares.Clear(); - Middlewares.AddRange(DEFAULT_MIDDLEWARES); - AssemblyCommandMap.Clear(); - _converters.Clear(); - _cache = new(); - } - - // todo: document this default behavior, it's just not something to ship without but you can Middlewares.Claer(); - private static List DEFAULT_MIDDLEWARES = new() { new VCF.Core.Basics.BasicAdminCheck() }; - public static List Middlewares { get; } = new() { new VCF.Core.Basics.BasicAdminCheck() }; - - // Store pending commands for selection - private static Dictionary> _pendingCommands = []; - - internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata command) - { - // Log.Debug($"Executing {Middlewares.Count} CanHandle Middlwares:"); - foreach (var middleware in Middlewares) - { - // Log.Debug($"\t{middleware.GetType().Name}"); - try - { - if (!middleware.CanExecute(ctx, command.Attribute, command.Method)) - { - return false; - } - } - catch (Exception e) - { - Log.Error($"Error executing {middleware.GetType().Name.Color(Color.Gold)} {e}"); - return false; - } - } - return true; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text; +using VampireCommandFramework.Basics; +using VampireCommandFramework.Common; +using VampireCommandFramework.Registry; + +using static VampireCommandFramework.Format; + +namespace VampireCommandFramework; + +public static class CommandRegistry +{ + internal const string DEFAULT_PREFIX = "."; + internal static CommandCache _cache = new(); + /// + /// From converting type to (object instance, MethodInfo tryParse, Type contextType) + /// + internal static Dictionary _converters = []; + + internal static void Reset() + { + // testability and a bunch of static crap, I know... + Middlewares.Clear(); + Middlewares.AddRange(DEFAULT_MIDDLEWARES); + AssemblyCommandMap.Clear(); + _converters.Clear(); + _cache = new(); + } + + // todo: document this default behavior, it's just not something to ship without but you can Middlewares.Claer(); + private static List DEFAULT_MIDDLEWARES = new() { new VCF.Core.Basics.BasicAdminCheck() }; + public static List Middlewares { get; } = new() { new VCF.Core.Basics.BasicAdminCheck() }; + + // Store pending commands for selection + private static Dictionary commands)> _pendingCommands = []; + + private static Dictionary> _commandHistory = new(); + private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands + + internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata command) + { + // Log.Debug($"Executing {Middlewares.Count} CanHandle Middlwares:"); + foreach (var middleware in Middlewares) + { + // Log.Debug($"\t{middleware.GetType().Name}"); + try + { + if (!middleware.CanExecute(ctx, command.Attribute, command.Method)) + { + return false; + } + } + catch (Exception e) + { + Log.Error($"Error executing {middleware.GetType().Name.Color(Color.Gold)} {e}"); + return false; + } + } + return true; } internal static IEnumerable FindCloseMatches(ICommandContext ctx, string input) @@ -123,7 +126,7 @@ internal static IEnumerable FindCloseMatches(ICommandContext ctx, string .Select(x => "." + x.Command); return matches; - } + } private static float DamerauLevenshteinDistance(string s, string t) { @@ -168,597 +171,673 @@ private static float DamerauLevenshteinDistance(string s, string t) } return matrix[s.Length, t.Length]; - } - - - - private static void HandleBeforeExecute(ICommandContext ctx, CommandMetadata command) - { - Middlewares.ForEach(m => m.BeforeExecute(ctx, command.Attribute, command.Method)); - } - - private static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) - { - Middlewares.ForEach(m => m.AfterExecute(ctx, command.Attribute, command.Method)); - } - - public static CommandResult Handle(ICommandContext ctx, string input) - { - // Check if this is a command selection (e.g., .1, .2, etc.) - if (input.StartsWith(DEFAULT_PREFIX) && input.Length > 1) - { - string numberPart = input.Substring(1); - if (int.TryParse(numberPart, out int selectedIndex) && selectedIndex > 0) - { - return HandleCommandSelection(ctx, selectedIndex); - } - } - - // Ensure the command starts with the prefix - if (!input.StartsWith(DEFAULT_PREFIX)) - { - return CommandResult.Unmatched; // Not a command - } - - // Remove the prefix for processing - string afterPrefix = input.Substring(DEFAULT_PREFIX.Length); - - // Check if this could be an assembly-specific command - string assemblyName = null; - string commandInput = input; // Default to using the entire input - - int spaceIndex = afterPrefix.IndexOf(' '); - if (spaceIndex > 0) - { - string potentialAssemblyName = afterPrefix.Substring(0, spaceIndex); - - // Check if this could be a valid assembly name - bool isValidAssembly = AssemblyCommandMap.Keys.Any(a => - a.GetName().Name.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } - - // Get command(s) based on input - CacheResult matchedCommand; - if (assemblyName != null) - { - matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); - } - else - { - matchedCommand = _cache.GetCommand(input); - } - - var (commands, args) = (matchedCommand.Commands, matchedCommand.Args); - - if (!matchedCommand.IsMatched) - { - if (!matchedCommand.HasPartial) return CommandResult.Unmatched; // NOT FOUND - - foreach (var possible in matchedCommand.PartialMatches) - { - ctx.SysReply(HelpCommands.GetShortHelp(possible)); - } - - return CommandResult.UsageError; - } - - // If there's only one command, handle it directly - if (commands.Count() == 1) - { - return ExecuteCommand(ctx, commands.First(), args); - } - - // Multiple commands match, try to convert parameters for each - var successfulCommands = new List<(CommandMetadata Command, object[] Args, string Error)>(); - var failedCommands = new List<(CommandMetadata Command, string Error)>(); - - foreach (var command in commands) - { - if (!CanCommandExecute(ctx, command)) continue; - - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); - if (success) - { - successfulCommands.Add((command, commandArgs, null)); - } - else - { - failedCommands.Add((command, error)); - } - } - - // Case 1: No command succeeded - if (successfulCommands.Count == 0) - { - var sb = new StringBuilder(); - sb.AppendLine($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); - foreach (var (command, error) in failedCommands) - { - string assemblyInfo = command.Assembly.GetName().Name; - sb.AppendLine($" - {command.Attribute.Name} ({assemblyInfo}): {error}"); - } - ctx.SysPaginatedReply(sb); - return CommandResult.UsageError; - } - - // Case 2: Only one command succeeded - if (successfulCommands.Count == 1) - { - var (command, commandArgs, _) = successfulCommands[0]; - return ExecuteCommandWithArgs(ctx, command, commandArgs); - } - - // Case 3: Multiple commands succeeded - store and ask user to select - _pendingCommands[ctx.Name] = successfulCommands; - - { - var sb = new StringBuilder(); - sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); - for (int i = 0; i < successfulCommands.Count; i++) - { - var (command, _, _) = successfulCommands[i]; - var cmdAssembly = command.Assembly.GetName().Name; - var description = command.Attribute.Description; - sb.AppendLine($" {("." + (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} {command.Attribute.Description}"); - sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); - } + } + + + + private static void HandleBeforeExecute(ICommandContext ctx, CommandMetadata command) + { + Middlewares.ForEach(m => m.BeforeExecute(ctx, command.Attribute, command.Method)); + } + + private static void HandleAfterExecute(ICommandContext ctx, CommandMetadata command) + { + Middlewares.ForEach(m => m.AfterExecute(ctx, command.Attribute, command.Method)); + } + + public static CommandResult Handle(ICommandContext ctx, string input) + { + // Check if this is a command selection (e.g., .1, .2, etc.) + if (input.StartsWith(DEFAULT_PREFIX) && input.Length > 1) + { + string numberPart = input.Substring(1); + if (int.TryParse(numberPart, out int selectedIndex) && selectedIndex > 0) + { + return HandleCommandSelection(ctx, selectedIndex); + } + } + + // Ensure the command starts with the prefix + if (!input.StartsWith(DEFAULT_PREFIX)) + { + return CommandResult.Unmatched; // Not a command + } + + if (input.Trim().StartsWith(".!")) + { + HandleCommandHistory(ctx, input.Trim()); + return CommandResult.Success; + } + + // Remove the prefix for processing + string afterPrefix = input.Substring(DEFAULT_PREFIX.Length); + + // Check if this could be an assembly-specific command + string assemblyName = null; + string commandInput = input; // Default to using the entire input + + int spaceIndex = afterPrefix.IndexOf(' '); + if (spaceIndex > 0) + { + string potentialAssemblyName = afterPrefix.Substring(0, spaceIndex); + + // Check if this could be a valid assembly name + bool isValidAssembly = AssemblyCommandMap.Keys.Any(a => + a.GetName().Name.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + + if (isValidAssembly) + { + assemblyName = potentialAssemblyName; + commandInput = "." + afterPrefix.Substring(spaceIndex + 1); + } + } + + // Get command(s) based on input + CacheResult matchedCommand; + if (assemblyName != null) + { + matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); + } + else + { + matchedCommand = _cache.GetCommand(input); + } + + var (commands, args) = (matchedCommand.Commands, matchedCommand.Args); + + if (!matchedCommand.IsMatched) + { + if (!matchedCommand.HasPartial) return CommandResult.Unmatched; // NOT FOUND + + foreach (var possible in matchedCommand.PartialMatches) + { + ctx.SysReply(HelpCommands.GetShortHelp(possible)); + } + + return CommandResult.UsageError; + } + + // If there's only one command, handle it directly + if (commands.Count() == 1) + { + return ExecuteCommand(ctx, commands.First(), args, input); + } + + // Multiple commands match, try to convert parameters for each + var successfulCommands = new List<(CommandMetadata Command, object[] Args, string Error)>(); + var failedCommands = new List<(CommandMetadata Command, string Error)>(); + + foreach (var command in commands) + { + if (!CanCommandExecute(ctx, command)) continue; + + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + if (success) + { + successfulCommands.Add((command, commandArgs, null)); + } + else + { + failedCommands.Add((command, error)); + } + } + + // Case 1: No command succeeded + if (successfulCommands.Count == 0) + { + var sb = new StringBuilder(); + sb.AppendLine($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); + foreach (var (command, error) in failedCommands) + { + string assemblyInfo = command.Assembly.GetName().Name; + sb.AppendLine($" - {command.Attribute.Name} ({assemblyInfo}): {error}"); + } ctx.SysPaginatedReply(sb); - } - - return CommandResult.Success; - } - - // Add these helper methods: - - private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) - { - if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.Count == 0) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} No command selection is pending."); - return CommandResult.CommandError; - } - - if (selectedIndex < 1 || selectedIndex > pendingCommands.Count) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between {"1".Color(Color.Gold)} and {pendingCommands.Count.ToString().Color(Color.Gold)}."); - return CommandResult.UsageError; - } - - var (command, args, _) = pendingCommands[selectedIndex - 1]; - - // Clear pending commands after selection - var result = ExecuteCommandWithArgs(ctx, command, args); - pendingCommands.Clear(); - return result; - } - - private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args) - { - var argCount = args?.Length ?? 0; - var paramsCount = command.Parameters.Length; - var commandArgs = new object[paramsCount + 1]; - commandArgs[0] = ctx; - - // Special case for commands with no parameters - if (paramsCount == 0 && argCount == 0) - { - return (true, commandArgs, null); - } - - // Handle parameter count mismatch - if (argCount > paramsCount) - { - return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - else if (argCount < paramsCount) - { - var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); - if (!canDefault) - { - return (false, null, $"Missing required parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - for (var i = argCount; i < paramsCount; i++) - { - commandArgs[i + 1] = command.Parameters[i].DefaultValue; - } - } - - // If we have arguments to convert, process them - if (argCount > 0) - { - for (var i = 0; i < argCount; i++) - { - var param = command.Parameters[i]; - var arg = args[i]; - bool conversionSuccess = false; - string conversionError = null; - - try - { - // Custom Converter - if (_converters.TryGetValue(param.ParameterType, out var customConverter)) - { - var (converter, convertMethod, converterContextType) = customConverter; - - // IMPORTANT CHANGE: Return special error code for unassignable context - if (!converterContextType.IsAssignableFrom(ctx.GetType())) - { - // Signal internal error with a special return format - return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); - } - - object result; - var tryParseArgs = new object[] { ctx, arg }; - try - { - result = convertMethod.Invoke(converter, tryParseArgs); - commandArgs[i + 1] = result; - conversionSuccess = true; - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException e) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; - } - else - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; - } - } - catch (Exception) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; - } - } - else - { - var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); - try - { - var val = defaultConverter.ConvertFromInvariantString(arg); - - // Separate, more robust enum validation - if (param.ParameterType.IsEnum) - { - bool isDefined = false; - - // For numeric input, we need to check if the value is defined - if (int.TryParse(arg, out int enumIntVal)) - { - isDefined = Enum.IsDefined(param.ParameterType, enumIntVal); - - if (!isDefined) - { - return (false, null, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); - } - } - } - - commandArgs[i + 1] = val; - conversionSuccess = true; - } - catch (Exception e) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; - } - } - } - catch (Exception ex) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"; - } - - if (!conversionSuccess) - { - return (false, null, conversionError); - } - } - } - - return (true, commandArgs, null); - } - - private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata command, string[] args) - { - // Handle Context Type not matching command - if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - return CommandResult.InternalError; - } - - // Try to convert parameters - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); - if (!success) - { - // Check for special internal error flag - if (error != null && error.StartsWith("INTERNAL_ERROR:")) - { - string actualError = error.Substring("INTERNAL_ERROR:".Length); - Log.Warning(actualError); - ctx.InternalError(); - return CommandResult.InternalError; - } - - ctx.SysReply($"{"[error]".Color(Color.Red)} {error}"); - return CommandResult.UsageError; - } - - return ExecuteCommandWithArgs(ctx, command, commandArgs); - } - - private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, CommandMetadata command, object[] commandArgs) - { - // Handle Context Type not matching command - if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - return CommandResult.InternalError; - } - - // Then handle this invocation's context not being valid for the command classes custom constructor - if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ConstructorType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - ctx.InternalError(); - return CommandResult.InternalError; - } - - object instance = null; - // construct command's type with context if declared only in a non-static class and on a non-static method - if (!command.Method.IsStatic && !(command.Method.DeclaringType.IsAbstract && command.Method.DeclaringType.IsSealed)) - { - try - { - instance = command.Constructor == null ? Activator.CreateInstance(command.Method.DeclaringType) : command.Constructor.Invoke(new[] { ctx }); - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException ce) - { - ctx.SysReply(ce.Message); - } - else - { - ctx.InternalError(); - } - - return CommandResult.InternalError; - } - } - - // Handle Middlewares - if (!CanCommandExecute(ctx, command)) - { - ctx.SysReply($"{"[denied]".Color(Color.Red)} {command.Attribute.Name.ToString().Color(Color.Gold)}"); - return CommandResult.Denied; - } - - HandleBeforeExecute(ctx, command); - - // Execute Command - try - { - command.Method.Invoke(instance, commandArgs); - } - catch (TargetInvocationException tie) when (tie.InnerException is CommandException e) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} {e.Message}"); - return CommandResult.CommandError; - } - catch (Exception e) - { - Log.Warning($"Hit unexpected exception executing command {command.Attribute.Id.ToString().Color(Color.Gold)}\n: {e}"); - ctx.InternalError(); - return CommandResult.InternalError; - } - - HandleAfterExecute(ctx, command); - - return CommandResult.Success; - } - - public static void UnregisterConverter(Type converter) - { - if (!IsGenericConverterContext(converter) && !IsSpecificConverterContext(converter)) - { - return; - } - - var args = converter.BaseType.GenericTypeArguments; - var convertFrom = args.FirstOrDefault(); - if (convertFrom == null) - { - Log.Warning($"Could not resolve converter type {converter.Name.ToString().Color(Color.Gold)}"); - return; - } - - if (_converters.ContainsKey(convertFrom)) - { - _converters.Remove(convertFrom); - Log.Info($"Unregistered converter {converter.Name}"); - } - else - { - Log.Warning($"Call to UnregisterConverter for a converter that was not registered. Type: {converter.Name.ToString().Color(Color.Gold)}"); - } - } - - internal static bool IsGenericConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<>).Name; - internal static bool IsSpecificConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<,>).Name; - - public static void RegisterConverter(Type converter) - { - // check base type - var isGenericContext = IsGenericConverterContext(converter); - var isSpecificContext = IsSpecificConverterContext(converter); - - if (!isGenericContext && !isSpecificContext) - { - return; - } - - Log.Debug($"Trying to process {converter} as specifc={isSpecificContext} generic={isGenericContext}"); - - object converterInstance = Activator.CreateInstance(converter); - MethodInfo methodInfo = converter.GetMethod("Parse", BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); - if (methodInfo == null) - { - // can't bud - Log.Error("Can't find TryParse that matches"); - return; - } - - var args = converter.BaseType.GenericTypeArguments; - var convertFrom = args.FirstOrDefault(); - if (convertFrom == null) - { - Log.Error("Can't determine generic base type to convert from. "); - return; - } - - Type contextType = typeof(ICommandContext); - if (isSpecificContext) - { - if (args.Length != 2 || !typeof(ICommandContext).IsAssignableFrom(args[1])) - { - Log.Error("Can't determine generic base type to convert from."); - return; - } - - contextType = args[1]; - } - - - _converters.Add(convertFrom, (converterInstance, methodInfo, contextType)); - } - - public static void RegisterAll() => RegisterAll(Assembly.GetCallingAssembly()); - - public static void RegisterAll(Assembly assembly) - { - var types = assembly.GetTypes(); - - // Register Converters first as typically commands will depend on them. - foreach (var type in types) - { - RegisterConverter(type); - } - - foreach (var type in types) - { - RegisterCommandType(type); - } - } - - public static void RegisterCommandType(Type type) - { - var groupAttr = type.GetCustomAttribute(); - var assembly = type.Assembly; - if (groupAttr != null) - { - // handle groups - IDK later - } - - var methods = type.GetMethods(); - - ConstructorInfo contextConstructor = type.GetConstructors() - .Where(c => c.GetParameters().Length == 1 && typeof(ICommandContext).IsAssignableFrom(c.GetParameters().SingleOrDefault()?.ParameterType)) - .FirstOrDefault(); - - foreach (var method in methods) - { - RegisterMethod(assembly, groupAttr, contextConstructor, method); - } - } - - private static void RegisterMethod(Assembly assembly, CommandGroupAttribute groupAttr, ConstructorInfo customConstructor, MethodInfo method) - { - var commandAttr = method.GetCustomAttribute(); - if (commandAttr == null) return; - - // check for CommandContext as first argument to method - var paramInfos = method.GetParameters(); - var first = paramInfos.FirstOrDefault(); - if (first == null || first.ParameterType is ICommandContext) - { - Log.Error($"Method {method.Name.ToString().Color(Color.Gold)} has no CommandContext as first argument"); - return; - } - - var parameters = paramInfos.Skip(1).ToArray(); - - var canConvert = parameters.All(param => - { - if (_converters.ContainsKey(param.ParameterType)) - { - Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a parameter of type {param.ParameterType.Name.ToString().Color(Color.Gold)} which is registered as a converter"); - return true; - } - - var converter = TypeDescriptor.GetConverter(param.ParameterType); - if (converter == null || - !converter.CanConvertFrom(typeof(string))) - { - Log.Warning($"Parameter {param.Name.ToString().Color(Color.Gold)} could not be converted, so {method.Name.ToString().Color(Color.Gold)} will be ignored."); - return false; - } - - return true; - }); - - if (!canConvert) return; - - var constructorType = customConstructor?.GetParameters().Single().ParameterType; - - var command = new CommandMetadata(commandAttr, assembly, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); - - // todo include prefix and group in here, this shoudl be a string match - // todo handle collisons here - - // BAD CODE INC.. permute and cache keys -> command - var groupNames = groupAttr == null ? new[] { "" } : groupAttr.ShortHand == null ? new[] { $"{groupAttr.Name} " } : new[] { $"{groupAttr.Name} ", $"{groupAttr.ShortHand} ", }; - var names = commandAttr.ShortHand == null ? new[] { commandAttr.Name } : new[] { commandAttr.Name, commandAttr.ShortHand }; - var prefix = DEFAULT_PREFIX; // TODO: get from attribute/config - List keys = new(); - foreach (var group in groupNames) - { - foreach (var name in names) - { - var key = $"{prefix}{group}{name}"; - _cache.AddCommand(key, parameters, command); - keys.Add(key); - } - } - - AssemblyCommandMap.TryGetValue(assembly, out var commandKeyCache); - commandKeyCache ??= new(); - commandKeyCache[command] = keys; - AssemblyCommandMap[assembly] = commandKeyCache; - } - - internal static Dictionary>> AssemblyCommandMap { get; } = new(); - - public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); - - public static void UnregisterAssembly(Assembly assembly) - { - foreach (var type in assembly.DefinedTypes) - { - _cache.RemoveCommandsFromType(type); - UnregisterConverter(type); - // TODO: There's a lot of nasty cases involving cross mod converters that need testing - // as of right now the guidance should be to avoid depending on converters from a different mod - // especially if you're hot reloading either. - } - - AssemblyCommandMap.Remove(assembly); - } -} + return CommandResult.UsageError; + } + + // Case 2: Only one command succeeded + if (successfulCommands.Count == 1) + { + var (command, commandArgs, _) = successfulCommands[0]; + AddToCommandHistory(ctx.Name, input, command, commandArgs); + return ExecuteCommandWithArgs(ctx, command, commandArgs); + } + + // Case 3: Multiple commands succeeded - store and ask user to select + _pendingCommands[ctx.Name] = (input, successfulCommands); + + { + var sb = new StringBuilder(); + sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); + for (int i = 0; i < successfulCommands.Count; i++) + { + var (command, _, _) = successfulCommands[i]; + var cmdAssembly = command.Assembly.GetName().Name; + var description = command.Attribute.Description; + sb.AppendLine($" {("." + (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} {command.Attribute.Description}"); + sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); + } + ctx.SysPaginatedReply(sb); + } + + return CommandResult.Success; + } + + // Add these helper methods: + + private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) + { + if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.commands.Count == 0) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} No command selection is pending."); + return CommandResult.CommandError; + } + + if (selectedIndex < 1 || selectedIndex > pendingCommands.commands.Count) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between {"1".Color(Color.Gold)} and {pendingCommands.commands.Count.ToString().Color(Color.Gold)}."); + return CommandResult.UsageError; + } + + var (command, args, _) = pendingCommands.commands[selectedIndex - 1]; + + AddToCommandHistory(ctx.Name, pendingCommands.input, command, args); + var result = ExecuteCommandWithArgs(ctx, command, args); + _pendingCommands.Remove(ctx.Name); + return result; + } + + private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args) + { + var argCount = args?.Length ?? 0; + var paramsCount = command.Parameters.Length; + var commandArgs = new object[paramsCount + 1]; + commandArgs[0] = ctx; + + // Special case for commands with no parameters + if (paramsCount == 0 && argCount == 0) + { + return (true, commandArgs, null); + } + + // Handle parameter count mismatch + if (argCount > paramsCount) + { + return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); + } + else if (argCount < paramsCount) + { + var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); + if (!canDefault) + { + return (false, null, $"Missing required parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); + } + for (var i = argCount; i < paramsCount; i++) + { + commandArgs[i + 1] = command.Parameters[i].DefaultValue; + } + } + + // If we have arguments to convert, process them + if (argCount > 0) + { + for (var i = 0; i < argCount; i++) + { + var param = command.Parameters[i]; + var arg = args[i]; + bool conversionSuccess = false; + string conversionError = null; + + try + { + // Custom Converter + if (_converters.TryGetValue(param.ParameterType, out var customConverter)) + { + var (converter, convertMethod, converterContextType) = customConverter; + + // IMPORTANT CHANGE: Return special error code for unassignable context + if (!converterContextType.IsAssignableFrom(ctx.GetType())) + { + // Signal internal error with a special return format + return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); + } + + object result; + var tryParseArgs = new object[] { ctx, arg }; + try + { + result = convertMethod.Invoke(converter, tryParseArgs); + commandArgs[i + 1] = result; + conversionSuccess = true; + } + catch (TargetInvocationException tie) + { + if (tie.InnerException is CommandException e) + { + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; + } + else + { + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; + } + } + catch (Exception) + { + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; + } + } + else + { + var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); + try + { + var val = defaultConverter.ConvertFromInvariantString(arg); + + // Separate, more robust enum validation + if (param.ParameterType.IsEnum) + { + bool isDefined = false; + + // For numeric input, we need to check if the value is defined + if (int.TryParse(arg, out int enumIntVal)) + { + isDefined = Enum.IsDefined(param.ParameterType, enumIntVal); + + if (!isDefined) + { + return (false, null, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); + } + } + } + + commandArgs[i + 1] = val; + conversionSuccess = true; + } + catch (Exception e) + { + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; + } + } + } + catch (Exception ex) + { + conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"; + } + + if (!conversionSuccess) + { + return (false, null, conversionError); + } + } + } + + return (true, commandArgs, null); + } + + private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata command, string[] args, string input) + { + // Handle Context Type not matching command + if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); + return CommandResult.InternalError; + } + + // Try to convert parameters + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + if (!success) + { + // Check for special internal error flag + if (error != null && error.StartsWith("INTERNAL_ERROR:")) + { + string actualError = error.Substring("INTERNAL_ERROR:".Length); + Log.Warning(actualError); + ctx.InternalError(); + return CommandResult.InternalError; + } + + ctx.SysReply($"{"[error]".Color(Color.Red)} {error}"); + return CommandResult.UsageError; + } + + AddToCommandHistory(ctx.Name, input, command, commandArgs); + return ExecuteCommandWithArgs(ctx, command, commandArgs); + } + + private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, CommandMetadata command, object[] commandArgs) + { + // Handle Context Type not matching command + if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); + return CommandResult.InternalError; + } + + // Then handle this invocation's context not being valid for the command classes custom constructor + if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) + { + Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ConstructorType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); + ctx.InternalError(); + return CommandResult.InternalError; + } + + object instance = null; + // construct command's type with context if declared only in a non-static class and on a non-static method + if (!command.Method.IsStatic && !(command.Method.DeclaringType.IsAbstract && command.Method.DeclaringType.IsSealed)) + { + try + { + instance = command.Constructor == null ? Activator.CreateInstance(command.Method.DeclaringType) : command.Constructor.Invoke(new[] { ctx }); + } + catch (TargetInvocationException tie) + { + if (tie.InnerException is CommandException ce) + { + ctx.SysReply(ce.Message); + } + else + { + ctx.InternalError(); + } + + return CommandResult.InternalError; + } + } + + // Handle Middlewares + if (!CanCommandExecute(ctx, command)) + { + ctx.SysReply($"{"[denied]".Color(Color.Red)} {command.Attribute.Name.ToString().Color(Color.Gold)}"); + return CommandResult.Denied; + } + + HandleBeforeExecute(ctx, command); + + // Execute Command + try + { + command.Method.Invoke(instance, commandArgs); + } + catch (TargetInvocationException tie) when (tie.InnerException is CommandException e) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} {e.Message}"); + return CommandResult.CommandError; + } + catch (Exception e) + { + Log.Warning($"Hit unexpected exception executing command {command.Attribute.Id.ToString().Color(Color.Gold)}\n: {e}"); + ctx.InternalError(); + return CommandResult.InternalError; + } + + HandleAfterExecute(ctx, command); + + return CommandResult.Success; + } + + private static void AddToCommandHistory(string contextName, string input, CommandMetadata command, object[] args) + { + // Create the history list for this context if it doesn't exist yet + if (!_commandHistory.TryGetValue(contextName, out var history)) + { + history = new List<(string input, CommandMetadata Command, object[] Args)>(); + _commandHistory[contextName] = history; + } + + // Add the new command to the beginning of the list + history.Insert(0, (input, command, args)); + + // Keep only the most recent MAX_COMMAND_HISTORY commands + if (history.Count > MAX_COMMAND_HISTORY) + { + history.RemoveAt(history.Count - 1); + } + } + + private static void HandleCommandHistory(ICommandContext ctx, string input) + { + // Remove the ".!" prefix + string command = input.Substring(2).Trim(); + + // Check if the command history exists for this context + if (!_commandHistory.TryGetValue(ctx.Name, out var history) || history.Count == 0) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} No command history available."); + return; + } + + // Handle .! list or .! l commands + if (command == "list" || command == "l") + { + var sb = new StringBuilder(); + sb.AppendLine("Command history:"); + + for (int i = 0; i < history.Count; i++) + { + sb.AppendLine($"{(i + 1).ToString().Color(Color.Gold)}. {history[i].input.Color(Color.Command)}"); + } + + ctx.SysPaginatedReply(sb); + return; + } + + // Handle .! # to execute a specific command by number + if (int.TryParse(command, out int index) && index > 0 && index <= history.Count) + { + var selectedCommand = history[index - 1]; + ctx.SysReply($"Executing command {index.ToString().Color(Color.Gold)}: {selectedCommand.input.Color(Color.Command)}"); + ExecuteCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); + return; + } + + // If just .! is provided, execute the most recent command + if (string.IsNullOrWhiteSpace(command)) + { + var mostRecent = history[0]; + ctx.SysReply($"Repeating most recent command: {mostRecent.input.Color(Color.Command)}"); + ExecuteCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); + return; + } + + // Invalid command + ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid command history selection. Use {".! list".Color(Color.Command)} to see available commands or {".! #".Color(Color.Command)} to execute a specific command."); + } + + public static void UnregisterConverter(Type converter) + { + if (!IsGenericConverterContext(converter) && !IsSpecificConverterContext(converter)) + { + return; + } + + var args = converter.BaseType.GenericTypeArguments; + var convertFrom = args.FirstOrDefault(); + if (convertFrom == null) + { + Log.Warning($"Could not resolve converter type {converter.Name.ToString().Color(Color.Gold)}"); + return; + } + + if (_converters.ContainsKey(convertFrom)) + { + _converters.Remove(convertFrom); + Log.Info($"Unregistered converter {converter.Name}"); + } + else + { + Log.Warning($"Call to UnregisterConverter for a converter that was not registered. Type: {converter.Name.ToString().Color(Color.Gold)}"); + } + } + + internal static bool IsGenericConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<>).Name; + internal static bool IsSpecificConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<,>).Name; + + public static void RegisterConverter(Type converter) + { + // check base type + var isGenericContext = IsGenericConverterContext(converter); + var isSpecificContext = IsSpecificConverterContext(converter); + + if (!isGenericContext && !isSpecificContext) + { + return; + } + + Log.Debug($"Trying to process {converter} as specifc={isSpecificContext} generic={isGenericContext}"); + + object converterInstance = Activator.CreateInstance(converter); + MethodInfo methodInfo = converter.GetMethod("Parse", BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); + if (methodInfo == null) + { + // can't bud + Log.Error("Can't find TryParse that matches"); + return; + } + + var args = converter.BaseType.GenericTypeArguments; + var convertFrom = args.FirstOrDefault(); + if (convertFrom == null) + { + Log.Error("Can't determine generic base type to convert from. "); + return; + } + + Type contextType = typeof(ICommandContext); + if (isSpecificContext) + { + if (args.Length != 2 || !typeof(ICommandContext).IsAssignableFrom(args[1])) + { + Log.Error("Can't determine generic base type to convert from."); + return; + } + + contextType = args[1]; + } + + + _converters.Add(convertFrom, (converterInstance, methodInfo, contextType)); + } + + public static void RegisterAll() => RegisterAll(Assembly.GetCallingAssembly()); + + public static void RegisterAll(Assembly assembly) + { + var types = assembly.GetTypes(); + + // Register Converters first as typically commands will depend on them. + foreach (var type in types) + { + RegisterConverter(type); + } + + foreach (var type in types) + { + RegisterCommandType(type); + } + } + + public static void RegisterCommandType(Type type) + { + var groupAttr = type.GetCustomAttribute(); + var assembly = type.Assembly; + if (groupAttr != null) + { + // handle groups - IDK later + } + + var methods = type.GetMethods(); + + ConstructorInfo contextConstructor = type.GetConstructors() + .Where(c => c.GetParameters().Length == 1 && typeof(ICommandContext).IsAssignableFrom(c.GetParameters().SingleOrDefault()?.ParameterType)) + .FirstOrDefault(); + + foreach (var method in methods) + { + RegisterMethod(assembly, groupAttr, contextConstructor, method); + } + } + + private static void RegisterMethod(Assembly assembly, CommandGroupAttribute groupAttr, ConstructorInfo customConstructor, MethodInfo method) + { + var commandAttr = method.GetCustomAttribute(); + if (commandAttr == null) return; + + // check for CommandContext as first argument to method + var paramInfos = method.GetParameters(); + var first = paramInfos.FirstOrDefault(); + if (first == null || first.ParameterType is ICommandContext) + { + Log.Error($"Method {method.Name.ToString().Color(Color.Gold)} has no CommandContext as first argument"); + return; + } + + var parameters = paramInfos.Skip(1).ToArray(); + + var canConvert = parameters.All(param => + { + if (_converters.ContainsKey(param.ParameterType)) + { + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a parameter of type {param.ParameterType.Name.ToString().Color(Color.Gold)} which is registered as a converter"); + return true; + } + + var converter = TypeDescriptor.GetConverter(param.ParameterType); + if (converter == null || + !converter.CanConvertFrom(typeof(string))) + { + Log.Warning($"Parameter {param.Name.ToString().Color(Color.Gold)} could not be converted, so {method.Name.ToString().Color(Color.Gold)} will be ignored."); + return false; + } + + return true; + }); + + if (!canConvert) return; + + var constructorType = customConstructor?.GetParameters().Single().ParameterType; + + var command = new CommandMetadata(commandAttr, assembly, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); + + // todo include prefix and group in here, this shoudl be a string match + // todo handle collisons here + + // BAD CODE INC.. permute and cache keys -> command + var groupNames = groupAttr == null ? new[] { "" } : groupAttr.ShortHand == null ? new[] { $"{groupAttr.Name} " } : new[] { $"{groupAttr.Name} ", $"{groupAttr.ShortHand} ", }; + var names = commandAttr.ShortHand == null ? new[] { commandAttr.Name } : new[] { commandAttr.Name, commandAttr.ShortHand }; + var prefix = DEFAULT_PREFIX; // TODO: get from attribute/config + List keys = new(); + foreach (var group in groupNames) + { + foreach (var name in names) + { + var key = $"{prefix}{group}{name}"; + _cache.AddCommand(key, parameters, command); + keys.Add(key); + } + } + + AssemblyCommandMap.TryGetValue(assembly, out var commandKeyCache); + commandKeyCache ??= new(); + commandKeyCache[command] = keys; + AssemblyCommandMap[assembly] = commandKeyCache; + } + + internal static Dictionary>> AssemblyCommandMap { get; } = new(); + + public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); + + public static void UnregisterAssembly(Assembly assembly) + { + foreach (var type in assembly.DefinedTypes) + { + _cache.RemoveCommandsFromType(type); + UnregisterConverter(type); + // TODO: There's a lot of nasty cases involving cross mod converters that need testing + // as of right now the guidance should be to avoid depending on converters from a different mod + // especially if you're hot reloading either. + } + + AssemblyCommandMap.Remove(assembly); + } +} diff --git a/VCF.Tests/RepeatCommandsTests.cs b/VCF.Tests/RepeatCommandsTests.cs new file mode 100644 index 0000000..af87771 --- /dev/null +++ b/VCF.Tests/RepeatCommandsTests.cs @@ -0,0 +1,245 @@ +using NUnit.Framework; +using System.Text; +using VampireCommandFramework; +using VampireCommandFramework.Basics; + +namespace VCF.Tests +{ + [TestFixture] + public class RepeatCommandsTests + { + [SetUp] + public void Setup() + { + CommandRegistry.Reset(); + Format.Mode = Format.FormatMode.None; + + // Register the necessary command types for testing + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + } + + #region Test Commands + + public static class TestCommands + { + [Command("echo", description: "Echoes the provided message")] + public static void Echo(ICommandContext ctx, string message) + { + ctx.Reply($"Echo: {message}"); + } + + [Command("add", description: "Adds two numbers")] + public static void Add(ICommandContext ctx, int a, int b) + { + ctx.Reply($"Result: {a + b}"); + } + } + + #endregion + + [Test] + public void RepeatLastCommand_ExecutesMostRecentCommand() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute an initial command + TestUtilities.AssertHandle(ctx, ".echo hello", CommandResult.Success); + ctx.AssertReplyContains("Echo: hello"); + + // Act - Use the repeat command + var result = CommandRegistry.Handle(ctx, ".!"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Repeating most recent command: .echo hello"); + ctx.AssertReplyContains("Echo: hello"); + } + + [Test] + public void RepeatLastCommand_NoHistory_ReturnsError() + { + // Arrange + var ctx = new AssertReplyContext() { Name = "NewUser" }; + + // Act - Attempt to repeat with no history + var result = CommandRegistry.Handle(ctx, ".!"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + ctx.AssertReplyContains("[error]"); + ctx.AssertReplyContains("No command history available"); + } + + [Test] + public void ListCommandHistory_DisplaysCommandHistory() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute a few commands to build history + TestUtilities.AssertHandle(ctx, ".echo first", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo second", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 5 10", CommandResult.Success); + + // Act - Use the list command history command + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Command history:"); + ctx.AssertReplyContains("1. .add 5 10"); + ctx.AssertReplyContains("2. .echo second"); + ctx.AssertReplyContains("3. .echo first"); + } + + [Test] + public void ListCommandHistory_ShortHand_DisplaysCommandHistory() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute a few commands to build history + TestUtilities.AssertHandle(ctx, ".echo test1", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo test2", CommandResult.Success); + + // Act - Use the shorthand command history command + var result = CommandRegistry.Handle(ctx, ".! l"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Command history:"); + ctx.AssertReplyContains("1. .echo test2"); + ctx.AssertReplyContains("2. .echo test1"); + } + + [Test] + public void ExecuteHistoryCommand_ValidIndex_ExecutesCommand() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute different commands to build history + TestUtilities.AssertHandle(ctx, ".echo \"first message\"", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 10 20", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo \"third message\"", CommandResult.Success); + + // Act - Execute the second command in history (index 2) + var result = CommandRegistry.Handle(ctx, ".! 2"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("Executing command 2: .add 10 20"); + ctx.AssertReplyContains("Result: 30"); + } + + [Test] + public void ExecuteHistoryCommand_InvalidIndex_ReturnsError() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute a command to build history + TestUtilities.AssertHandle(ctx, ".echo test", CommandResult.Success); + + // Act - Execute with an index that doesn't exist + var result = CommandRegistry.Handle(ctx, ".! 99"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + ctx.AssertReplyContains("[error]"); + ctx.AssertReplyContains("Invalid command history selection"); + } + + [Test] + public void CommandHistory_IsolatedBetweenUsers() + { + // Arrange + var user1 = new AssertReplyContext { Name = "User1" }; + var user2 = new AssertReplyContext { Name = "User2" }; + + // User 1 executes commands + TestUtilities.AssertHandle(user1, ".echo \"user1 message\"", CommandResult.Success); + + // User 2 executes different commands + TestUtilities.AssertHandle(user2, ".add 5 7", CommandResult.Success); + + // Act - Both users try to repeat their last command + var resultUser1 = CommandRegistry.Handle(user1, ".!"); + var resultUser2 = CommandRegistry.Handle(user2, ".!"); + + // Assert - Each user should see their own history + Assert.That(resultUser1, Is.EqualTo(CommandResult.Success)); + user1.AssertReplyContains("Repeating most recent command: .echo \"user1 message\""); + user1.AssertReplyContains("Echo: user1 message"); + + Assert.That(resultUser2, Is.EqualTo(CommandResult.Success)); + user2.AssertReplyContains("Repeating most recent command: .add 5 7"); + user2.AssertReplyContains("Result: 12"); + } + + [Test] + public void CommandHistory_LimitedToMaximumEntries() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute 11 commands (more than the MAX_COMMAND_HISTORY of 10) + for (int i = 0; i <= 10; i++) + { + TestUtilities.AssertHandle(ctx, $".echo message{i}", CommandResult.Success); + } + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + + // Should have most recent 10 commands, but not the oldest one (message1) + ctx.AssertReplyContains("1. .echo message10"); + ctx.AssertReplyContains("10. .echo message1"); + + // Get the full response to check message0 is not present + var fullReplyCtx = new AssertReplyContext(); + CommandRegistry.Handle(fullReplyCtx, ".! list"); + Assert.That(fullReplyCtx.RepliedTextLfAndTrimmed().Contains("message0"), Is.False, + "The oldest message should have been removed due to history size limit"); + } + + [Test] + public void InvalidCommandHistoryCommand_ReturnsError() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute a command to ensure we have history + TestUtilities.AssertHandle(ctx, ".echo test", CommandResult.Success); + + // Act - Execute an invalid history command + var result = CommandRegistry.Handle(ctx, ".! invalid"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + ctx.AssertReplyContains("[error]"); + ctx.AssertReplyContains("Invalid command history selection"); + } + } + + // Extension method to get the replied text for testing + public static class AssertReplyContextExtensions + { + public static string RepliedTextLfAndTrimmed(this AssertReplyContext ctx) + { + // Using reflection to access the private field _sb + var fieldInfo = typeof(AssertReplyContext).GetField("_sb", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var sb = (StringBuilder)fieldInfo.GetValue(ctx); + return sb.ToString() + .Replace("\r\n", "\n") + .TrimEnd(Environment.NewLine.ToCharArray()); + } + } +} From eeac4cc86b96f36c413df1d4f1691f4511d368e6 Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 29 May 2025 23:05:52 -0400 Subject: [PATCH 6/8] Updating README.md reflecting all changes Added additional colors correlating to various colors in VRising --- README.md | 277 ++++++++++++++++++++++++++------- VCF.Core/Basics/HelpCommand.cs | 4 +- VCF.Core/Framework/Color.cs | 33 +++- 3 files changed, 245 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 11832c7..0ab6da5 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,218 @@ - -# VampireCommandFramework -![](https://github.com/decaprime/VampireCommandFramework/raw/main/images/logo_128.png) Framework for V Rising mod developers to easily build commands. - -# For Server Operators - -This plugin should be installed into the `BepInEx/plugins` folder and be kept up to date. It is required by other plugins for commands. In the future there will be more universal configurations for server operators to manage commands across plugins. - -# For Plugin Developers -## How to use - -### 1. Add a reference to the plugin ->`dotnet add package VRising.VampireCommandFramework` - -### 2. Add the plugin as a dependency by setting this attribute on your plugin class ->`[BepInDependency("gg.deca.VampireCommandFramework")]` - -### 3. Register your plugin when you're done loading: ->`CommandRegistry.RegisterAll()` - -### 4. Write commands -```csharp - [Command("foo")] - public void Foo(ICommandContext ctx, int count, string orValues = "with defaults", float someFloat=3f) - => ctx.Reply($"You'd do stuff here with your parsed {count} and stuff"); -``` - -### This gets you automatically -- Help command listings -- Argument usage text -- Command parsing -- Invoking your code with contexts - -### For example -The above would execute for: -- `.foo 5` -- `.foo 5 works` -- `.foo 5 "or really fancy" 3.145` - -But if you typed `.foo 5.123` you'd see a generated usage message back like ->*.foo (count) [orValues ="with defaults"] [someFloat=3]* - -## Middleware - -All commands execute through the same pipeline and through a series of middleware. You can add your own middleware to the pipeline by adding a class that implements `ICommandMiddleware` and adding it to the `CommandRegistry.Middlewares` list. Middleware is where you'd implement things like cooldowns, permissions, logging, command 'costs', that could apply across commands even from other vcf plugins. - -## Other features - -- Custom type converters -- Context abstraction support for other types of commands (e.g. RCon, Console, UI, Discord, etc.) -- Response and formatting utilities -- Universal BepInEx config management commands for all (including non-vcf) plugins. -- Override config system for metadata on commands, this lets server operators do things like - - Localization - - Custom help text and descriptions - - Disabling commands entirely - -## Help -Please feel free to direct questions to @decaprime on discord at the [V Rising Modding Discord](https://vrisingmods.com/discord) +# VampireCommandFramework +![](https://github.com/decaprime/VampireCommandFramework/raw/main/images/logo_128.png) + +A comprehensive framework for V Rising mod developers to easily build commands with advanced features like command overloading, history, smart matching, and plugin-specific execution. +## Usage + +**For Server Operators** + +This plugin should be installed into the `BepInEx/plugins` folder and be kept up to date. It is required by other plugins for commands. + + + +
For Plugin Developers + +## How to use + +#### 1. Add a reference to the plugin +>`dotnet add package VRising.VampireCommandFramework` + +#### 2. Add the plugin as a dependency by setting this attribute on your plugin class +>`[BepInDependency("gg.deca.VampireCommandFramework")]` + +#### 3. Register your plugin when you're done loading: +>`CommandRegistry.RegisterAll()` + +#### 4. Write commands +```csharp +[Command("foo")] +public void Foo(ICommandContext ctx, int count, string orValues = "with defaults", float someFloat = 3f) + => ctx.Reply($"You'd do stuff here with your parsed {count} and stuff"); +``` + +This command would execute for: +- `.foo 5` +- `.foo 5 works` +- `.foo 5 "or really fancy" 3.145` + +But if you typed `.foo 5.123` you'd see a generated usage message like: +> +```*.foo (count) [orValues="with defaults"] [someFloat=3]*``` + +### This simple example provides +- **Automatic help listings** - your command appears in `.help` +- **Parameter parsing** - `count`, `orValues`, and `someFloat` are automatically converted +- **Usage text generation** - help shows `(count) [orValues="with defaults"] [someFloat=3]` +- **Default parameter handling** - optional parameters work seamlessly +- **Type conversion** - strings become integers, floats, etc. + +### More Examples + +The framework handles these additional command patterns: + +**Command with parameters:** +```csharp +[Command("give")] +public void GiveItem(ICommandContext ctx, string item, int count = 1) + => ctx.Reply($"Gave {count} {item}"); +``` +- Executes with: `.give sword`, `.give sword 5` + +**Command groups:** +```csharp +[CommandGroup("admin")] +public class AdminCommands +{ + [Command("ban")] + public void Ban(ICommandContext ctx, string player) + => ctx.Reply($"Banned {player}"); +} +``` +- Executes with: `.admin ban PlayerName` + +## Command Overloading +You can now create multiple commands with the same name but different parameter types: + +```csharp +[Command("teleport")] +public void TeleportToPlayer(ICommandContext ctx, string playerName) + => ctx.Reply($"Teleporting to {playerName}"); + +[Command("teleport")] +public void TeleportToCoords(ICommandContext ctx, float x, float y) + => ctx.Reply($"Teleporting to {x}, {y}"); +``` + +When there's ambiguity, players will be presented with options and can select using `.1`, `.2`, etc. + +## Middleware + +All commands execute through a middleware pipeline. You can add your own middleware by implementing `ICommandMiddleware` and adding it to the `CommandRegistry.Middlewares` list. + +Middleware is perfect for implementing permissions and roles, cooldowns, logging, command costs, rate limiting, and other cross-cutting concerns that should apply across commands even from other VCF plugins. + +Example middleware: +```csharp +public class CooldownMiddleware : CommandMiddleware +{ + public override bool CanExecute(ICommandContext ctx, CommandAttribute cmd, MethodInfo method) + { + // Your cooldown logic here + return !IsOnCooldown(ctx.Name, cmd.Name); + } +} +``` + +## Response and Formatting Utilities + +The framework includes rich formatting utilities for enhanced chat responses: + +```csharp +// Text formatting +ctx.Reply($"{"Important".Bold()} message with {"emphasis".Italic()}"); +ctx.Reply($"{"Warning".Underline()} - please read carefully"); + +// Colors (using predefined color constants) +ctx.Reply($"{"Error".Color(Color.Red)} - something went wrong"); +ctx.Reply($"{"Success".Color(Color.Green)} - command completed"); +ctx.Reply($"{"Info".Color(Color.LightBlue)} message"); + +// Text sizing +ctx.Reply($"{"Large Header".Large()} with {"small details".Small()}"); + +// Combining formatting +ctx.Reply($"{"Critical".Bold().Color(Color.Red).Large()} system alert!"); + +// Paginated replies for long content +var longText = "Very long text that might exceed chat limits..."; +ctx.SysPaginatedReply(longText); // Automatically splits into multiple messages +``` +
+Available colors include: +`Red`, `Primary`, `White`, `LightGrey`, `Yellow`, `Green`, `Command`, `Beige`, `Gold`, `Lavender`, `Pink`, `Periwinkle`, `Teal`, `LightRed`, `LightPurple`, `Lilac`, `LightPink`, `SoftBGrey`, `AdminUsername`, `ClanName`, `LightBlood`, `Blood`, `LightChaos`, `Chaos`, `LightUnholy`, `Unholy`, `LightIllusion`, `Illusion`, `LightFrost`, `Frost`, `LightStorm`, `Storm`, `Discord`, `Global`, and `ClassicUsername`. +
+ +## Custom Type Converters +Create converters for your custom types: + +```csharp +public class PlayerConverter : CommandArgumentConverter +{ + public override Player Parse(ICommandContext ctx, string input) + { + var player = FindPlayer(input); + if (player == null) + throw ctx.Error($"Player '{input}' not found"); + return player; + } +} +``` +
+ +## Framework Features +The VampireCommandFramework also provides these powerful features across all commands: +- **Enhanced help system** with filtering and search +- **Command overloading** - multiple commands with same name but different parameters +- **Command history and recall** - players can repeat previous commands +- **Intelligent command matching** - suggests closest matches for typos +- **Plugin-specific command execution** - target commands from specific mods +- **Case-insensitive commands** - works regardless of capitalization +- **Middleware pipeline** for permissions, cooldowns, etc. + +### Enhanced Help System + +The help system has been significantly improved: + +- `.help` - Lists all available plugins +- `.help ` - Shows commands for a specific plugin +- `.help ` - Shows detailed help for a specific command +- `.help-all` - Shows all commands from all plugins +- `.help-all ` - Shows all commands matching the filter + +All help commands support case-insensitive searching and include: +- Command descriptions and usage +- Parameter information with types and defaults +- Enum value listings +- Custom converter usage information + +## Advanced Usage + +### Command History and Recall +Players can easily repeat previous commands: +- `.!` - Repeat the last command +- `.! 3` - Repeat the 3rd most recent command +- `.! list` or `.! l` - Show command history (last 10 commands) + +### Smart Command Matching +When a command isn't found, the system suggests up to 3 closest matches: +``` +Command not found: .tleport +Did you mean: .teleport, .teleport-home, .tp +``` + +### Plugin-Specific Commands +Players can execute commands from specific plugins to avoid conflicts: +``` +.HealthMod heal +.MagicMod heal +``` + +### Command Overloading +Some commands can work with different types of input. For example, a teleport command might accept either a player name or coordinates: +``` +.teleport PlayerName +.teleport 100 200 +``` +When your input could match multiple command variations, you'll see a list of options and can select the one you want using `.1`, `.2`, etc. + + + +## Universal Configuration Management +Built-in commands for managing BepInEx configurations across all plugins: +- `.config dump ` - View plugin configuration +- `.config set
` - Modify settings + + + +## Help +Please feel free to direct questions to @decaprime or @odjit on discord at the [V Rising Modding Discord](https://vrisingmods.com/discord) \ No newline at end of file diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index 6ccfceb..fa01f8f 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -77,7 +77,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string void GenerateFullHelp(CommandMetadata command, List aliases, StringBuilder sb) { - sb.AppendLine($"{B(command.Attribute.Name).Color(Color.LightRed)} {command.Attribute.Description.Color(Color.Grey)}"); + sb.AppendLine($"{B(command.Attribute.Name).Color(Color.LightRed)} {command.Attribute.Description.Color(Color.SoftBGrey)}"); sb.AppendLine(GetShortHelp(command)); sb.AppendLine($"{B("Aliases").Underline().Color(Color.Pink)}: {string.Join(", ", aliases).Italic()}"); @@ -163,7 +163,7 @@ internal static string GetOrGenerateUsage(CommandMetadata command) var usages = command.Parameters.Select( p => !p.HasDefaultValue ? $"({p.Name})".Color(Color.LightGrey) // todo could compress this for the cases with no defaulting - : $"[{p.Name}={p.DefaultValue}]".Color(Color.DarkGreen) + : $"[{p.Name}={p.DefaultValue}]".Color(Color.Green) ); usageText = string.Join(" ", usages); diff --git a/VCF.Core/Framework/Color.cs b/VCF.Core/Framework/Color.cs index 3ade848..710bb50 100644 --- a/VCF.Core/Framework/Color.cs +++ b/VCF.Core/Framework/Color.cs @@ -9,20 +9,37 @@ public static class Color public static string Primary = "#d25"; public static string White = "#eee"; public static string LightGrey = "#bbf"; - public static string Yellow = "#dd0"; - public static string DarkGreen = "#4c4"; + public static string Yellow = "#dd0"; + public static string DarkGreen = "#0c0"; + public static string Green = "#4c4"; public static string Command = "#4ED"; public static string Beige = "#fed"; public static string Gold = "#eb0"; - public static string Magenta = "#c5d"; + public static string Lavender = "#c5d"; public static string Pink = "#faf"; - public static string Purple = "#52d"; - public static string LightBlue = "#3ac"; + public static string Periwinkle = "#52d"; + public static string Teal = "#3ac"; public static string LightRed = "#f45"; public static string LightPurple = "#84c"; public static string Lilac = "#b9f"; public static string LightPink = "#EED"; - public static string Grey = "#eef"; - - + public static string SoftBGrey = "#eef"; + public static string AdminUsername = "#afa"; + public static string ClanName = "#59e"; + public static string LightBlood = "#f44"; + public static string Blood = "#c24"; + public static string LightChaos = "#faf"; + public static string Chaos = "#d5e"; + public static string LightUnholy = "#bd3"; + public static string Unholy = "#4c0"; + public static string LightIllusion = "#bfd"; + public static string Illusion = "#3db"; + public static string LightFrost = "#aef"; + public static string Frost = "#09f"; + public static string LightStorm = "#fe7"; + public static string Storm = "#ff5"; + public static string Discord = "#78f"; + public static string Global = "#8cf"; + public static string ClassicUsername = "#876"; + } From fabb070ea08641d4632ab1595f97b980e2c397c9 Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 29 May 2025 23:10:33 -0400 Subject: [PATCH 7/8] Adding reference to V Roles in the Middleware section as an example plugin already using the middleware. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0ab6da5..f81a722 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ All commands execute through a middleware pipeline. You can add your own middlew Middleware is perfect for implementing permissions and roles, cooldowns, logging, command costs, rate limiting, and other cross-cutting concerns that should apply across commands even from other VCF plugins. + [V Roles](https://github.com/Odjit/VRoles) is an example of a Middleware plugin for VCF that adds in roles that commands and users can get assigned. + Example middleware: ```csharp public class CooldownMiddleware : CommandMiddleware From fef0f5532533eb03a5d92e786afed59bc3089219 Mon Sep 17 00:00:00 2001 From: Amber Date: Fri, 30 May 2025 22:19:04 -0400 Subject: [PATCH 8/8] Guess different version of .net is being used on the GitHub Action workflow than in the project --- VCF.Core/Registry/CommandRegistry.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index d79fce2..9d7a5bc 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -19,7 +19,7 @@ public static class CommandRegistry /// /// From converting type to (object instance, MethodInfo tryParse, Type contextType) /// - internal static Dictionary _converters = []; + internal static Dictionary _converters = new(); internal static void Reset() { @@ -36,7 +36,7 @@ internal static void Reset() public static List Middlewares { get; } = new() { new VCF.Core.Basics.BasicAdminCheck() }; // Store pending commands for selection - private static Dictionary commands)> _pendingCommands = []; + private static Dictionary commands)> _pendingCommands = new(); private static Dictionary> _commandHistory = new(); private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands @@ -94,13 +94,13 @@ internal static IEnumerable FindCloseMatches(ICommandContext ctx, string { // Get all possible combinations of group and command names var groupNames = cmd.GroupAttribute == null - ? [""] + ? new[] { "" } : cmd.GroupAttribute.ShortHand == null - ? [cmd.GroupAttribute.Name + " "] + ? new[] { cmd.GroupAttribute.Name + " " } : new[] { cmd.GroupAttribute.Name + " ", cmd.GroupAttribute.ShortHand + " " }; var commandNames = cmd.Attribute.ShortHand == null - ? [cmd.Attribute.Name] + ? new[] { cmd.Attribute.Name } : new[] { cmd.Attribute.Name, cmd.Attribute.ShortHand }; return groupNames.SelectMany(group =>