From 3e7146c99b22a2c9320c33d74da631255146209d Mon Sep 17 00:00:00 2001 From: Amber Date: Fri, 18 Jul 2025 19:03:45 -0400 Subject: [PATCH 01/29] No longer keeping hard references to Assembly's instead just purely their names --- VCF.Core/Basics/HelpCommand.cs | 8 ++++---- VCF.Core/Registry/CommandMetadata.cs | 2 +- VCF.Core/Registry/CommandRegistry.cs | 21 ++++++++++++--------- VCF.Tests/AssemblyCommandTests.cs | 18 ++++++------------ 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index fa01f8f..074a521 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -24,7 +24,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string // If search is specified first look for matching assembly, then matching command if (!string.IsNullOrEmpty(search)) { - var foundAssembly = CommandRegistry.AssemblyCommandMap.FirstOrDefault(x => x.Key.GetName().Name.StartsWith(search, StringComparison.OrdinalIgnoreCase)); + var foundAssembly = CommandRegistry.AssemblyCommandMap.FirstOrDefault(x => x.Key.StartsWith(search, StringComparison.OrdinalIgnoreCase)); if (foundAssembly.Value != null) { StringBuilder sb = new(); @@ -67,7 +67,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string 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) + .Select(x => x.Key) .OrderBy(x => x)) { sb.AppendLine($"{assemblyName.Color(Color.Lilac)}"); @@ -125,9 +125,9 @@ public static void HelpAllCommand(ICommandContext ctx, string filter = null) ctx.SysPaginatedReply(sb); } - static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb, string filter = null) + static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair>> assembly, StringBuilder sb, string filter = null) { - var name = assembly.Key.GetName().Name; + var name = assembly.Key; name = _trailingLongDashRegex.Replace(name, ""); sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline()); diff --git a/VCF.Core/Registry/CommandMetadata.cs b/VCF.Core/Registry/CommandMetadata.cs index 417b0fd..54088ac 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, Assembly Assembly, MethodInfo Method, ConstructorInfo Constructor, ParameterInfo[] Parameters, Type ContextType, Type ConstructorType, CommandGroupAttribute GroupAttribute); +internal record CommandMetadata(CommandAttribute Attribute, string AssemblyName, 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 77e8ab2..f1dbe86 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -222,8 +222,8 @@ public static CommandResult Handle(ICommandContext ctx, string input) 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)); + bool isValidAssembly = AssemblyCommandMap.Keys.Any(assemblyName => + assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); if (isValidAssembly) { @@ -289,7 +289,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) 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; + string assemblyInfo = command.AssemblyName; sb.AppendLine($" - {command.Attribute.Name} ({assemblyInfo}): {error}"); } ctx.SysPaginatedReply(sb); @@ -313,7 +313,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) for (int i = 0; i < successfulCommands.Count; i++) { var (command, _, _) = successfulCommands[i]; - var cmdAssembly = command.Assembly.GetName().Name; + var cmdAssembly = command.AssemblyName; 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)); @@ -797,7 +797,7 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou var constructorType = customConstructor?.GetParameters().Single().ParameterType; - var command = new CommandMetadata(commandAttr, assembly, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); + var command = new CommandMetadata(commandAttr, assembly.GetName().Name, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); // todo include prefix and group in here, this shoudl be a string match // todo handle collisons here @@ -817,18 +817,21 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou } } - AssemblyCommandMap.TryGetValue(assembly, out var commandKeyCache); + var assemblyName = assembly.GetName().Name; + AssemblyCommandMap.TryGetValue(assemblyName, out var commandKeyCache); commandKeyCache ??= new(); commandKeyCache[command] = keys; - AssemblyCommandMap[assembly] = commandKeyCache; + AssemblyCommandMap[assemblyName] = commandKeyCache; } - internal static Dictionary>> AssemblyCommandMap { get; } = new(); + internal static Dictionary>> AssemblyCommandMap { get; } = new(); public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); public static void UnregisterAssembly(Assembly assembly) { + var assemblyName = assembly.GetName().Name; + foreach (var type in assembly.DefinedTypes) { _cache.RemoveCommandsFromType(type); @@ -838,6 +841,6 @@ public static void UnregisterAssembly(Assembly assembly) // especially if you're hot reloading either. } - AssemblyCommandMap.Remove(assembly); + AssemblyCommandMap.Remove(assemblyName); } } diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs index 2c43609..4797934 100644 --- a/VCF.Tests/AssemblyCommandTests.cs +++ b/VCF.Tests/AssemblyCommandTests.cs @@ -62,18 +62,12 @@ private void RegisterCommandsWithMockAssembly(System.Type commandType, string mo // Register the command type normally CommandRegistry.RegisterCommandType(commandType); - // Get the actual assembly - var realAssembly = commandType.Assembly; + // Get the actual assembly name + var realAssemblyName = commandType.Assembly.GetName().Name; // Check if commands were registered for the real assembly - if (CommandRegistry.AssemblyCommandMap.TryGetValue(realAssembly, out var commandCache)) + if (CommandRegistry.AssemblyCommandMap.TryGetValue(realAssemblyName, 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>(); @@ -87,7 +81,7 @@ private void RegisterCommandsWithMockAssembly(System.Type commandType, string mo { // This command belongs to the commandType we're registering // Move it to the mock assembly - var newCommandMetadata = entry.Key with { Assembly = mockAssembly }; + var newCommandMetadata = entry.Key with { AssemblyName = mockAssemblyName }; mockCommandCache[newCommandMetadata] = entry.Value; // Update CommandCache @@ -114,8 +108,8 @@ private void RegisterCommandsWithMockAssembly(System.Type commandType, string mo } // Update the registry with our new caches - CommandRegistry.AssemblyCommandMap[mockAssembly] = mockCommandCache; - CommandRegistry.AssemblyCommandMap[realAssembly] = newRealCommandCache; + CommandRegistry.AssemblyCommandMap[mockAssemblyName] = mockCommandCache; + CommandRegistry.AssemblyCommandMap[realAssemblyName] = newRealCommandCache; } } From 6e3fe65ae7266e44df548d19dd9943078e837eb1 Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 19 Jul 2025 03:40:00 -0400 Subject: [PATCH 02/29] As we now support command overloading and choosing which command to execute, we are allowing multiple matches for overlapped commands instead of favoring the first command matched. --- VCF.Core/Registry/CacheResult.cs | 11 +-- VCF.Core/Registry/CommandCache.cs | 50 ++++-------- VCF.Core/Registry/CommandRegistry.cs | 7 +- VCF.Tests/CommandOverlapTests.cs | 13 ++- VCF.Tests/CommandOverloadingTests.cs | 114 +++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 45 deletions(-) diff --git a/VCF.Core/Registry/CacheResult.cs b/VCF.Core/Registry/CacheResult.cs index 4dd17a1..e30dac4 100644 --- a/VCF.Core/Registry/CacheResult.cs +++ b/VCF.Core/Registry/CacheResult.cs @@ -6,26 +6,23 @@ namespace VampireCommandFramework.Registry; internal record CacheResult { - internal IEnumerable Commands { get; } - internal string[] Args { get; } + internal IEnumerable<(CommandMetadata Command, string[] Args)> Commands { 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) + public CacheResult(IEnumerable<(CommandMetadata Command, string[] Args)> commands, 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) + public CacheResult((CommandMetadata Command, string[] Args)? command, IEnumerable partialMatches) { - Commands = command != null ? new[] { command } : null; - Args = args ?? Array.Empty(); // Ensure Args is never null + Commands = command.HasValue ? new[] { command.Value } : null; PartialMatches = partialMatches; } } diff --git a/VCF.Core/Registry/CommandCache.cs b/VCF.Core/Registry/CommandCache.cs index bcb1560..2e2f9a0 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -47,7 +47,7 @@ internal CacheResult GetCommand(string rawInput) { var lowerRawInput = rawInput.ToLowerInvariant(); List possibleMatches = new(); - List exactMatches = new(); + List<(CommandMetadata Command, string[] Args)> exactMatches = new(); foreach (var (key, argCounts) in _newCache) { @@ -64,17 +64,10 @@ internal CacheResult GetCommand(string rawInput) 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 + // Add all commands that match the exact parameter count, paired with their parameters + foreach (var cmd in cmds) { - return new CacheResult(exactMatches, Array.Empty(), null); + exactMatches.Add((cmd, parameters)); } } else @@ -86,15 +79,14 @@ internal CacheResult GetCommand(string rawInput) } } - // If we have exact matches but didn't return early + // If we have exact matches, return them if (exactMatches.Count > 0) { - return new CacheResult(exactMatches, Array.Empty(), null); + return new CacheResult(exactMatches, null); } - // Use the explicit single command constructor with null - CommandMetadata nullCommand = null; - return new CacheResult(nullCommand, null, possibleMatches.Distinct()); + // No exact matches found + return new CacheResult(((CommandMetadata, string[])?)null, possibleMatches.Distinct()); } // Handle assembly-specific command lookup @@ -102,7 +94,7 @@ internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName { var lowerRawInput = rawInput.ToLowerInvariant(); List possibleMatches = new(); - List exactMatches = new(); + List<(CommandMetadata Command, string[] Args)> exactMatches = new(); foreach (var (key, argCounts) in _newCache) { @@ -119,38 +111,30 @@ internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName 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 + // Add all commands that match the exact parameter count and assembly name, paired with their parameters + foreach (var cmd in cmds.Where(cmd => cmd.AssemblyName.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))) { - return new CacheResult(exactMatches, Array.Empty(), null); + exactMatches.Add((cmd, parameters)); } } 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))); + .Where(cmd => cmd.AssemblyName.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); } } } } - // If we have exact matches but didn't return early + // If we have exact matches, return them if (exactMatches.Count > 0) { - return new CacheResult(exactMatches, Array.Empty(), null); + return new CacheResult(exactMatches, null); } - // Use the explicit single command constructor with null - CommandMetadata nullCommand = null; - return new CacheResult(nullCommand, null, possibleMatches.Distinct()); + // No exact matches found + return new CacheResult(((CommandMetadata, string[])?)null, possibleMatches.Distinct()); } internal void RemoveCommandsFromType(Type t) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index f1dbe86..9602d61 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -243,7 +243,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) matchedCommand = _cache.GetCommand(input); } - var (commands, args) = (matchedCommand.Commands, matchedCommand.Args); + var commands = matchedCommand.Commands; if (!matchedCommand.IsMatched) { @@ -260,14 +260,15 @@ public static CommandResult Handle(ICommandContext ctx, string input) // If there's only one command, handle it directly if (commands.Count() == 1) { - return ExecuteCommand(ctx, commands.First(), args, input); + var (command, args) = commands.First(); + return ExecuteCommand(ctx, command, 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) + foreach (var (command, args) in commands) { if (!CanCommandExecute(ctx, command)) continue; diff --git a/VCF.Tests/CommandOverlapTests.cs b/VCF.Tests/CommandOverlapTests.cs index f3205ac..bcf537c 100644 --- a/VCF.Tests/CommandOverlapTests.cs +++ b/VCF.Tests/CommandOverlapTests.cs @@ -33,11 +33,20 @@ public enum Thing { foo, bar }; [Test] public void Does_FooBar_Overload_In_Order() { + Format.Mode = Format.FormatMode.None; AssertHandle(".foo", CommandResult.UsageError); AssertHandle(".bar", CommandResult.UsageError); AssertHandle(".foo foo", CommandResult.Success, withReply: "foo"); - AssertHandle(".foo bar", CommandResult.Success, withReply: "foo"); + AssertHandle(".foo bar", CommandResult.Success, withReply: "[vcf] Multiple commands match this input. Select one by typing .<#>:\n"+ + " .1 - VCF.Tests - foo \n"+ + " .foo (v)\n"+ + " .2 - VCF.Tests - foo bar \n"+ + " .foo bar"); AssertHandle(".bar bar", CommandResult.Success, withReply: "bar"); - AssertHandle(".bar foo", CommandResult.Success, withReply: "bar foo"); + AssertHandle(".bar foo", CommandResult.Success, withReply: "[vcf] Multiple commands match this input. Select one by typing .<#>:\n" + + " .1 - VCF.Tests - bar foo \n" + + " .bar foo\n"+ + " .2 - VCF.Tests - bar \n" + + " .bar (v)"); } } \ No newline at end of file diff --git a/VCF.Tests/CommandOverloadingTests.cs b/VCF.Tests/CommandOverloadingTests.cs index f926b95..14d81a7 100644 --- a/VCF.Tests/CommandOverloadingTests.cs +++ b/VCF.Tests/CommandOverloadingTests.cs @@ -63,6 +63,120 @@ public void Selection(ICommandContext ctx, float value) #endregion + #region Custom Converter Fallback Tests + + // Custom type and converter for fallback testing + public class CustomType + { + public string Value { get; set; } + public CustomType(string value) { Value = value; } + } + + public class FailingCustomConverter : CommandArgumentConverter + { + public override CustomType Parse(ICommandContext ctx, string input) + { + // Only accept "valid" as a valid input, throw error for anything else + if (input == "valid") + { + return new CustomType(input); + } + throw ctx.Error($"CustomConverter failed: '{input}' is not valid"); + } + } + + public class CustomConverterCommands + { + [Command("test", description: "Custom type parameter command")] + public void TestCustom(ICommandContext ctx, CustomType value) + { + ctx.Reply($"Custom command executed with: {value.Value}"); + } + } + + public class CustomInvalidCommands + { + [Command("test invalid", description: "Takes invalid as a second part with no parameters")] + public void TestCustom(ICommandContext ctx) + { + ctx.Reply($"test invalid executed"); + } + } + + public class StringAndCustomCommands + { + [Command("test", description: "String parameter command")] + public void TestString(ICommandContext ctx, string value) + { + ctx.Reply($"String command executed with: {value}"); + } + + [Command("test", description: "Custom type parameter command")] + public void TestCustom(ICommandContext ctx, CustomType value) + { + ctx.Reply($"Custom command executed with: {value.Value}"); + } + } + + [Test] + public void CustomConverterFallback_ConverterFails_FallsBackToNoParamCommand() + { + // Register custom converter and commands + CommandRegistry.RegisterConverter(typeof(FailingCustomConverter)); + CommandRegistry.RegisterCommandType(typeof(CustomInvalidCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test invalid"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("test invalid executed"); + } + + [Test] + public void CustomConverterFallback_ConverterFails_FallsBackToStringCommand() + { + // Register custom converter and commands + CommandRegistry.RegisterConverter(typeof(FailingCustomConverter)); + CommandRegistry.RegisterCommandType(typeof(StringAndCustomCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test invalid"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("String command executed with: invalid"); + } + + [Test] + public void CustomConverterFallback_OnlyCustomCommandExists_ConverterFails_ShowsError() + { + // Register only the custom converter command (no string fallback) + CommandRegistry.RegisterConverter(typeof(FailingCustomConverter)); + CommandRegistry.RegisterCommandType(typeof(CustomConverterCommands)); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".test invalid"); + + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); + ctx.AssertReplyContains("[error]"); + ctx.AssertReplyContains("CustomConverter failed"); + } + + [Test] + public void CustomConverterFallback_StringAndIntCommands_ConverterNotRegistered_FallsBackToString() + { + // Register commands without the custom converter + CommandRegistry.RegisterCommandType(typeof(StringParameterCommands)); + CommandRegistry.RegisterCommandType(typeof(IntParameterCommands)); + + 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"); + } + + #endregion + #region Basic Overloading Tests [Test] From 0272bb2b9f82166bfff5565f93b6c4557bc56655 Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 19 Jul 2025 04:47:08 -0400 Subject: [PATCH 03/29] Adding the ability to handle the remainder of a command input with a parameter called _remainder for any command. --- VCF.Core/Registry/CommandCache.cs | 129 ++++++++-- VCF.Core/Registry/CommandRegistry.cs | 337 +++++++++++++++++++++------ VCF.Tests/RemainderParameterTests.cs | 144 ++++++++++++ 3 files changed, 514 insertions(+), 96 deletions(-) create mode 100644 VCF.Tests/RemainderParameterTests.cs diff --git a/VCF.Core/Registry/CommandCache.cs b/VCF.Core/Registry/CommandCache.cs index 2e2f9a0..748f08a 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -10,37 +10,60 @@ internal class CommandCache { private static Dictionary> _commandAssemblyMap = new(); - // Change dictionary value from CommandMetadata to List internal Dictionary>> _newCache = new(); + + internal Dictionary> _remainderCache = 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(); + + bool hasRemainder = parameters.Length > 0 && + parameters[parameters.Length - 1].Name == "_remainder" && + parameters[parameters.Length - 1].ParameterType == typeof(string); + if (!_newCache.ContainsKey(key)) { _newCache.Add(key, new()); } - // somewhat lame datastructure but memory cheap and tiny for space of commands - for (var i = p - d; i <= p; i++) + if (hasRemainder) { - _newCache[key] = _newCache.GetValueOrDefault(key, new()) ?? new(); - if (!_newCache[key].ContainsKey(i)) + // Add to remainder cache + if (!_remainderCache.ContainsKey(key)) { - _newCache[key][i] = new List(); + _remainderCache[key] = new List(); } - - // Add new command to the list - _newCache[key][i].Add(command); + _remainderCache[key].Add(command); var typeKey = command.Method.DeclaringType; - var usedParams = _commandAssemblyMap.TryGetValue(typeKey, out var existing) ? existing : new(); - usedParams.Add((key, i)); + usedParams.Add((key, -1)); // Use -1 to indicate remainder command in assembly map _commandAssemblyMap[typeKey] = usedParams; } + else + { + // Original logic for non-_remainder commands + for (var i = p - d; i <= p; i++) + { + _newCache[key] = _newCache.GetValueOrDefault(key, new()) ?? new(); + if (!_newCache[key].ContainsKey(i)) + { + _newCache[key][i] = new List(); + } + + // 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(); + usedParams.Add((key, i)); + _commandAssemblyMap[typeKey] = usedParams; + } + } } internal CacheResult GetCommand(string rawInput) @@ -70,10 +93,33 @@ internal CacheResult GetCommand(string rawInput) exactMatches.Add((cmd, parameters)); } } - else + + // Check remainder commands for this key + if (_remainderCache.TryGetValue(key, out var remainderCommands)) + { + foreach (var remainderCmd in remainderCommands) + { + // Check if this remainder command can handle the provided parameter count + var remainderParams = remainderCmd.Method.GetParameters(); + var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and _remainder itself + + if (parameters.Length >= requiredParams) + { + exactMatches.Add((remainderCmd, parameters)); + } + } + } + + if (exactMatches.Count == 0) { // Add all possible matches for the command name but different param counts possibleMatches.AddRange(argCounts.Values.SelectMany(x => x)); + + // Also add remainder commands as possible matches + if (_remainderCache.TryGetValue(key, out var remainderCmds)) + { + possibleMatches.AddRange(remainderCmds); + } } } } @@ -117,11 +163,35 @@ internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName exactMatches.Add((cmd, parameters)); } } - else + + // Check remainder commands for this key and assembly + if (_remainderCache.TryGetValue(key, out var remainderCommands)) + { + foreach (var remainderCmd in remainderCommands.Where(cmd => cmd.AssemblyName.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))) + { + // Check if this remainder command can handle the provided parameter count + var remainderParams = remainderCmd.Method.GetParameters(); + var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 1; // Exclude _remainder itself + var maxParams = remainderParams.Length - 1; // Exclude _remainder + + if (parameters.Length >= requiredParams && parameters.Length <= maxParams) + { + exactMatches.Add((remainderCmd, parameters)); + } + } + } + + if (exactMatches.Count == 0) { // Add all possible matches for the command name but different param counts possibleMatches.AddRange(argCounts.Values.SelectMany(x => x) .Where(cmd => cmd.AssemblyName.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); + + // Also add remainder commands as possible matches + if (_remainderCache.TryGetValue(key, out var remainderCmds)) + { + possibleMatches.AddRange(remainderCmds.Where(cmd => cmd.AssemblyName.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); + } } } } @@ -145,19 +215,35 @@ internal void RemoveCommandsFromType(Type t) } foreach (var (key, index) in commands) { - if (!_newCache.TryGetValue(key, out var dict)) + if (index == -1) // Remainder command { - continue; + if (_remainderCache.TryGetValue(key, out var remainderList)) + { + remainderList.RemoveAll(cmd => cmd.Method.DeclaringType == t); + + // If the list is now empty, remove the entry + if (remainderList.Count == 0) + { + _remainderCache.Remove(key); + } + } } - - if (dict.TryGetValue(index, out var cmdList)) + else // Regular command { - cmdList.RemoveAll(cmd => cmd.Method.DeclaringType == t); + if (!_newCache.TryGetValue(key, out var dict)) + { + continue; + } - // If the list is now empty, remove the entry - if (cmdList.Count == 0) + if (dict.TryGetValue(index, out var cmdList)) { - dict.Remove(index); + cmdList.RemoveAll(cmd => cmd.Method.DeclaringType == t); + + // If the list is now empty, remove the entry + if (cmdList.Count == 0) + { + dict.Remove(index); + } } } } @@ -167,6 +253,7 @@ internal void RemoveCommandsFromType(Type t) internal void Clear() { _newCache.Clear(); + _remainderCache.Clear(); } internal void Reset() diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index 9602d61..f620d5e 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -63,6 +63,14 @@ internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata comm return true; } + internal static bool HasRemainderParameter(CommandMetadata command) + { + if (command.Parameters.Length == 0) return false; + + var lastParam = command.Parameters[command.Parameters.Length - 1]; + return lastParam.Name == "_remainder" && lastParam.ParameterType == typeof(string); + } + internal static IEnumerable FindCloseMatches(ICommandContext ctx, string input) { // Look for the closest matches to the input command @@ -349,131 +357,304 @@ private static CommandResult HandleCommandSelection(ICommandContext ctx, int sel return result; } - private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args) + private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput = null) { var argCount = args?.Length ?? 0; var paramsCount = command.Parameters.Length; var commandArgs = new object[paramsCount + 1]; commandArgs[0] = ctx; + bool hasRemainder = HasRemainderParameter(command); + // Special case for commands with no parameters if (paramsCount == 0 && argCount == 0) { return (true, commandArgs, null); } - // Handle parameter count mismatch + // Handle remainder commands with special logic + if (hasRemainder) + { + return TryConvertParametersWithRemainder(ctx, command, args, originalInput, commandArgs); + } + + // Handle parameter count mismatch for non-remainder commands 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) + + // Handle missing parameters for non-remainder commands + if (argCount < paramsCount) { - var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); + var missingParams = command.Parameters.Skip(argCount); + var canDefault = missingParams.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) + // Convert provided arguments for non-remainder commands + for (var i = 0; i < Math.Min(argCount, paramsCount); i++) + { + var param = command.Parameters[i]; + var arg = args[i]; + + var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); + if (!success) + { + return (false, null, error); + } + + commandArgs[i + 1] = convertedValue; + } + + return (true, commandArgs, null); + } + + private static (bool Success, object[] Args, string Error) TryConvertParametersWithRemainder(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs) + { + var argCount = args?.Length ?? 0; + var paramsCount = command.Parameters.Length; + var remainderIndex = paramsCount - 1; // _remainder is always last + + // Calculate minimum required parameters (non-optional, non-remainder) + var requiredParamCount = 0; + for (int i = 0; i < remainderIndex; i++) + { + if (!command.Parameters[i].HasDefaultValue) + { + requiredParamCount++; + } + } + + // Check if we have enough arguments for required parameters + if (argCount < requiredParamCount) + { + return (false, null, $"Missing required parameters: expected at least {requiredParamCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); + } + + // Try different strategies to split arguments between regular params and remainder + // Start from the maximum possible and work backwards to handle optional parameters + var maxNonRemainderArgs = Math.Min(argCount, remainderIndex); + + for (int splitPoint = maxNonRemainderArgs; splitPoint >= requiredParamCount; splitPoint--) + { + var (success, error) = TryConvertWithSplitPoint(ctx, command, args, originalInput, commandArgs, splitPoint); + if (success) + { + return (true, commandArgs, null); + } + + // If conversion failed due to parameter type mismatch and we have optional parameters, + // try with fewer parameters (let optional parameters use defaults) + if (error != null && error.Contains("Parameter") && splitPoint > requiredParamCount) + { + continue; // Try next split point + } + + // If it's a different kind of error or we're at minimum required, return it + if (splitPoint == requiredParamCount) + { + return (false, null, error); + } + } + + return (false, null, "Failed to parse parameters"); + } + + private static (bool Success, string Error) TryConvertWithSplitPoint(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs, int splitPoint) + { + var paramsCount = command.Parameters.Length; + var remainderIndex = paramsCount - 1; + + // Convert regular parameters up to split point + for (int i = 0; i < splitPoint; i++) + { + var param = command.Parameters[i]; + var arg = args[i]; + + var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); + if (!success) + { + return (false, error); + } + + commandArgs[i + 1] = convertedValue; + } + + // Fill remaining optional parameters with defaults + for (int i = splitPoint; i < remainderIndex; i++) + { + var param = command.Parameters[i]; + if (param.HasDefaultValue) + { + commandArgs[i + 1] = param.DefaultValue; + } + else + { + return (false, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}) is required but no value provided"); + } + } + + // Handle remainder parameter + if (!string.IsNullOrEmpty(originalInput)) + { + commandArgs[remainderIndex + 1] = ExtractRemainderFromOriginalInput(originalInput, command, remainderIndex, splitPoint); + } + else + { + var remainderArgs = args.Skip(splitPoint).ToArray(); + commandArgs[remainderIndex + 1] = string.Join(" ", remainderArgs); + } + + return (true, null); + } + + private static (bool Success, object ConvertedValue, string Error) TryConvertSingleParameter(ICommandContext ctx, ParameterInfo param, string arg, int paramIndex) + { + try { - for (var i = 0; i < argCount; i++) + // Custom Converter + if (_converters.TryGetValue(param.ParameterType, out var customConverter)) { - var param = command.Parameters[i]; - var arg = args[i]; - bool conversionSuccess = false; - string conversionError = null; + var (converter, convertMethod, converterContextType) = customConverter; + if (!converterContextType.IsAssignableFrom(ctx.GetType())) + { + return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); + } + + var tryParseArgs = new object[] { ctx, arg }; try { - // Custom Converter - if (_converters.TryGetValue(param.ParameterType, out var customConverter)) + var result = convertMethod.Invoke(converter, tryParseArgs); + return (true, result, null); + } + catch (TargetInvocationException tie) + { + if (tie.InnerException is CommandException e) { - 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"; - } + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); } else { - var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); - try + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"); + } + } + catch (Exception) + { + return (false, null, $"Parameter {paramIndex + 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)) { - var val = defaultConverter.ConvertFromInvariantString(arg); + isDefined = Enum.IsDefined(param.ParameterType, enumIntVal); - // Separate, more robust enum validation - if (param.ParameterType.IsEnum) + if (!isDefined) { - 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)}"); - } - } + return (false, null, $"Parameter {paramIndex + 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}"; } } + + return (true, val, null); } - catch (Exception ex) + catch (Exception e) { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"; + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); } + } + } + catch (Exception ex) + { + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"); + } + } + + private static string ExtractRemainderFromOriginalInput(string originalInput, CommandMetadata command, int remainderParameterIndex, int splitPoint = -1) + { + // Remove the prefix (.) + var afterPrefix = originalInput.Substring(DEFAULT_PREFIX.Length); + + // Split by first space to separate command from parameters + var firstSpaceIndex = afterPrefix.IndexOf(' '); + if (firstSpaceIndex == -1) + { + return ""; // No parameters at all + } + + var parametersText = afterPrefix.Substring(firstSpaceIndex + 1); + + // If remainder is the first parameter, return all parameters + if (remainderParameterIndex == 0) + { + return parametersText; + } + + // We need to skip the first splitPoint parameters (or remainderParameterIndex if splitPoint not provided) + var parametersToSkip = splitPoint >= 0 ? splitPoint : remainderParameterIndex; + + // Parse through the parameters text respecting quotes + var currentParamIndex = 0; + var position = 0; + var inQuotes = false; - if (!conversionSuccess) + while (position < parametersText.Length && currentParamIndex < parametersToSkip) + { + var ch = parametersText[position]; + + // Handle escaped quotes + if (ch == '\\' && position + 1 < parametersText.Length && parametersText[position + 1] == '"') + { + position += 2; + continue; + } + + if (ch == '"') + { + inQuotes = !inQuotes; + } + else if (ch == ' ' && !inQuotes) + { + // Skip consecutive spaces + while (position < parametersText.Length && parametersText[position] == ' ') { - return (false, null, conversionError); + position++; } + currentParamIndex++; + continue; } + + position++; } - return (true, commandArgs, null); + // Return the remainder from this position + if (position < parametersText.Length) + { + return parametersText.Substring(position); + } + + return ""; } private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata command, string[] args, string input) @@ -486,7 +667,7 @@ private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata } // Try to convert parameters - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args, input); if (!success) { // Check for special internal error flag @@ -777,6 +958,12 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou var canConvert = parameters.All(param => { + if (param.Name == "_remainder" && param.ParameterType == typeof(string)) + { + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a _remainder parameter"); + return true; + } + 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"); diff --git a/VCF.Tests/RemainderParameterTests.cs b/VCF.Tests/RemainderParameterTests.cs new file mode 100644 index 0000000..c822de7 --- /dev/null +++ b/VCF.Tests/RemainderParameterTests.cs @@ -0,0 +1,144 @@ +using NUnit.Framework; +using VampireCommandFramework; + +namespace VCF.Tests +{ + public class RemainderParameterTests + { + [SetUp] + public void Setup() + { + CommandRegistry.Reset(); + Format.Mode = Format.FormatMode.None; + } + + #region Test Commands + + public class RemainderCommands + { + [Command("echo", description: "Echoes the remainder text")] + public void EchoRemainder(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Remainder: '{_remainder}'"); + } + + [Command("say", description: "Says something with a prefix")] + public void SayWithPrefix(ICommandContext ctx, string prefix, string _remainder) + { + ctx.Reply($"{prefix}: {_remainder}"); + } + + [Command("optional", description: "Command with optional parameter and remainder")] + public void OptionalWithRemainder(ICommandContext ctx, string required, int optional = 42, string _remainder = "") + { + ctx.Reply($"Required: {required}, Optional: {optional}, Remainder: '{_remainder}'"); + } + } + + public class ConflictingCommands + { + [Command("test", description: "Regular test with fixed params")] + public void TestRegular(ICommandContext ctx, string arg1, string arg2) + { + ctx.Reply($"Regular test: {arg1}, {arg2}"); + } + + [Command("test", description: "Test with remainder")] + public void TestRemainder(ICommandContext ctx, string arg1, string _remainder) + { + ctx.Reply($"Remainder test: {arg1}, '{_remainder}'"); + } + } + + #endregion + + [Test] + public void RemainderParameter_BasicCapture_HandlesAllCases() + { + CommandRegistry.RegisterCommandType(typeof(RemainderCommands)); + + // Test single word + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".echo hello"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Remainder: 'hello'"); + + // Test multiple words with special characters + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".echo hello world \"quoted text\" !@#$"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Remainder: 'hello world \"quoted text\" !@#$'"); + + // Test empty remainder + var ctx3 = new AssertReplyContext(); + var result3 = CommandRegistry.Handle(ctx3, ".echo"); + Assert.That(result3, Is.EqualTo(CommandResult.Success)); + ctx3.AssertReplyContains("Remainder: ''"); + } + + [Test] + public void RemainderWithRequiredParameters_Works() + { + CommandRegistry.RegisterCommandType(typeof(RemainderCommands)); + + // Test with required parameter + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".say INFO this is the remainder message"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("INFO: this is the remainder message"); + + // Test missing required parameter returns error + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".say"); + Assert.That(result2, Is.EqualTo(CommandResult.UsageError)); + } + + [Test] + public void RemainderWithOptionalParameters_HandlesAllScenarios() + { + CommandRegistry.RegisterCommandType(typeof(RemainderCommands)); + + // All parameters provided + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".optional test 99 this is the remainder"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Required: test, Optional: 99, Remainder: 'this is the remainder'"); + + // Optional parameter omitted + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".optional test this is the remainder"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Required: test, Optional: 42, Remainder: 'this is the remainder'"); + + // Both optional and remainder omitted + var ctx3 = new AssertReplyContext(); + var result3 = CommandRegistry.Handle(ctx3, ".optional test"); + Assert.That(result3, Is.EqualTo(CommandResult.Success)); + ctx3.AssertReplyContains("Required: test, Optional: 42, Remainder: ''"); + } + + [Test] + public void RemainderCommand_OverloadingResolution_SelectsCorrectCommand() + { + CommandRegistry.RegisterCommandType(typeof(ConflictingCommands)); + + // If it's ambiguous, should prompt for selection + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".test arg1 arg2"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Multiple commands match this input"); + + // More args than regular command should use remainder + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".test arg1 this is more than two arguments"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Remainder test: arg1, 'this is more than two arguments'"); + + // Fewer args than regular command should use remainder + var ctx3 = new AssertReplyContext(); + var result3 = CommandRegistry.Handle(ctx3, ".test arg1"); + Assert.That(result3, Is.EqualTo(CommandResult.Success)); + ctx3.AssertReplyContains("Remainder test: arg1, ''"); + } + } +} \ No newline at end of file From 987255e9ee9adf50bf06ee62bb02a507b1179a96 Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 13 Sep 2025 23:24:36 -0400 Subject: [PATCH 04/29] Command history won't store anymore duplicate sets of command/args Also whoops CommandRegistry.cs didn't have Unix line endings >_< --- VCF.Core/Registry/CommandRegistry.cs | 2102 +++++++++++++------------- 1 file changed, 1068 insertions(+), 1034 deletions(-) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index f620d5e..b5440cc 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -1,1034 +1,1068 @@ -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 = new(); - - 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 = new(); - - 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 bool HasRemainderParameter(CommandMetadata command) - { - if (command.Parameters.Length == 0) return false; - - var lastParam = command.Parameters[command.Parameters.Length - 1]; - return lastParam.Name == "_remainder" && lastParam.ParameterType == typeof(string); - } - - 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 - ? new[] { "" } - : cmd.GroupAttribute.ShortHand == null - ? new[] { cmd.GroupAttribute.Name + " " } - : new[] { cmd.GroupAttribute.Name + " ", cmd.GroupAttribute.ShortHand + " " }; - - var commandNames = cmd.Attribute.ShortHand == null - ? new[] { 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) - { - 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(assemblyName => - assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } - - // Get command(s) based on input - CacheResult matchedCommand = null; - if (assemblyName != null) - { - matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); - } - if (matchedCommand == null || !matchedCommand.IsMatched) - { - matchedCommand = _cache.GetCommand(input); - } - - var commands = matchedCommand.Commands; - - 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) - { - var (command, args) = commands.First(); - return ExecuteCommand(ctx, command, 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, args) 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.AssemblyName; - 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]; - 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.AssemblyName; - 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, string originalInput = null) - { - var argCount = args?.Length ?? 0; - var paramsCount = command.Parameters.Length; - var commandArgs = new object[paramsCount + 1]; - commandArgs[0] = ctx; - - bool hasRemainder = HasRemainderParameter(command); - - // Special case for commands with no parameters - if (paramsCount == 0 && argCount == 0) - { - return (true, commandArgs, null); - } - - // Handle remainder commands with special logic - if (hasRemainder) - { - return TryConvertParametersWithRemainder(ctx, command, args, originalInput, commandArgs); - } - - // Handle parameter count mismatch for non-remainder commands - if (argCount > paramsCount) - { - return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - - // Handle missing parameters for non-remainder commands - if (argCount < paramsCount) - { - var missingParams = command.Parameters.Skip(argCount); - var canDefault = missingParams.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; - } - } - - // Convert provided arguments for non-remainder commands - for (var i = 0; i < Math.Min(argCount, paramsCount); i++) - { - var param = command.Parameters[i]; - var arg = args[i]; - - var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); - if (!success) - { - return (false, null, error); - } - - commandArgs[i + 1] = convertedValue; - } - - return (true, commandArgs, null); - } - - private static (bool Success, object[] Args, string Error) TryConvertParametersWithRemainder(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs) - { - var argCount = args?.Length ?? 0; - var paramsCount = command.Parameters.Length; - var remainderIndex = paramsCount - 1; // _remainder is always last - - // Calculate minimum required parameters (non-optional, non-remainder) - var requiredParamCount = 0; - for (int i = 0; i < remainderIndex; i++) - { - if (!command.Parameters[i].HasDefaultValue) - { - requiredParamCount++; - } - } - - // Check if we have enough arguments for required parameters - if (argCount < requiredParamCount) - { - return (false, null, $"Missing required parameters: expected at least {requiredParamCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - - // Try different strategies to split arguments between regular params and remainder - // Start from the maximum possible and work backwards to handle optional parameters - var maxNonRemainderArgs = Math.Min(argCount, remainderIndex); - - for (int splitPoint = maxNonRemainderArgs; splitPoint >= requiredParamCount; splitPoint--) - { - var (success, error) = TryConvertWithSplitPoint(ctx, command, args, originalInput, commandArgs, splitPoint); - if (success) - { - return (true, commandArgs, null); - } - - // If conversion failed due to parameter type mismatch and we have optional parameters, - // try with fewer parameters (let optional parameters use defaults) - if (error != null && error.Contains("Parameter") && splitPoint > requiredParamCount) - { - continue; // Try next split point - } - - // If it's a different kind of error or we're at minimum required, return it - if (splitPoint == requiredParamCount) - { - return (false, null, error); - } - } - - return (false, null, "Failed to parse parameters"); - } - - private static (bool Success, string Error) TryConvertWithSplitPoint(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs, int splitPoint) - { - var paramsCount = command.Parameters.Length; - var remainderIndex = paramsCount - 1; - - // Convert regular parameters up to split point - for (int i = 0; i < splitPoint; i++) - { - var param = command.Parameters[i]; - var arg = args[i]; - - var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); - if (!success) - { - return (false, error); - } - - commandArgs[i + 1] = convertedValue; - } - - // Fill remaining optional parameters with defaults - for (int i = splitPoint; i < remainderIndex; i++) - { - var param = command.Parameters[i]; - if (param.HasDefaultValue) - { - commandArgs[i + 1] = param.DefaultValue; - } - else - { - return (false, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}) is required but no value provided"); - } - } - - // Handle remainder parameter - if (!string.IsNullOrEmpty(originalInput)) - { - commandArgs[remainderIndex + 1] = ExtractRemainderFromOriginalInput(originalInput, command, remainderIndex, splitPoint); - } - else - { - var remainderArgs = args.Skip(splitPoint).ToArray(); - commandArgs[remainderIndex + 1] = string.Join(" ", remainderArgs); - } - - return (true, null); - } - - private static (bool Success, object ConvertedValue, string Error) TryConvertSingleParameter(ICommandContext ctx, ParameterInfo param, string arg, int paramIndex) - { - try - { - // Custom Converter - if (_converters.TryGetValue(param.ParameterType, out var customConverter)) - { - var (converter, convertMethod, converterContextType) = customConverter; - - if (!converterContextType.IsAssignableFrom(ctx.GetType())) - { - return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); - } - - var tryParseArgs = new object[] { ctx, arg }; - try - { - var result = convertMethod.Invoke(converter, tryParseArgs); - return (true, result, null); - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException e) - { - return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); - } - else - { - return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"); - } - } - catch (Exception) - { - return (false, null, $"Parameter {paramIndex + 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 {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); - } - } - } - - return (true, val, null); - } - catch (Exception e) - { - return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); - } - } - } - catch (Exception ex) - { - return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"); - } - } - - private static string ExtractRemainderFromOriginalInput(string originalInput, CommandMetadata command, int remainderParameterIndex, int splitPoint = -1) - { - // Remove the prefix (.) - var afterPrefix = originalInput.Substring(DEFAULT_PREFIX.Length); - - // Split by first space to separate command from parameters - var firstSpaceIndex = afterPrefix.IndexOf(' '); - if (firstSpaceIndex == -1) - { - return ""; // No parameters at all - } - - var parametersText = afterPrefix.Substring(firstSpaceIndex + 1); - - // If remainder is the first parameter, return all parameters - if (remainderParameterIndex == 0) - { - return parametersText; - } - - // We need to skip the first splitPoint parameters (or remainderParameterIndex if splitPoint not provided) - var parametersToSkip = splitPoint >= 0 ? splitPoint : remainderParameterIndex; - - // Parse through the parameters text respecting quotes - var currentParamIndex = 0; - var position = 0; - var inQuotes = false; - - while (position < parametersText.Length && currentParamIndex < parametersToSkip) - { - var ch = parametersText[position]; - - // Handle escaped quotes - if (ch == '\\' && position + 1 < parametersText.Length && parametersText[position + 1] == '"') - { - position += 2; - continue; - } - - if (ch == '"') - { - inQuotes = !inQuotes; - } - else if (ch == ' ' && !inQuotes) - { - // Skip consecutive spaces - while (position < parametersText.Length && parametersText[position] == ' ') - { - position++; - } - currentParamIndex++; - continue; - } - - position++; - } - - // Return the remainder from this position - if (position < parametersText.Length) - { - return parametersText.Substring(position); - } - - return ""; - } - - 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, input); - 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 (param.Name == "_remainder" && param.ParameterType == typeof(string)) - { - Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a _remainder parameter"); - return true; - } - - 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.GetName().Name, 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); - } - } - - var assemblyName = assembly.GetName().Name; - AssemblyCommandMap.TryGetValue(assemblyName, out var commandKeyCache); - commandKeyCache ??= new(); - commandKeyCache[command] = keys; - AssemblyCommandMap[assemblyName] = commandKeyCache; - } - - internal static Dictionary>> AssemblyCommandMap { get; } = new(); - - public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); - - public static void UnregisterAssembly(Assembly assembly) - { - var assemblyName = assembly.GetName().Name; - - 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(assemblyName); - } -} +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 = new(); + + 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 = new(); + + 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 bool HasRemainderParameter(CommandMetadata command) + { + if (command.Parameters.Length == 0) return false; + + var lastParam = command.Parameters[command.Parameters.Length - 1]; + return lastParam.Name == "_remainder" && lastParam.ParameterType == typeof(string); + } + + 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 + ? new[] { "" } + : cmd.GroupAttribute.ShortHand == null + ? new[] { cmd.GroupAttribute.Name + " " } + : new[] { cmd.GroupAttribute.Name + " ", cmd.GroupAttribute.ShortHand + " " }; + + var commandNames = cmd.Attribute.ShortHand == null + ? new[] { 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) + { + 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(assemblyName => + assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + + if (isValidAssembly) + { + assemblyName = potentialAssemblyName; + commandInput = "." + afterPrefix.Substring(spaceIndex + 1); + } + } + + // Get command(s) based on input + CacheResult matchedCommand = null; + if (assemblyName != null) + { + matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); + } + if (matchedCommand == null || !matchedCommand.IsMatched) + { + matchedCommand = _cache.GetCommand(input); + } + + var commands = matchedCommand.Commands; + + 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) + { + var (command, args) = commands.First(); + return ExecuteCommand(ctx, command, 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, args) 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.AssemblyName; + 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]; + 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.AssemblyName; + 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, string originalInput = null) + { + var argCount = args?.Length ?? 0; + var paramsCount = command.Parameters.Length; + var commandArgs = new object[paramsCount + 1]; + commandArgs[0] = ctx; + + bool hasRemainder = HasRemainderParameter(command); + + // Special case for commands with no parameters + if (paramsCount == 0 && argCount == 0) + { + return (true, commandArgs, null); + } + + // Handle remainder commands with special logic + if (hasRemainder) + { + return TryConvertParametersWithRemainder(ctx, command, args, originalInput, commandArgs); + } + + // Handle parameter count mismatch for non-remainder commands + if (argCount > paramsCount) + { + return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); + } + + // Handle missing parameters for non-remainder commands + if (argCount < paramsCount) + { + var missingParams = command.Parameters.Skip(argCount); + var canDefault = missingParams.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; + } + } + + // Convert provided arguments for non-remainder commands + for (var i = 0; i < Math.Min(argCount, paramsCount); i++) + { + var param = command.Parameters[i]; + var arg = args[i]; + + var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); + if (!success) + { + return (false, null, error); + } + + commandArgs[i + 1] = convertedValue; + } + + return (true, commandArgs, null); + } + + private static (bool Success, object[] Args, string Error) TryConvertParametersWithRemainder(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs) + { + var argCount = args?.Length ?? 0; + var paramsCount = command.Parameters.Length; + var remainderIndex = paramsCount - 1; // _remainder is always last + + // Calculate minimum required parameters (non-optional, non-remainder) + var requiredParamCount = 0; + for (int i = 0; i < remainderIndex; i++) + { + if (!command.Parameters[i].HasDefaultValue) + { + requiredParamCount++; + } + } + + // Check if we have enough arguments for required parameters + if (argCount < requiredParamCount) + { + return (false, null, $"Missing required parameters: expected at least {requiredParamCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); + } + + // Try different strategies to split arguments between regular params and remainder + // Start from the maximum possible and work backwards to handle optional parameters + var maxNonRemainderArgs = Math.Min(argCount, remainderIndex); + + for (int splitPoint = maxNonRemainderArgs; splitPoint >= requiredParamCount; splitPoint--) + { + var (success, error) = TryConvertWithSplitPoint(ctx, command, args, originalInput, commandArgs, splitPoint); + if (success) + { + return (true, commandArgs, null); + } + + // If conversion failed due to parameter type mismatch and we have optional parameters, + // try with fewer parameters (let optional parameters use defaults) + if (error != null && error.Contains("Parameter") && splitPoint > requiredParamCount) + { + continue; // Try next split point + } + + // If it's a different kind of error or we're at minimum required, return it + if (splitPoint == requiredParamCount) + { + return (false, null, error); + } + } + + return (false, null, "Failed to parse parameters"); + } + + private static (bool Success, string Error) TryConvertWithSplitPoint(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput, object[] commandArgs, int splitPoint) + { + var paramsCount = command.Parameters.Length; + var remainderIndex = paramsCount - 1; + + // Convert regular parameters up to split point + for (int i = 0; i < splitPoint; i++) + { + var param = command.Parameters[i]; + var arg = args[i]; + + var (success, convertedValue, error) = TryConvertSingleParameter(ctx, param, arg, i); + if (!success) + { + return (false, error); + } + + commandArgs[i + 1] = convertedValue; + } + + // Fill remaining optional parameters with defaults + for (int i = splitPoint; i < remainderIndex; i++) + { + var param = command.Parameters[i]; + if (param.HasDefaultValue) + { + commandArgs[i + 1] = param.DefaultValue; + } + else + { + return (false, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}) is required but no value provided"); + } + } + + // Handle remainder parameter + if (!string.IsNullOrEmpty(originalInput)) + { + commandArgs[remainderIndex + 1] = ExtractRemainderFromOriginalInput(originalInput, command, remainderIndex, splitPoint); + } + else + { + var remainderArgs = args.Skip(splitPoint).ToArray(); + commandArgs[remainderIndex + 1] = string.Join(" ", remainderArgs); + } + + return (true, null); + } + + private static (bool Success, object ConvertedValue, string Error) TryConvertSingleParameter(ICommandContext ctx, ParameterInfo param, string arg, int paramIndex) + { + try + { + // Custom Converter + if (_converters.TryGetValue(param.ParameterType, out var customConverter)) + { + var (converter, convertMethod, converterContextType) = customConverter; + + if (!converterContextType.IsAssignableFrom(ctx.GetType())) + { + return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); + } + + var tryParseArgs = new object[] { ctx, arg }; + try + { + var result = convertMethod.Invoke(converter, tryParseArgs); + return (true, result, null); + } + catch (TargetInvocationException tie) + { + if (tie.InnerException is CommandException e) + { + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); + } + else + { + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"); + } + } + catch (Exception) + { + return (false, null, $"Parameter {paramIndex + 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 {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); + } + } + } + + return (true, val, null); + } + catch (Exception e) + { + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"); + } + } + } + catch (Exception ex) + { + return (false, null, $"Parameter {paramIndex + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"); + } + } + + private static string ExtractRemainderFromOriginalInput(string originalInput, CommandMetadata command, int remainderParameterIndex, int splitPoint = -1) + { + // Remove the prefix (.) + var afterPrefix = originalInput.Substring(DEFAULT_PREFIX.Length); + + // Split by first space to separate command from parameters + var firstSpaceIndex = afterPrefix.IndexOf(' '); + if (firstSpaceIndex == -1) + { + return ""; // No parameters at all + } + + var parametersText = afterPrefix.Substring(firstSpaceIndex + 1); + + // If remainder is the first parameter, return all parameters + if (remainderParameterIndex == 0) + { + return parametersText; + } + + // We need to skip the first splitPoint parameters (or remainderParameterIndex if splitPoint not provided) + var parametersToSkip = splitPoint >= 0 ? splitPoint : remainderParameterIndex; + + // Parse through the parameters text respecting quotes + var currentParamIndex = 0; + var position = 0; + var inQuotes = false; + + while (position < parametersText.Length && currentParamIndex < parametersToSkip) + { + var ch = parametersText[position]; + + // Handle escaped quotes + if (ch == '\\' && position + 1 < parametersText.Length && parametersText[position + 1] == '"') + { + position += 2; + continue; + } + + if (ch == '"') + { + inQuotes = !inQuotes; + } + else if (ch == ' ' && !inQuotes) + { + // Skip consecutive spaces + while (position < parametersText.Length && parametersText[position] == ' ') + { + position++; + } + currentParamIndex++; + continue; + } + + position++; + } + + // Return the remainder from this position + if (position < parametersText.Length) + { + return parametersText.Substring(position); + } + + return ""; + } + + 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, input); + 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; + } + + // Check if this exact command with same arguments already exists in history + for (int i = 0; i < history.Count; i++) + { + var historyEntry = history[i]; + + // Compare command metadata (same command method) and arguments + if (historyEntry.Command.Method == command.Method && + historyEntry.Command.Attribute.Name == command.Attribute.Name && + ArgsEqual(historyEntry.Args, args)) + { + // Remove the existing duplicate + history.RemoveAt(i); + break; // Only remove the first match found as it should be the only one + } + } + + // 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 bool ArgsEqual(object[] args1, object[] args2) + { + if (args1 == null && args2 == null) return true; + if (args1 == null || args2 == null) return false; + if (args1.Length != args2.Length) return false; + + // Skipping the first index as that is always the context + for (int i = 1; i < args1.Length; i++) + { + if (!Equals(args1[i], args2[i])) + { + return false; + } + } + + return true; + } + + 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 (param.Name == "_remainder" && param.ParameterType == typeof(string)) + { + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a _remainder parameter"); + return true; + } + + 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.GetName().Name, 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); + } + } + + var assemblyName = assembly.GetName().Name; + AssemblyCommandMap.TryGetValue(assemblyName, out var commandKeyCache); + commandKeyCache ??= new(); + commandKeyCache[command] = keys; + AssemblyCommandMap[assemblyName] = commandKeyCache; + } + + internal static Dictionary>> AssemblyCommandMap { get; } = new(); + + public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); + + public static void UnregisterAssembly(Assembly assembly) + { + var assemblyName = assembly.GetName().Name; + + 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(assemblyName); + } +} From 650805fff26182f3ecfa38f79faec8808c92689c Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 14 Sep 2025 09:06:43 -0400 Subject: [PATCH 05/29] Fixing a few compile warnings --- VCF.Core/Basics/BepInExConfigCommands.cs | 2 +- VCF.Core/Breadstone/ChatHook.cs | 75 ++++++++++++------------ 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/VCF.Core/Basics/BepInExConfigCommands.cs b/VCF.Core/Basics/BepInExConfigCommands.cs index a743f50..7743a32 100644 --- a/VCF.Core/Basics/BepInExConfigCommands.cs +++ b/VCF.Core/Basics/BepInExConfigCommands.cs @@ -58,7 +58,7 @@ public void DumpConfig(ICommandContext ctx, string pluginGuid, string section, s ctx.SysReply($"Set {def.Key} = {convertedValue}"); } - catch (Exception e) + catch (Exception) { throw ctx.Error($"Can not convert {value} to {entry.SettingType}"); } diff --git a/VCF.Core/Breadstone/ChatHook.cs b/VCF.Core/Breadstone/ChatHook.cs index f0f512f..326139f 100644 --- a/VCF.Core/Breadstone/ChatHook.cs +++ b/VCF.Core/Breadstone/ChatHook.cs @@ -17,55 +17,52 @@ public static class ChatMessageSystem_Patch { public static void Prefix(ChatMessageSystem __instance) { - if (__instance.__query_661171423_0 != null) + NativeArray entities = __instance.__query_661171423_0.ToEntityArray(Allocator.Temp); + foreach (var entity in entities) { - NativeArray entities = __instance.__query_661171423_0.ToEntityArray(Allocator.Temp); - foreach (var entity in entities) - { - var fromData = __instance.EntityManager.GetComponentData(entity); - var userData = __instance.EntityManager.GetComponentData(fromData.User); - var chatEventData = __instance.EntityManager.GetComponentData(entity); + var fromData = __instance.EntityManager.GetComponentData(entity); + var userData = __instance.EntityManager.GetComponentData(fromData.User); + var chatEventData = __instance.EntityManager.GetComponentData(entity); - var messageText = chatEventData.MessageText.ToString(); + var messageText = chatEventData.MessageText.ToString(); - if (!messageText.StartsWith(".") || messageText.StartsWith("..")) continue; + if (!messageText.StartsWith(".") || messageText.StartsWith("..")) continue; - VChatEvent ev = new VChatEvent(fromData.User, fromData.Character, messageText, chatEventData.MessageType, userData); - var ctx = new ChatCommandContext(ev); + VChatEvent ev = new VChatEvent(fromData.User, fromData.Character, messageText, chatEventData.MessageType, userData); + var ctx = new ChatCommandContext(ev); - CommandResult result; - try - { - result = CommandRegistry.Handle(ctx, messageText); - } - catch (Exception e) - { - Log.Error($"Error while handling chat message {e}"); - continue; - } + CommandResult result; + try + { + result = CommandRegistry.Handle(ctx, messageText); + } + catch (Exception e) + { + Log.Error($"Error while handling chat message {e}"); + continue; + } - // Legacy .help pass through support - if (result == CommandResult.Success && messageText.StartsWith(".help-legacy", System.StringComparison.InvariantCulture)) - { - chatEventData.MessageText = messageText.Replace("-legacy", string.Empty); - __instance.EntityManager.SetComponentData(entity, chatEventData); - continue; - } - else if (result == CommandResult.Unmatched) - { - var sb = new StringBuilder(); + // Legacy .help pass through support + if (result == CommandResult.Success && messageText.StartsWith(".help-legacy", System.StringComparison.InvariantCulture)) + { + chatEventData.MessageText = messageText.Replace("-legacy", string.Empty); + __instance.EntityManager.SetComponentData(entity, chatEventData); + continue; + } + else if (result == CommandResult.Unmatched) + { + var sb = new StringBuilder(); - sb.AppendLine($"Command not found: {messageText.Color(Color.Command)}"); + 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()); + 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)))}"); } - VWorld.Server.EntityManager.DestroyEntity(entity); + ctx.SysReply(sb.ToString()); } + VWorld.Server.EntityManager.DestroyEntity(entity); } } } \ No newline at end of file From 7a0ee8d1774f9f3b26c19723bfe5b21a3074a169 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 14 Sep 2025 09:10:16 -0400 Subject: [PATCH 06/29] Command History persists across server restarts. Loaded at first command usage for a player --- VCF.Core/Registry/CommandRegistry.cs | 256 ++++++++++++++++++++++++--- 1 file changed, 234 insertions(+), 22 deletions(-) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index b5440cc..23183f2 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Linq; using System.Reflection; using System.Text; +using BepInEx; using VampireCommandFramework.Basics; using VampireCommandFramework.Common; using VampireCommandFramework.Registry; @@ -29,6 +31,9 @@ internal static void Reset() AssemblyCommandMap.Clear(); _converters.Clear(); _cache = new(); + _commandHistory.Clear(); + _loadedHistories.Clear(); + _pendingCommands.Clear(); } // todo: document this default behavior, it's just not something to ship without but you can Middlewares.Claer(); @@ -38,8 +43,14 @@ internal static void Reset() // Store pending commands for selection private static Dictionary commands)> _pendingCommands = new(); - private static Dictionary> _commandHistory = new(); + private static Dictionary> _commandHistory = new(); private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands + + // Track which users have had their history loaded this session + private static HashSet _loadedHistories = new(); + + // Command history directory path + private static string HistoryDirectory => Path.Combine(Path.Combine(Paths.ConfigPath, PluginInfo.PLUGIN_NAME), "CommandHistory"); internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata command) { @@ -181,8 +192,6 @@ 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)); @@ -195,6 +204,13 @@ private static void HandleAfterExecute(ICommandContext ctx, CommandMetadata comm public static CommandResult Handle(ICommandContext ctx, string input) { + // Load command history for this user if it's their first command this session + var platformId = GetPlatformId(ctx); + if (platformId.HasValue && !_loadedHistories.Contains(platformId.Value)) + { + LoadHistoryFromFile(ctx, platformId.Value); + } + // Check if this is a command selection (e.g., .1, .2, etc.) if (input.StartsWith(DEFAULT_PREFIX) && input.Length > 1) { @@ -309,12 +325,13 @@ public static CommandResult Handle(ICommandContext ctx, string input) if (successfulCommands.Count == 1) { var (command, commandArgs, _) = successfulCommands[0]; - AddToCommandHistory(ctx.Name, input, command, commandArgs); + AddToCommandHistory(ctx, 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 pendingKey = platformId.HasValue ? platformId.Value.ToString() : ctx.Name; + _pendingCommands[pendingKey] = (input, successfulCommands); { var sb = new StringBuilder(); @@ -337,7 +354,10 @@ public static CommandResult Handle(ICommandContext ctx, string input) private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) { - if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.commands.Count == 0) + var platformId = GetPlatformId(ctx); + var pendingKey = platformId.HasValue ? platformId.Value.ToString() : ctx.Name; + + if (!_pendingCommands.TryGetValue(pendingKey, out var pendingCommands) || pendingCommands.commands.Count == 0) { ctx.SysReply($"{"[error]".Color(Color.Red)} No command selection is pending."); return CommandResult.CommandError; @@ -351,9 +371,9 @@ private static CommandResult HandleCommandSelection(ICommandContext ctx, int sel var (command, args, _) = pendingCommands.commands[selectedIndex - 1]; - AddToCommandHistory(ctx.Name, pendingCommands.input, command, args); + AddToCommandHistory(ctx, pendingCommands.input, command, args); var result = ExecuteCommandWithArgs(ctx, command, args); - _pendingCommands.Remove(ctx.Name); + _pendingCommands.Remove(pendingKey); return result; } @@ -683,7 +703,7 @@ private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata return CommandResult.UsageError; } - AddToCommandHistory(ctx.Name, input, command, commandArgs); + AddToCommandHistory(ctx, input, command, commandArgs); return ExecuteCommandWithArgs(ctx, command, commandArgs); } @@ -758,19 +778,28 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command return CommandResult.Success; } - private static void AddToCommandHistory(string contextName, string input, CommandMetadata command, object[] args) + private static void AddToCommandHistory(ICommandContext ctx, 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)) + var platformId = GetPlatformId(ctx); + if (!platformId.HasValue) + { + return; // Cannot save history for non-chat contexts + } + + // Create the history list for this platform ID if it doesn't exist yet + if (!_commandHistory.TryGetValue(platformId.Value, out var history)) { history = new List<(string input, CommandMetadata Command, object[] Args)>(); - _commandHistory[contextName] = history; + _commandHistory[platformId.Value] = history; } // Check if this exact command with same arguments already exists in history for (int i = 0; i < history.Count; i++) { - var historyEntry = history[i]; + var historyEntry = history[i]; + + // Skip entries that couldn't be parsed at load + if (historyEntry.Command == null) continue; // Compare command metadata (same command method) and arguments if (historyEntry.Command.Method == command.Method && @@ -791,15 +820,18 @@ private static void AddToCommandHistory(string contextName, string input, Comman { history.RemoveAt(history.Count - 1); } + + // Save the updated history to file + SaveHistoryToFile(platformId.Value, history); } private static bool ArgsEqual(object[] args1, object[] args2) { if (args1 == null && args2 == null) return true; if (args1 == null || args2 == null) return false; - if (args1.Length != args2.Length) return false; - - // Skipping the first index as that is always the context + if (args1.Length != args2.Length) return false; + + // Skipping the first index as that is always the context for (int i = 1; i < args1.Length; i++) { if (!Equals(args1[i], args2[i])) @@ -813,11 +845,18 @@ private static bool ArgsEqual(object[] args1, object[] args2) private static void HandleCommandHistory(ICommandContext ctx, string input) { + var platformId = GetPlatformId(ctx); + if (!platformId.HasValue) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} Command history is not available for this context type."); + return; + } + // 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) + // Check if the command history exists for this platform ID + if (!_commandHistory.TryGetValue(platformId.Value, out var history) || history.Count == 0) { ctx.SysReply($"{"[error]".Color(Color.Red)} No command history available."); return; @@ -843,7 +882,17 @@ private static void HandleCommandHistory(ICommandContext ctx, string input) { 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); + + // If Command and Args are available (successfully parsed), use them directly + if (selectedCommand.Command != null && selectedCommand.Args != null) + { + ExecuteCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); + } + else + { + // Fall back to re-parsing if command wasn't successfully parsed during load + Handle(ctx, selectedCommand.input); + } return; } @@ -852,13 +901,176 @@ private static void HandleCommandHistory(ICommandContext ctx, string input) { var mostRecent = history[0]; ctx.SysReply($"Repeating most recent command: {mostRecent.input.Color(Color.Command)}"); - ExecuteCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); + + // If Command and Args are available (successfully parsed), use them directly + if (mostRecent.Command != null && mostRecent.Args != null) + { + ExecuteCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); + } + else + { + // Fall back to re-parsing if command wasn't successfully parsed during load + Handle(ctx, mostRecent.input); + } 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."); - } + } + + private static void SaveHistoryToFile(ulong platformId, List<(string input, CommandMetadata Command, object[] Args)> history) + { + try + { + if (!Directory.Exists(HistoryDirectory)) + { + Directory.CreateDirectory(HistoryDirectory); + } + + string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + var inputsOnly = history.Select(h => h.input).ToArray(); + File.WriteAllLines(filePath, inputsOnly); + } + catch (Exception ex) + { + Log.Error($"Failed to save command history for platform ID {platformId}: {ex.Message}"); + } + } + + private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) + { + try + { + string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + + if (!File.Exists(filePath)) + { + return; // No history file exists + } + + var lines = File.ReadAllLines(filePath); + if (lines.Length == 0) + { + return; // Empty file + } + + var reconstructedHistory = new List<(string input, CommandMetadata Command, object[] Args)>(); + + // Process lines in reverse to maintain chronological order (most recent first) + lines.Reverse(); + foreach (var input in lines) + { + try + { + // Parse the command to get CommandMetadata and Args + var (command, args) = ParseCommandForHistory(ctx, input); + if (command != null && args != null) + { + reconstructedHistory.Add((input, command, args)); + } + else + { + // If parsing fails, still add the input for display purposes + reconstructedHistory.Add((input, null, null)); + } + } + catch (Exception) + { + // If individual command parsing fails, add with null values + reconstructedHistory.Add((input, null, null)); + } + } + + _commandHistory[platformId] = reconstructedHistory; + _loadedHistories.Add(platformId); + } + catch (Exception ex) + { + Log.Error($"Failed to load command history for platform ID {platformId}: {ex.Message}"); + _loadedHistories.Add(platformId); // Mark as loaded to avoid repeated attempts + } + } + + private static ulong? GetPlatformId(ICommandContext ctx) + { + if (ctx is ChatCommandContext chatCtx) + { + return chatCtx.User.PlatformId; + } + return null; + } + + private static (CommandMetadata command, object[] args) ParseCommandForHistory(ICommandContext ctx, string input) + { + try + { + // Ensure the command starts with the prefix + if (!input.StartsWith(DEFAULT_PREFIX)) + { + return (null, null); + } + + // 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; + + 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(assemblyName => + assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + + if (isValidAssembly) + { + assemblyName = potentialAssemblyName; + commandInput = "." + afterPrefix.Substring(spaceIndex + 1); + } + } + + // Get command(s) based on input + CacheResult matchedCommand = null; + if (assemblyName != null) + { + matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); + } + if (matchedCommand == null || !matchedCommand.IsMatched) + { + matchedCommand = _cache.GetCommand(input); + } + + var commands = matchedCommand.Commands; + + if (!matchedCommand.IsMatched || !commands.Any()) + { + return (null, null); + } + + // Try to find the first command that can be parsed successfully + foreach (var (command, cmdArgs) in commands) + { + if (!CanCommandExecute(ctx, command)) continue; + + var (success, commandArgs, error) = TryConvertParameters(ctx, command, cmdArgs, input); + if (success) + { + return (command, commandArgs); + } + } + + return (null, null); + } + catch (Exception) + { + return (null, null); + } + } public static void UnregisterConverter(Type converter) { From 487e87f5e65a00cf585c6ce67618b6a539660aa4 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 14 Sep 2025 09:42:36 -0400 Subject: [PATCH 07/29] Moved Command History code into its own static class --- VCF.Core/Registry/CommandHistory.cs | 349 +++++++++++++++++++++++++++ VCF.Core/Registry/CommandRegistry.cs | 324 ++----------------------- 2 files changed, 366 insertions(+), 307 deletions(-) create mode 100644 VCF.Core/Registry/CommandHistory.cs diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs new file mode 100644 index 0000000..6fe5359 --- /dev/null +++ b/VCF.Core/Registry/CommandHistory.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using BepInEx; +using VampireCommandFramework.Common; +using VampireCommandFramework.Registry; + +using static VampireCommandFramework.Format; + +namespace VampireCommandFramework.Registry; + +/// +/// Manages command history functionality for the VampireCommandFramework +/// +public static class CommandHistory +{ + #region Private Fields + + private static Dictionary> _commandHistory = new(); + private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands + + // Track which users have had their history loaded this session + private static HashSet _loadedHistories = new(); + + // Command history directory path + private static string HistoryDirectory => Path.Combine(Path.Combine(Paths.ConfigPath, PluginInfo.PLUGIN_NAME), "CommandHistory"); + + #endregion + + #region Public Methods + + internal static void Reset() + { + _commandHistory.Clear(); + _loadedHistories.Clear(); + } + + internal static bool IsHistoryLoaded(ulong platformId) + { + return _loadedHistories.Contains(platformId); + } + + internal static void EnsureHistoryLoaded(ICommandContext ctx) + { + var platformId = GetPlatformId(ctx); + if (platformId.HasValue && !_loadedHistories.Contains(platformId.Value)) + { + LoadHistoryFromFile(ctx, platformId.Value); + } + } + + internal static void AddToHistory(ICommandContext ctx, string input, CommandMetadata command, object[] args) + { + var platformId = GetPlatformId(ctx); + if (!platformId.HasValue) + { + return; // Cannot save history for non-chat contexts + } + + // Create the history list for this platform ID if it doesn't exist yet + if (!_commandHistory.TryGetValue(platformId.Value, out var history)) + { + history = new List<(string input, CommandMetadata Command, object[] Args)>(); + _commandHistory[platformId.Value] = history; + } + + // Check if this exact command with same arguments already exists in history + for (int i = 0; i < history.Count; i++) + { + var historyEntry = history[i]; + + // Skip entries that couldn't be parsed at load + if (historyEntry.Command == null) continue; + + // Compare command metadata (same command method) and arguments + if (historyEntry.Command.Method == command.Method && + historyEntry.Command.Attribute.Name == command.Attribute.Name && + ArgsEqual(historyEntry.Args, args)) + { + // Remove the existing duplicate + history.RemoveAt(i); + break; // Only remove the first match found as it should be the only one + } + } + + // 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); + } + + // Save the updated history to file + SaveHistoryToFile(platformId.Value, history); + } + + internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string input, Func handleCommand, Func executeCommandWithArgs) + { + var platformId = GetPlatformId(ctx); + if (!platformId.HasValue) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} Command history is not available for this context type."); + return CommandResult.CommandError; + } + + // Remove the ".!" prefix + string command = input.Substring(2).Trim(); + + // Check if the command history exists for this platform ID + if (!_commandHistory.TryGetValue(platformId.Value, out var history) || history.Count == 0) + { + ctx.SysReply($"{"[error]".Color(Color.Red)} No command history available."); + return CommandResult.CommandError; + } + + // 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 CommandResult.Success; + } + + // 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)}"); + + // If Command and Args are available (successfully parsed), use them directly + if (selectedCommand.Command != null && selectedCommand.Args != null) + { + return executeCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); + } + else + { + // Fall back to re-parsing if command wasn't successfully parsed during load + return handleCommand(ctx, selectedCommand.input); + } + } + + // 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)}"); + + // If Command and Args are available (successfully parsed), use them directly + if (mostRecent.Command != null && mostRecent.Args != null) + { + return executeCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); + } + else + { + // Fall back to re-parsing if command wasn't successfully parsed during load + return handleCommand(ctx, mostRecent.input); + } + } + + // 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."); + return CommandResult.UsageError; + } + + #endregion + + #region Private Helper Methods + + private static bool ArgsEqual(object[] args1, object[] args2) + { + if (args1 == null && args2 == null) return true; + if (args1 == null || args2 == null) return false; + if (args1.Length != args2.Length) return false; + + // Skipping the first index as that is always the context + for (int i = 1; i < args1.Length; i++) + { + if (!Equals(args1[i], args2[i])) + { + return false; + } + } + + return true; + } + + private static void SaveHistoryToFile(ulong platformId, List<(string input, CommandMetadata Command, object[] Args)> history) + { + try + { + if (!Directory.Exists(HistoryDirectory)) + { + Directory.CreateDirectory(HistoryDirectory); + } + + string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + var inputsOnly = history.Select(h => h.input).ToArray(); + File.WriteAllLines(filePath, inputsOnly); + } + catch (Exception ex) + { + Log.Error($"Failed to save command history for platform ID {platformId}: {ex.Message}"); + } + } + + private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) + { + try + { + string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + + if (!File.Exists(filePath)) + { + return; // No history file exists + } + + var lines = File.ReadAllLines(filePath); + if (lines.Length == 0) + { + return; // Empty file + } + + var reconstructedHistory = new List<(string input, CommandMetadata Command, object[] Args)>(); + + // Process lines in reverse to maintain chronological order (most recent first) + lines.Reverse(); + foreach (var input in lines) + { + try + { + // Parse the command to get CommandMetadata and Args + var (command, args) = ParseCommandForHistory(ctx, input); + if (command != null && args != null) + { + reconstructedHistory.Add((input, command, args)); + } + else + { + // If parsing fails, still add the input for display purposes + reconstructedHistory.Add((input, null, null)); + } + } + catch (Exception) + { + // If individual command parsing fails, add with null values + reconstructedHistory.Add((input, null, null)); + } + } + + _commandHistory[platformId] = reconstructedHistory; + _loadedHistories.Add(platformId); + } + catch (Exception ex) + { + Log.Error($"Failed to load command history for platform ID {platformId}: {ex.Message}"); + _loadedHistories.Add(platformId); // Mark as loaded to avoid repeated attempts + } + } + + private static ulong? GetPlatformId(ICommandContext ctx) + { + if (ctx is ChatCommandContext chatCtx) + { + return chatCtx.User.PlatformId; + } + return null; + } + + private static (CommandMetadata command, object[] args) ParseCommandForHistory(ICommandContext ctx, string input) + { + try + { + // Ensure the command starts with the prefix + if (!input.StartsWith(CommandRegistry.DEFAULT_PREFIX)) + { + return (null, null); + } + + // Remove the prefix for processing + string afterPrefix = input.Substring(CommandRegistry.DEFAULT_PREFIX.Length); + + // Check if this could be an assembly-specific command + string assemblyName = null; + string commandInput = 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 = CommandRegistry.AssemblyCommandMap.Keys.Any(assemblyName => + assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + + if (isValidAssembly) + { + assemblyName = potentialAssemblyName; + commandInput = "." + afterPrefix.Substring(spaceIndex + 1); + } + } + + // Get command(s) based on input - we need to access the cache through CommandRegistry + var matchedCommand = CommandRegistry.GetCommandFromCache(commandInput, assemblyName); + + if (matchedCommand == null || !matchedCommand.IsMatched) + { + matchedCommand = CommandRegistry.GetCommandFromCache(input); + } + + var commands = matchedCommand.Commands; + + if (!matchedCommand.IsMatched || !commands.Any()) + { + return (null, null); + } + + // Try to find the first command that can be parsed successfully + foreach (var (command, cmdArgs) in commands) + { + if (!CommandRegistry.CanCommandExecute(ctx, command)) continue; + + var (success, commandArgs, error) = CommandRegistry.TryConvertParameters(ctx, command, cmdArgs, input); + if (success) + { + return (command, commandArgs); + } + } + + return (null, null); + } + catch (Exception) + { + return (null, null); + } + } + + #endregion +} diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index 23183f2..9649c6a 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -31,8 +31,7 @@ internal static void Reset() AssemblyCommandMap.Clear(); _converters.Clear(); _cache = new(); - _commandHistory.Clear(); - _loadedHistories.Clear(); + CommandHistory.Reset(); _pendingCommands.Clear(); } @@ -43,14 +42,14 @@ internal static void Reset() // Store pending commands for selection private static Dictionary commands)> _pendingCommands = new(); - private static Dictionary> _commandHistory = new(); - private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands - - // Track which users have had their history loaded this session - private static HashSet _loadedHistories = new(); - - // Command history directory path - private static string HistoryDirectory => Path.Combine(Path.Combine(Paths.ConfigPath, PluginInfo.PLUGIN_NAME), "CommandHistory"); + internal static CacheResult GetCommandFromCache(string input, string assemblyName = null) + { + if (assemblyName != null) + { + return _cache.GetCommandFromAssembly(input, assemblyName); + } + return _cache.GetCommand(input); + } internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata command) { @@ -205,11 +204,7 @@ private static void HandleAfterExecute(ICommandContext ctx, CommandMetadata comm public static CommandResult Handle(ICommandContext ctx, string input) { // Load command history for this user if it's their first command this session - var platformId = GetPlatformId(ctx); - if (platformId.HasValue && !_loadedHistories.Contains(platformId.Value)) - { - LoadHistoryFromFile(ctx, platformId.Value); - } + CommandHistory.EnsureHistoryLoaded(ctx); // Check if this is a command selection (e.g., .1, .2, etc.) if (input.StartsWith(DEFAULT_PREFIX) && input.Length > 1) @@ -229,8 +224,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) if (input.Trim().StartsWith(".!")) { - HandleCommandHistory(ctx, input.Trim()); - return CommandResult.Success; + return CommandHistory.HandleHistoryCommand(ctx, input.Trim(), Handle, ExecuteCommandWithArgs); } // Remove the prefix for processing @@ -325,11 +319,12 @@ public static CommandResult Handle(ICommandContext ctx, string input) if (successfulCommands.Count == 1) { var (command, commandArgs, _) = successfulCommands[0]; - AddToCommandHistory(ctx, input, command, commandArgs); + CommandHistory.AddToHistory(ctx, input, command, commandArgs); return ExecuteCommandWithArgs(ctx, command, commandArgs); } // Case 3: Multiple commands succeeded - store and ask user to select + var platformId = GetPlatformId(ctx); var pendingKey = platformId.HasValue ? platformId.Value.ToString() : ctx.Name; _pendingCommands[pendingKey] = (input, successfulCommands); @@ -371,13 +366,13 @@ private static CommandResult HandleCommandSelection(ICommandContext ctx, int sel var (command, args, _) = pendingCommands.commands[selectedIndex - 1]; - AddToCommandHistory(ctx, pendingCommands.input, command, args); + CommandHistory.AddToHistory(ctx, pendingCommands.input, command, args); var result = ExecuteCommandWithArgs(ctx, command, args); _pendingCommands.Remove(pendingKey); return result; } - private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput = null) + internal static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args, string originalInput = null) { var argCount = args?.Length ?? 0; var paramsCount = command.Parameters.Length; @@ -703,7 +698,7 @@ private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata return CommandResult.UsageError; } - AddToCommandHistory(ctx, input, command, commandArgs); + CommandHistory.AddToHistory(ctx, input, command, commandArgs); return ExecuteCommandWithArgs(ctx, command, commandArgs); } @@ -778,220 +773,6 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command return CommandResult.Success; } - private static void AddToCommandHistory(ICommandContext ctx, string input, CommandMetadata command, object[] args) - { - var platformId = GetPlatformId(ctx); - if (!platformId.HasValue) - { - return; // Cannot save history for non-chat contexts - } - - // Create the history list for this platform ID if it doesn't exist yet - if (!_commandHistory.TryGetValue(platformId.Value, out var history)) - { - history = new List<(string input, CommandMetadata Command, object[] Args)>(); - _commandHistory[platformId.Value] = history; - } - - // Check if this exact command with same arguments already exists in history - for (int i = 0; i < history.Count; i++) - { - var historyEntry = history[i]; - - // Skip entries that couldn't be parsed at load - if (historyEntry.Command == null) continue; - - // Compare command metadata (same command method) and arguments - if (historyEntry.Command.Method == command.Method && - historyEntry.Command.Attribute.Name == command.Attribute.Name && - ArgsEqual(historyEntry.Args, args)) - { - // Remove the existing duplicate - history.RemoveAt(i); - break; // Only remove the first match found as it should be the only one - } - } - - // 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); - } - - // Save the updated history to file - SaveHistoryToFile(platformId.Value, history); - } - - private static bool ArgsEqual(object[] args1, object[] args2) - { - if (args1 == null && args2 == null) return true; - if (args1 == null || args2 == null) return false; - if (args1.Length != args2.Length) return false; - - // Skipping the first index as that is always the context - for (int i = 1; i < args1.Length; i++) - { - if (!Equals(args1[i], args2[i])) - { - return false; - } - } - - return true; - } - - private static void HandleCommandHistory(ICommandContext ctx, string input) - { - var platformId = GetPlatformId(ctx); - if (!platformId.HasValue) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} Command history is not available for this context type."); - return; - } - - // Remove the ".!" prefix - string command = input.Substring(2).Trim(); - - // Check if the command history exists for this platform ID - if (!_commandHistory.TryGetValue(platformId.Value, 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)}"); - - // If Command and Args are available (successfully parsed), use them directly - if (selectedCommand.Command != null && selectedCommand.Args != null) - { - ExecuteCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); - } - else - { - // Fall back to re-parsing if command wasn't successfully parsed during load - Handle(ctx, selectedCommand.input); - } - 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)}"); - - // If Command and Args are available (successfully parsed), use them directly - if (mostRecent.Command != null && mostRecent.Args != null) - { - ExecuteCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); - } - else - { - // Fall back to re-parsing if command wasn't successfully parsed during load - Handle(ctx, mostRecent.input); - } - 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."); - } - - private static void SaveHistoryToFile(ulong platformId, List<(string input, CommandMetadata Command, object[] Args)> history) - { - try - { - if (!Directory.Exists(HistoryDirectory)) - { - Directory.CreateDirectory(HistoryDirectory); - } - - string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); - var inputsOnly = history.Select(h => h.input).ToArray(); - File.WriteAllLines(filePath, inputsOnly); - } - catch (Exception ex) - { - Log.Error($"Failed to save command history for platform ID {platformId}: {ex.Message}"); - } - } - - private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) - { - try - { - string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); - - if (!File.Exists(filePath)) - { - return; // No history file exists - } - - var lines = File.ReadAllLines(filePath); - if (lines.Length == 0) - { - return; // Empty file - } - - var reconstructedHistory = new List<(string input, CommandMetadata Command, object[] Args)>(); - - // Process lines in reverse to maintain chronological order (most recent first) - lines.Reverse(); - foreach (var input in lines) - { - try - { - // Parse the command to get CommandMetadata and Args - var (command, args) = ParseCommandForHistory(ctx, input); - if (command != null && args != null) - { - reconstructedHistory.Add((input, command, args)); - } - else - { - // If parsing fails, still add the input for display purposes - reconstructedHistory.Add((input, null, null)); - } - } - catch (Exception) - { - // If individual command parsing fails, add with null values - reconstructedHistory.Add((input, null, null)); - } - } - - _commandHistory[platformId] = reconstructedHistory; - _loadedHistories.Add(platformId); - } - catch (Exception ex) - { - Log.Error($"Failed to load command history for platform ID {platformId}: {ex.Message}"); - _loadedHistories.Add(platformId); // Mark as loaded to avoid repeated attempts - } - } - private static ulong? GetPlatformId(ICommandContext ctx) { if (ctx is ChatCommandContext chatCtx) @@ -999,78 +780,7 @@ private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) return chatCtx.User.PlatformId; } return null; - } - - private static (CommandMetadata command, object[] args) ParseCommandForHistory(ICommandContext ctx, string input) - { - try - { - // Ensure the command starts with the prefix - if (!input.StartsWith(DEFAULT_PREFIX)) - { - return (null, null); - } - - // 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; - - 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(assemblyName => - assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } - - // Get command(s) based on input - CacheResult matchedCommand = null; - if (assemblyName != null) - { - matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); - } - if (matchedCommand == null || !matchedCommand.IsMatched) - { - matchedCommand = _cache.GetCommand(input); - } - - var commands = matchedCommand.Commands; - - if (!matchedCommand.IsMatched || !commands.Any()) - { - return (null, null); - } - - // Try to find the first command that can be parsed successfully - foreach (var (command, cmdArgs) in commands) - { - if (!CanCommandExecute(ctx, command)) continue; - - var (success, commandArgs, error) = TryConvertParameters(ctx, command, cmdArgs, input); - if (success) - { - return (command, commandArgs); - } - } - - return (null, null); - } - catch (Exception) - { - return (null, null); - } - } + } public static void UnregisterConverter(Type converter) { From c4bbc1ede95fe492b1af41d32ebe001e8e2f883e Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 14 Sep 2025 22:11:12 -0400 Subject: [PATCH 08/29] Changed to storing history based on the context's name instead of platformId for unit testability Adding unit tests testing history deduplication and history loading --- VCF.Core/Registry/CommandHistory.cs | 82 ++--- VCF.Core/Registry/CommandRegistry.cs | 15 +- VCF.Tests/AssemblyCommandTests.cs | 3 - VCF.Tests/RepeatCommandsTests.cs | 449 ++++++++++++++++++++++++++- 4 files changed, 479 insertions(+), 70 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index 6fe5359..924c004 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -5,24 +5,18 @@ using System.Text; using BepInEx; using VampireCommandFramework.Common; -using VampireCommandFramework.Registry; - -using static VampireCommandFramework.Format; namespace VampireCommandFramework.Registry; -/// -/// Manages command history functionality for the VampireCommandFramework -/// public static class CommandHistory { #region Private Fields - private static Dictionary> _commandHistory = new(); + private static Dictionary> _commandHistory = new(); private const int MAX_COMMAND_HISTORY = 10; // Store up to 10 past commands // Track which users have had their history loaded this session - private static HashSet _loadedHistories = new(); + private static HashSet _loadedHistories = new(); // Command history directory path private static string HistoryDirectory => Path.Combine(Path.Combine(Paths.ConfigPath, PluginInfo.PLUGIN_NAME), "CommandHistory"); @@ -35,35 +29,31 @@ internal static void Reset() { _commandHistory.Clear(); _loadedHistories.Clear(); - } + } - internal static bool IsHistoryLoaded(ulong platformId) + internal static bool IsHistoryLoaded(string contextName) { - return _loadedHistories.Contains(platformId); - } + return _loadedHistories.Contains(contextName); + } internal static void EnsureHistoryLoaded(ICommandContext ctx) { - var platformId = GetPlatformId(ctx); - if (platformId.HasValue && !_loadedHistories.Contains(platformId.Value)) + var contextName = ctx.Name; + if (!_loadedHistories.Contains(contextName)) { - LoadHistoryFromFile(ctx, platformId.Value); + LoadHistoryFromFile(ctx, contextName); } - } + } internal static void AddToHistory(ICommandContext ctx, string input, CommandMetadata command, object[] args) { - var platformId = GetPlatformId(ctx); - if (!platformId.HasValue) - { - return; // Cannot save history for non-chat contexts - } + var contextName = ctx.Name; - // Create the history list for this platform ID if it doesn't exist yet - if (!_commandHistory.TryGetValue(platformId.Value, out var history)) + // 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[platformId.Value] = history; + _commandHistory[contextName] = history; } // Check if this exact command with same arguments already exists in history @@ -95,23 +85,18 @@ internal static void AddToHistory(ICommandContext ctx, string input, CommandMeta } // Save the updated history to file - SaveHistoryToFile(platformId.Value, history); + SaveHistoryToFile(contextName, history); } internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string input, Func handleCommand, Func executeCommandWithArgs) { - var platformId = GetPlatformId(ctx); - if (!platformId.HasValue) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} Command history is not available for this context type."); - return CommandResult.CommandError; - } + var contextName = ctx.Name; // Remove the ".!" prefix string command = input.Substring(2).Trim(); - // Check if the command history exists for this platform ID - if (!_commandHistory.TryGetValue(platformId.Value, out var history) || history.Count == 0) + // Check if the command history exists for this context + if (!_commandHistory.TryGetValue(contextName, out var history) || history.Count == 0) { ctx.SysReply($"{"[error]".Color(Color.Red)} No command history available."); return CommandResult.CommandError; @@ -195,7 +180,7 @@ private static bool ArgsEqual(object[] args1, object[] args2) return true; } - private static void SaveHistoryToFile(ulong platformId, List<(string input, CommandMetadata Command, object[] Args)> history) + private static void SaveHistoryToFile(string contextName, List<(string input, CommandMetadata Command, object[] Args)> history) { try { @@ -204,21 +189,25 @@ private static void SaveHistoryToFile(ulong platformId, List<(string input, Comm Directory.CreateDirectory(HistoryDirectory); } - string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + // Use a safe filename by replacing invalid characters + var safeFileName = string.Join("_", contextName.Split(Path.GetInvalidFileNameChars())); + string filePath = Path.Combine(HistoryDirectory, $"{safeFileName}.txt"); var inputsOnly = history.Select(h => h.input).ToArray(); File.WriteAllLines(filePath, inputsOnly); } catch (Exception ex) { - Log.Error($"Failed to save command history for platform ID {platformId}: {ex.Message}"); + Log.Error($"Failed to save command history for context {contextName}: {ex.Message}"); } } - private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) + private static void LoadHistoryFromFile(ICommandContext ctx, string contextName) { try { - string filePath = Path.Combine(HistoryDirectory, $"{platformId}.txt"); + // Use a safe filename by replacing invalid characters + var safeFileName = string.Join("_", contextName.Split(Path.GetInvalidFileNameChars())); + string filePath = Path.Combine(HistoryDirectory, $"{safeFileName}.txt"); if (!File.Exists(filePath)) { @@ -258,23 +247,14 @@ private static void LoadHistoryFromFile(ICommandContext ctx, ulong platformId) } } - _commandHistory[platformId] = reconstructedHistory; - _loadedHistories.Add(platformId); + _commandHistory[contextName] = reconstructedHistory; + _loadedHistories.Add(contextName); } catch (Exception ex) { - Log.Error($"Failed to load command history for platform ID {platformId}: {ex.Message}"); - _loadedHistories.Add(platformId); // Mark as loaded to avoid repeated attempts - } - } - - private static ulong? GetPlatformId(ICommandContext ctx) - { - if (ctx is ChatCommandContext chatCtx) - { - return chatCtx.User.PlatformId; + Log.Error($"Failed to load command history for context {contextName}: {ex.Message}"); + _loadedHistories.Add(contextName); // Mark as loaded to avoid repeated attempts } - return null; } private static (CommandMetadata command, object[] args) ParseCommandForHistory(ICommandContext ctx, string input) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index 9649c6a..bffdee2 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -324,8 +324,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) } // Case 3: Multiple commands succeeded - store and ask user to select - var platformId = GetPlatformId(ctx); - var pendingKey = platformId.HasValue ? platformId.Value.ToString() : ctx.Name; + var pendingKey = ctx.Name; _pendingCommands[pendingKey] = (input, successfulCommands); { @@ -349,8 +348,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) { - var platformId = GetPlatformId(ctx); - var pendingKey = platformId.HasValue ? platformId.Value.ToString() : ctx.Name; + var pendingKey = ctx.Name; if (!_pendingCommands.TryGetValue(pendingKey, out var pendingCommands) || pendingCommands.commands.Count == 0) { @@ -773,15 +771,6 @@ private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, Command return CommandResult.Success; } - private static ulong? GetPlatformId(ICommandContext ctx) - { - if (ctx is ChatCommandContext chatCtx) - { - return chatCtx.User.PlatformId; - } - return null; - } - public static void UnregisterConverter(Type converter) { if (!IsGenericConverterContext(converter) && !IsSpecificConverterContext(converter)) diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs index 4797934..b84b013 100644 --- a/VCF.Tests/AssemblyCommandTests.cs +++ b/VCF.Tests/AssemblyCommandTests.cs @@ -1,9 +1,6 @@ using NUnit.Framework; -using System.Reflection; -using System.Collections.Generic; using VampireCommandFramework; using VampireCommandFramework.Registry; -using System.Reflection.Emit; namespace VCF.Tests { diff --git a/VCF.Tests/RepeatCommandsTests.cs b/VCF.Tests/RepeatCommandsTests.cs index af87771..1f9c521 100644 --- a/VCF.Tests/RepeatCommandsTests.cs +++ b/VCF.Tests/RepeatCommandsTests.cs @@ -1,4 +1,6 @@ +using BepInEx; using NUnit.Framework; +using System.Reflection; using System.Text; using VampireCommandFramework; using VampireCommandFramework.Basics; @@ -8,9 +10,20 @@ namespace VCF.Tests [TestFixture] public class RepeatCommandsTests { + private string _tempDirectory; + private string _originalConfigPath; + [SetUp] public void Setup() { + // Create a temporary directory for testing + _tempDirectory = Path.Combine(Path.GetTempPath(), "VCFTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + + // Store original ConfigPath and set test path using reflection + _originalConfigPath = Paths.ConfigPath; + SetConfigPath(_tempDirectory); + CommandRegistry.Reset(); Format.Mode = Format.FormatMode.None; @@ -18,6 +31,67 @@ public void Setup() CommandRegistry.RegisterCommandType(typeof(TestCommands)); CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); } + + [TearDown] + public void TearDown() + { + CommandRegistry.Reset(); + + // Restore original ConfigPath + SetConfigPath(_originalConfigPath); + + // Clean up temporary directory + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, true); + } + catch + { + // Ignore cleanup errors in tests + } + } + } + + private void SetConfigPath(string path) + { + try + { + // Try to set the ConfigPath property using reflection + var pathsType = typeof(Paths); + var configPathProperty = pathsType.GetProperty("ConfigPath", BindingFlags.Public | BindingFlags.Static); + + if (configPathProperty != null && configPathProperty.CanWrite) + { + configPathProperty.SetValue(null, path); + return; + } + + // If property doesn't have a public setter, try to find the backing field + var configPathField = pathsType.GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Static) + ?? pathsType.GetField("_configPath", BindingFlags.NonPublic | BindingFlags.Static) + ?? pathsType.GetField("configPath", BindingFlags.NonPublic | BindingFlags.Static); + + if (configPathField != null) + { + configPathField.SetValue(null, path); + return; + } + + // If we can't find the field, try to find a private setter + var privateSetter = configPathProperty?.GetSetMethod(true); + if (privateSetter != null) + { + privateSetter.Invoke(null, new object[] { path }); + } + } + catch (Exception ex) + { + // If reflection fails, we'll fall back to the resilient approach + Console.WriteLine($"Failed to set ConfigPath via reflection: {ex.Message}"); + } + } #region Test Commands @@ -38,6 +112,33 @@ public static void Add(ICommandContext ctx, int a, int b) #endregion + [Test] + public void CommandHistory_HandlesSpecialCharactersInContextName() + { + // Arrange - Context name with special characters that would be invalid in filename + var ctx = new AssertReplyContext { Name = "User<>:\"/|?*With\nSpecial\tChars" }; + + // Act - Execute commands + TestUtilities.AssertHandle(ctx, ".echo special1", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo special2", CommandResult.Success); + + // Simulate restart + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Create new context with same name + var newCtx = new AssertReplyContext { Name = "User<>:\"/|?*With\nSpecial\tChars" }; + + // Act - List history + var result = CommandRegistry.Handle(newCtx, ".! list"); + + // Assert - Should handle special characters gracefully + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains(".echo special2"); + newCtx.AssertReplyContains(".echo special1"); + } + [Test] public void RepeatLastCommand_ExecutesMostRecentCommand() { @@ -67,7 +168,7 @@ public void RepeatLastCommand_NoHistory_ReturnsError() var result = CommandRegistry.Handle(ctx, ".!"); // Assert - Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + Assert.That(result, Is.EqualTo(CommandResult.CommandError)); ctx.AssertReplyContains("[error]"); ctx.AssertReplyContains("No command history available"); } @@ -147,7 +248,7 @@ public void ExecuteHistoryCommand_InvalidIndex_ReturnsError() var result = CommandRegistry.Handle(ctx, ".! 99"); // Assert - Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); // The command itself succeeds but reports an error ctx.AssertReplyContains("[error]"); ctx.AssertReplyContains("Invalid command history selection"); } @@ -221,10 +322,352 @@ public void InvalidCommandHistoryCommand_ReturnsError() var result = CommandRegistry.Handle(ctx, ".! invalid"); // Assert - Assert.That(result, Is.EqualTo(CommandResult.Success)); // The command itself succeeds but reports an error + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); // The command itself succeeds but reports an error ctx.AssertReplyContains("[error]"); ctx.AssertReplyContains("Invalid command history selection"); } + + #region Deduplication Tests + + [Test] + public void CommandHistory_DeduplicatesSameCommandWithSameArguments() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute the same command with same arguments multiple times + TestUtilities.AssertHandle(ctx, ".echo hello", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 5 10", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo hello", CommandResult.Success); // Duplicate + TestUtilities.AssertHandle(ctx, ".echo world", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 5 10", CommandResult.Success); // Duplicate + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + var historyText = ctx.RepliedTextLfAndTrimmed(); + + // Should only have unique commands, with most recent execution preserved + ctx.AssertReplyContains("1. .add 5 10"); + ctx.AssertReplyContains("2. .echo world"); + ctx.AssertReplyContains("3. .echo hello"); + + // Should not have duplicates - count occurrences + var helloCount = CountOccurrences(historyText, ".echo hello"); + var addCount = CountOccurrences(historyText, ".add 5 10"); + + Assert.That(helloCount, Is.EqualTo(1), "Should have only one instance of '.echo hello'"); + Assert.That(addCount, Is.EqualTo(1), "Should have only one instance of '.add 5 10'"); + } + + [Test] + public void CommandHistory_DoesNotDeduplicateCommandsWithDifferentArguments() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute same command with different arguments + TestUtilities.AssertHandle(ctx, ".echo hello", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo world", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo test", CommandResult.Success); + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("1. .echo test"); + ctx.AssertReplyContains("2. .echo world"); + ctx.AssertReplyContains("3. .echo hello"); + + // All three should be present as they have different arguments + var historyText = ctx.RepliedTextLfAndTrimmed(); + Assert.That(CountOccurrences(historyText, ".echo"), Is.EqualTo(3)); + } + + [Test] + public void CommandHistory_DoesNotDeduplicateDifferentCommands() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute different commands + TestUtilities.AssertHandle(ctx, ".echo test", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 1 2", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo test", CommandResult.Success); // Should deduplicate + TestUtilities.AssertHandle(ctx, ".add 3 4", CommandResult.Success); // Different args, should not deduplicate + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("1. .add 3 4"); + ctx.AssertReplyContains("2. .echo test"); + ctx.AssertReplyContains("3. .add 1 2"); + + // Should have 3 unique entries + var historyText = ctx.RepliedTextLfAndTrimmed(); + var lines = historyText.Split('\n').Where(line => line.Contains(". .")).ToArray(); + Assert.That(lines.Length, Is.EqualTo(3)); + } + + [Test] + public void CommandHistory_DeduplicationMovesToFront() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute commands to establish history + TestUtilities.AssertHandle(ctx, ".echo first", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo second", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo third", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo fourth", CommandResult.Success); + + // Re-execute an older command + TestUtilities.AssertHandle(ctx, ".echo second", CommandResult.Success); + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + + // The re-executed command should be at the front + ctx.AssertReplyContains("1. .echo second"); + ctx.AssertReplyContains("2. .echo fourth"); + ctx.AssertReplyContains("3. .echo third"); + ctx.AssertReplyContains("4. .echo first"); + + // Should still only have 4 unique entries + var historyText = ctx.RepliedTextLfAndTrimmed(); + var lines = historyText.Split('\n').Where(line => line.Contains(". .")).ToArray(); + Assert.That(lines.Length, Is.EqualTo(4)); + } + + [Test] + public void CommandHistory_DeduplicationWithComplexArguments() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute commands with complex arguments + TestUtilities.AssertHandle(ctx, ".add 10 20", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 30 40", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 10 20", CommandResult.Success); // Should deduplicate + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("1. .add 10 20"); + ctx.AssertReplyContains("2. .add 30 40"); + + // Should only have 2 unique entries + var historyText = ctx.RepliedTextLfAndTrimmed(); + var addCount = CountOccurrences(historyText, ".add 10 20"); + Assert.That(addCount, Is.EqualTo(1), "Should have only one instance of '.add 10 20'"); + } + + #endregion + + #region Persistence Tests + + [Test] + public void CommandHistory_PersistsAcrossResets() + { + // Arrange - Execute commands and save history + var ctx = new AssertReplyContext { Name = "TestUser" }; + TestUtilities.AssertHandle(ctx, ".echo persistent1", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 5 7", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo persistent2", CommandResult.Success); + + // Simulate restart by resetting the registry + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Act - Create new context with same name to trigger history loading + var newCtx = new AssertReplyContext { Name = "TestUser" }; + var result = CommandRegistry.Handle(newCtx, ".! list"); + + // Assert - History should be loaded from file + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains("1. .echo persistent2"); + newCtx.AssertReplyContains("2. .add 5 7"); + newCtx.AssertReplyContains("3. .echo persistent1"); + } + + [Test] + public void CommandHistory_HandlesEmptyHistoryFile() + { + // Arrange - User with no previous history + var ctx = new AssertReplyContext { Name = "NewTestUser" + Guid.NewGuid().ToString() }; + + // Act - Try to list history for a new user + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert - Should handle gracefully + Assert.That(result, Is.EqualTo(CommandResult.CommandError)); + ctx.AssertReplyContains("No command history available"); + } + + [Test] + public void CommandHistory_LoadsOnlyOncePerSession() + { + // Arrange + var ctx = new AssertReplyContext { Name = "SessionTestUser" }; + + // Execute a command to establish history + TestUtilities.AssertHandle(ctx, ".echo session1", CommandResult.Success); + + // Reset to clear in-memory history but keep files + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Create context with same name + var newCtx = new AssertReplyContext { Name = "SessionTestUser" }; + + // First access should load from file + TestUtilities.AssertHandle(newCtx, ".! list", CommandResult.Success); + newCtx.AssertReplyContains(".echo session1"); + + // Execute another command + TestUtilities.AssertHandle(newCtx, ".echo session2", CommandResult.Success); + + // Second access should use in-memory history (not reload from file) + var result = CommandRegistry.Handle(newCtx, ".! list"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains("1. .echo session2"); + newCtx.AssertReplyContains("2. .echo session1"); + } + + [Test] + public void CommandHistory_RepeatWorksAfterRestart() + { + // Arrange - Execute commands and save history + var ctx = new AssertReplyContext { Name = "RepeatTestUser" }; + TestUtilities.AssertHandle(ctx, ".echo beforeRestart", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 8 12", CommandResult.Success); + + // Simulate restart + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Act - Create new context and try to repeat + var newCtx = new AssertReplyContext { Name = "RepeatTestUser" }; + var result = CommandRegistry.Handle(newCtx, ".!"); + + // Assert - Should repeat the most recent command from loaded history + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains("Repeating most recent command: .add 8 12"); + newCtx.AssertReplyContains("Result: 20"); + } + + [Test] + public void CommandHistory_ExecuteByNumberWorksAfterRestart() + { + // Arrange - Execute commands and save history + var ctx = new AssertReplyContext { Name = "NumberTestUser" }; + TestUtilities.AssertHandle(ctx, ".echo first", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 3 4", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo third", CommandResult.Success); + + // Simulate restart + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Act - Create new context and execute by number + var newCtx = new AssertReplyContext { Name = "NumberTestUser" }; + var result = CommandRegistry.Handle(newCtx, ".! 2"); + + // Assert - Should execute the second command from loaded history + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains("Executing command 2: .add 3 4"); + newCtx.AssertReplyContains("Result: 7"); + } + + [Test] + public void CommandHistory_PersistenceWithDeduplication() + { + // Arrange - Execute commands with duplicates + var ctx = new AssertReplyContext { Name = "DedupPersistUser" }; + TestUtilities.AssertHandle(ctx, ".echo original", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".add 1 1", CommandResult.Success); + TestUtilities.AssertHandle(ctx, ".echo original", CommandResult.Success); // Should deduplicate + TestUtilities.AssertHandle(ctx, ".echo different", CommandResult.Success); + + // Simulate restart + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Act - Load history and list + var newCtx = new AssertReplyContext { Name = "DedupPersistUser" }; + var result = CommandRegistry.Handle(newCtx, ".! list"); + + // Assert - Should maintain deduplication across restarts + Assert.That(result, Is.EqualTo(CommandResult.Success)); + newCtx.AssertReplyContains("1. .echo different"); + newCtx.AssertReplyContains("2. .echo original"); + newCtx.AssertReplyContains("3. .add 1 1"); + + // Should only have one instance of .echo original + var historyText = newCtx.RepliedTextLfAndTrimmed(); + var originalCount = CountOccurrences(historyText, ".echo original"); + Assert.That(originalCount, Is.EqualTo(1), "Should have only one instance of '.echo original' after restart"); + } + + #endregion + + #region Edge Case Tests + + [Test] + public void CommandHistory_DeduplicationWithMaxHistoryLimit() + { + // Arrange + var ctx = new AssertReplyContext(); + + // Execute 11 unique commands (more than max limit of 10) + for (int i = 0; i < 11; i++) + { + TestUtilities.AssertHandle(ctx, $".echo unique{i}", CommandResult.Success); + } + + // Execute a duplicate of an earlier command that should have been removed + TestUtilities.AssertHandle(ctx, ".echo unique0", CommandResult.Success); + + // Act - List command history + var result = CommandRegistry.Handle(ctx, ".! list"); + + // Assert + Assert.That(result, Is.EqualTo(CommandResult.Success)); + + // Should have .echo unique0 at position 1 (moved to front due to recent execution) + ctx.AssertReplyContains("1. .echo unique0"); + + // Should still respect the max limit of 10 + var historyText = ctx.RepliedTextLfAndTrimmed(); + var lines = historyText.Split('\n').Where(line => line.Contains(". .")).ToArray(); + Assert.That(lines.Length, Is.LessThanOrEqualTo(10), "Should not exceed maximum history limit"); + } + + #endregion + + #region Helper Methods + + private static int CountOccurrences(string text, string pattern) + { + return text.Split(new[] { pattern }, StringSplitOptions.None).Length - 1; + } + + #endregion } // Extension method to get the replied text for testing From 093984580a8aacd1c8c35a92c22c1e6d743a5736 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 14 Sep 2025 22:33:25 -0400 Subject: [PATCH 09/29] Realized the context wasn't being updated on the history commands when executed and added unit tests to verify Also missed checking in removal of the history reversal --- VCF.Core/Registry/CommandHistory.cs | 6 +- VCF.Tests/AssertReplyContext.cs | 3 + VCF.Tests/RepeatCommandsTests.cs | 207 ++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index 924c004..4f4bdbb 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -126,7 +126,9 @@ internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string i // If Command and Args are available (successfully parsed), use them directly if (selectedCommand.Command != null && selectedCommand.Args != null) { - return executeCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); + var argsCopy = selectedCommand.Args.ToArray(); + argsCopy[0] = ctx; // Ensure the context is current + return executeCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); } else { @@ -222,8 +224,6 @@ private static void LoadHistoryFromFile(ICommandContext ctx, string contextName) var reconstructedHistory = new List<(string input, CommandMetadata Command, object[] Args)>(); - // Process lines in reverse to maintain chronological order (most recent first) - lines.Reverse(); foreach (var input in lines) { try diff --git a/VCF.Tests/AssertReplyContext.cs b/VCF.Tests/AssertReplyContext.cs index c42f515..0e0be36 100644 --- a/VCF.Tests/AssertReplyContext.cs +++ b/VCF.Tests/AssertReplyContext.cs @@ -10,6 +10,9 @@ public class AssertReplyContext : ICommandContext public IServiceProvider Services => throw new NotImplementedException(); public string Name { get; set; } = nameof(AssertReplyContext); + + // Add unique identifier for each context instance + public string ContextId { get; set; } = Guid.NewGuid().ToString("N"); public bool IsAdmin { get; set; } diff --git a/VCF.Tests/RepeatCommandsTests.cs b/VCF.Tests/RepeatCommandsTests.cs index 1f9c521..01e0790 100644 --- a/VCF.Tests/RepeatCommandsTests.cs +++ b/VCF.Tests/RepeatCommandsTests.cs @@ -108,10 +108,217 @@ public static void Add(ICommandContext ctx, int a, int b) { ctx.Reply($"Result: {a + b}"); } + + [Command("whoami", description: "Shows the current context name")] + public static void WhoAmI(ICommandContext ctx) + { + ctx.Reply($"You are: {ctx.Name}"); + } + + [Command("greet", description: "Greets the current user by name")] + public static void Greet(ICommandContext ctx, string message) + { + ctx.Reply($"Hello {ctx.Name}, {message}"); + } + + [Command("contextinfo", description: "Shows context name and unique ID")] + public static void ContextInfo(ICommandContext ctx) + { + // Cast to AssertReplyContext to access ContextId + if (ctx is AssertReplyContext assertCtx) + { + ctx.Reply($"Context: {ctx.Name} (ID: {assertCtx.ContextId})"); + } + else + { + ctx.Reply($"Context: {ctx.Name} (ID: unknown)"); + } + } + + [Command("greetfull", description: "Greets with full context info")] + public static void GreetFull(ICommandContext ctx, string message) + { + if (ctx is AssertReplyContext assertCtx) + { + ctx.Reply($"Hello {ctx.Name} (ID: {assertCtx.ContextId}), {message}"); + } + else + { + ctx.Reply($"Hello {ctx.Name} (ID: unknown), {message}"); + } + } } #endregion + [Test] + public void CommandHistory_RepeatWithDifferentUserContexts() + { + // This test verifies that when User B repeats a command from User A's history, + // it executes with User B's context, not User A's context + // NOTE: This test assumes cross-user history access for demonstration + + // Arrange - User A executes a context-dependent command + var userA = new AssertReplyContext { Name = "Alice" }; + TestUtilities.AssertHandle(userA, ".greet world", CommandResult.Success); + userA.AssertReplyContains("Hello Alice, world"); + + // Now let's simulate User B executing the same command but with different context + var userB = new AssertReplyContext { Name = "Bob" }; + TestUtilities.AssertHandle(userB, ".greet everyone", CommandResult.Success); + userB.AssertReplyContains("Hello Bob, everyone"); + + // User B repeats their own last command + var repeatResult = CommandRegistry.Handle(userB, ".!"); + + // Assert - The repeated command should execute with User B's context (Bob) + Assert.That(repeatResult, Is.EqualTo(CommandResult.Success)); + userB.AssertReplyContains("Repeating most recent command: .greet everyone"); + userB.AssertReplyContains("Hello Bob, everyone"); // Should be Bob, not Alice + + // Verify that Alice's context was not affected + var aliceText = userA.RepliedTextLfAndTrimmed(); + var bobText = userB.RepliedTextLfAndTrimmed(); + + // Alice should only have her original command response + Assert.That(aliceText, Contains.Substring("Hello Alice, world")); + Assert.That(aliceText, Does.Not.Contain("Hello Bob"), "Alice's context should not contain Bob's responses"); + + // Bob should have his responses including the repeated command + Assert.That(bobText, Contains.Substring("Hello Bob, everyone")); + Assert.That(bobText, Does.Not.Contain("Hello Alice"), "Bob's context should not contain Alice's responses"); + } + + [Test] + public void CommandHistory_BugTest_RepeatUsesStoredContextInsteadOfCurrent() + { + // This test specifically checks for a potential bug where repeated commands + // use the stored context from history instead of the current context + // This test may FAIL if the bug exists + + // Arrange - Create a context that will be stored in history + var originalContext = new AssertReplyContext { Name = "TestUser" }; + var originalContextId = originalContext.ContextId; + + TestUtilities.AssertHandle(originalContext, ".contextinfo", CommandResult.Success); + originalContext.AssertReplyContains($"Context: TestUser (ID: {originalContextId})"); + + // Create a NEW context with the same name but different instance (simulating restart) + var newContext = new AssertReplyContext { Name = "TestUser" }; + var newContextId = newContext.ContextId; + + // Verify they have different IDs + Assert.That(newContextId, Is.Not.EqualTo(originalContextId), "New context should have different ID"); + + // Act - Repeat the command using the new context + var repeatResult = CommandRegistry.Handle(newContext, ".!"); + + // Assert - The command should execute with the NEW context + Assert.That(repeatResult, Is.EqualTo(CommandResult.Success)); + newContext.AssertReplyContains("Repeating most recent command: .contextinfo"); + + // CRITICAL: Check that it uses the NEW context ID, not the old one + newContext.AssertReplyContains($"Context: TestUser (ID: {newContextId})"); + newContext.AssertReplyDoesntContain($"ID: {originalContextId}"); + + // Verify the responses went to the correct contexts + var newContextReplies = newContext.RepliedTextLfAndTrimmed(); + Assert.That(newContextReplies, Contains.Substring($"ID: {newContextId}"), + "The repeated command should execute with the NEW context ID"); + + Assert.That(newContextReplies, Does.Not.Contain($"ID: {originalContextId}"), + "The repeated command should NOT use the old context ID"); + } + + [Test] + public void CommandHistory_RepeatAfterRestartUsesCurrentContext() + { + // This test verifies that when commands are loaded from persistent storage and repeated, + // they execute with the current context, not the stale context from storage + + // Arrange - User executes context-dependent commands before restart + var originalUser = new AssertReplyContext { Name = "TestUser" }; + var originalContextId = originalUser.ContextId; + + TestUtilities.AssertHandle(originalUser, ".contextinfo", CommandResult.Success); + originalUser.AssertReplyContains($"Context: TestUser (ID: {originalContextId})"); + + TestUtilities.AssertHandle(originalUser, ".greetfull hello", CommandResult.Success); + originalUser.AssertReplyContains($"Hello TestUser (ID: {originalContextId}), hello"); + + // Simulate application restart + CommandRegistry.Reset(); + CommandRegistry.RegisterCommandType(typeof(TestCommands)); + CommandRegistry.RegisterCommandType(typeof(RepeatCommands)); + + // Act - Same user name but new context instance (simulating restart) + var newUser = new AssertReplyContext { Name = "TestUser" }; + var newContextId = newUser.ContextId; + + // Verify they have different IDs + Assert.That(newContextId, Is.Not.EqualTo(originalContextId), "New context should have different ID"); + + // Repeat the most recent command (loaded from file) + var repeatResult = CommandRegistry.Handle(newUser, ".!"); + + // Assert - Should execute with the NEW context instance, not the old one + Assert.That(repeatResult, Is.EqualTo(CommandResult.Success)); + newUser.AssertReplyContains("Repeating most recent command: .greetfull hello"); + newUser.AssertReplyContains($"Hello TestUser (ID: {newContextId}), hello"); + newUser.AssertReplyDoesntContain($"ID: {originalContextId}"); + + // Repeat the second-to-last command (contextinfo) + var contextInfoResult = CommandRegistry.Handle(newUser, ".! 2"); + Assert.That(contextInfoResult, Is.EqualTo(CommandResult.Success)); + newUser.AssertReplyContains("Executing command 2: .contextinfo"); + newUser.AssertReplyContains($"Context: TestUser (ID: {newContextId})"); + newUser.AssertReplyDoesntContain($"ID: {originalContextId}"); + } + + [Test] + public void CommandHistory_RepeatUsesCurrentContext() + { + // This test verifies that when a command is repeated, it executes with the + // current user's context, not the context that was saved in history + + // Arrange - User1 executes a context-dependent command + var user1 = new AssertReplyContext { Name = "Alice" }; + var user1ContextId = user1.ContextId; + + TestUtilities.AssertHandle(user1, ".greetfull world", CommandResult.Success); + user1.AssertReplyContains($"Hello Alice (ID: {user1ContextId}), world"); + + // User2 gets their own context + var user2 = new AssertReplyContext { Name = "Bob" }; + var user2ContextId = user2.ContextId; + + // Verify they have different context IDs + Assert.That(user2ContextId, Is.Not.EqualTo(user1ContextId), "Different users should have different context IDs"); + + // Act - User2 executes their own command then repeats it + TestUtilities.AssertHandle(user2, ".greetfull everyone", CommandResult.Success); + user2.AssertReplyContains($"Hello Bob (ID: {user2ContextId}), everyone"); + + // Now User2 repeats their last command + var repeatResult = CommandRegistry.Handle(user2, ".!"); + + // Assert - The repeated command should execute with User2's context (Bob), not User1's context (Alice) + Assert.That(repeatResult, Is.EqualTo(CommandResult.Success)); + user2.AssertReplyContains("Repeating most recent command: .greetfull everyone"); + user2.AssertReplyContains($"Hello Bob (ID: {user2ContextId}), everyone"); // Should use Bob's context ID + user2.AssertReplyDoesntContain($"ID: {user1ContextId}"); // Should NOT use Alice's context ID + + // Additional verification: User2 repeats a contextinfo command + TestUtilities.AssertHandle(user2, ".contextinfo", CommandResult.Success); + user2.AssertReplyContains($"Context: Bob (ID: {user2ContextId})"); + + var contextRepeatResult = CommandRegistry.Handle(user2, ".!"); + Assert.That(contextRepeatResult, Is.EqualTo(CommandResult.Success)); + user2.AssertReplyContains("Repeating most recent command: .contextinfo"); + user2.AssertReplyContains($"Context: Bob (ID: {user2ContextId})"); // Should still be Bob's context + user2.AssertReplyDoesntContain($"ID: {user1ContextId}"); // Should NOT be Alice's context + } + [Test] public void CommandHistory_HandlesSpecialCharactersInContextName() { From 0feff69243b3209573234f2d67b78f5f9ea9a462 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 23 Sep 2025 12:24:02 -0400 Subject: [PATCH 10/29] Start of version checking installed plugins --- VCF.Core/Common/ThunderstoreVersionChecker.cs | 347 ++++++++++++++++++ VCF.Core/Plugin.cs | 35 ++ VCF.Core/VCF.Core.csproj | 1 + 3 files changed, 383 insertions(+) create mode 100644 VCF.Core/Common/ThunderstoreVersionChecker.cs diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs new file mode 100644 index 0000000..004ce0c --- /dev/null +++ b/VCF.Core/Common/ThunderstoreVersionChecker.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using System.Linq; +using BepInEx.Unity.IL2CPP; + +namespace VampireCommandFramework.Common; + +/// +/// Handles version checking against the Thunderstore API +/// +internal static class ThunderstoreVersionChecker +{ + private static readonly HttpClient _httpClient = new HttpClient(); + private const string THUNDERSTORE_API_BASE = "https://thunderstore.io/c/v-rising/api/v1/package/"; + + static ThunderstoreVersionChecker() + { + _httpClient.DefaultRequestHeaders.Add("User-Agent", "VampireCommandFramework-VersionChecker/1.0"); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + /// + /// Checks all installed plugins for newer versions on Thunderstore + /// + public static async Task CheckAllPluginVersionsAsync() + { + try + { + Log.Info("Starting plugin version check..."); + + // Get all loaded plugins + var installedPlugins = GetInstalledPlugins(); + Log.Info($"Found {installedPlugins.Count} installed plugins to check"); + + // Get all packages from Thunderstore API for V Rising community + var thunderstorePackages = await GetThunderstorePackagesAsync(); + if (thunderstorePackages == null || thunderstorePackages.Count == 0) + { + Log.Warning("Could not retrieve Thunderstore package data"); + return; + } + + Log.Info($"Retrieved {thunderstorePackages.Count} packages from Thunderstore"); + + // Check each plugin against Thunderstore data + var updatesFound = false; + var resultMessage = new System.Text.StringBuilder(); + resultMessage.AppendLine($"Version check completed for {installedPlugins.Count} plugins:"); + + foreach (var plugin in installedPlugins) + { + var updateInfo = CheckPluginForUpdate(plugin, thunderstorePackages); + if (updateInfo != null) + { + updatesFound = true; + AppendUpdateInfo(resultMessage, updateInfo); + } + } + + if (!updatesFound) + { + resultMessage.AppendLine("* All plugins are up to date!"); + } + else + { + resultMessage.AppendLine("! Updates are available for the plugins listed above."); + resultMessage.AppendLine("* Note: Updates must be installed manually - VCF only identifies available updates."); + } + + // Log the complete results as a single message + Log.Info(resultMessage.ToString()); + } + catch (Exception ex) + { + Log.Error($"Error during version check: {ex.Message}"); + } + } + + /// + /// Gets information about all installed BepInEx plugins + /// + private static List GetInstalledPlugins() + { + var plugins = new List(); + + foreach (var pluginKvp in IL2CPPChainloader.Instance.Plugins) + { + var pluginInfo = pluginKvp.Value; + if (pluginInfo?.Metadata != null) + { + plugins.Add(new InstalledPluginInfo + { + GUID = pluginInfo.Metadata.GUID, + Name = pluginInfo.Metadata.Name, + Version = pluginInfo.Metadata.Version.ToString() + }); + } + } + + return plugins; + } + + /// + /// Retrieves all V Rising packages from Thunderstore API + /// + private static async Task> GetThunderstorePackagesAsync() + { + try + { + var response = await _httpClient.GetAsync(THUNDERSTORE_API_BASE); + if (!response.IsSuccessStatusCode) + { + Log.Warning($"Failed to retrieve Thunderstore data: HTTP {response.StatusCode}"); + return null; + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var packages = JsonSerializer.Deserialize>(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + // All packages from the V Rising API endpoint are V Rising packages + return packages ?? new List(); + } + catch (Exception ex) + { + Log.Error($"Error retrieving Thunderstore packages: {ex.Message}"); + return null; + } + } + + /// + /// Checks if a plugin has an update available on Thunderstore + /// + private static PluginUpdateInfo CheckPluginForUpdate(InstalledPluginInfo plugin, List thunderstorePackages) + { + try + { + // Try to find matching package by GUID first, then by name + var matchingPackage = thunderstorePackages.FirstOrDefault(p => + p.Versions?.Any(v => v.Dependencies?.Any(d => d.Contains(plugin.GUID)) == true) == true) + ?? thunderstorePackages.FirstOrDefault(p => + string.Equals(p.Name, plugin.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchingPackage?.Versions == null || matchingPackage.Versions.Count == 0) + { + return null; + } + + // Get the latest version + var latestVersion = matchingPackage.Versions + .OrderByDescending(v => DateTime.Parse(v.DateCreated)) + .FirstOrDefault(); + + if (latestVersion == null) + { + return null; + } + + // Compare versions + if (IsNewerVersion(plugin.Version, latestVersion.VersionNumber)) + { + return new PluginUpdateInfo + { + PluginName = plugin.Name, + CurrentVersion = plugin.Version, + LatestVersion = latestVersion.VersionNumber, + ReleaseDate = latestVersion.DateCreated, + PackageUrl = matchingPackage.PackageUrl + }; + } + + return null; + } + catch (Exception ex) + { + Log.Debug($"Error checking update for {plugin.Name}: {ex.Message}"); + return null; + } + } + + /// + /// Compares two version strings to determine if the second is newer + /// + private static bool IsNewerVersion(string currentVersion, string latestVersion) + { + try + { + var current = new Version(NormalizeVersion(currentVersion)); + var latest = new Version(NormalizeVersion(latestVersion)); + return latest > current; + } + catch + { + // If version parsing fails, do string comparison as fallback + return string.Compare(currentVersion, latestVersion, StringComparison.OrdinalIgnoreCase) < 0; + } + } + + /// + /// Normalizes version strings to ensure proper Version parsing + /// + private static string NormalizeVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return "0.0.0"; + + // Remove any non-numeric prefixes (like 'v') + version = version.TrimStart('v', 'V'); + + // Ensure at least 3 parts (Major.Minor.Patch) + var parts = version.Split('.'); + if (parts.Length == 1) + return $"{parts[0]}.0.0"; + if (parts.Length == 2) + return $"{parts[0]}.{parts[1]}.0"; + + return version; + } + + /// + /// Appends information about available update to the StringBuilder + /// + private static void AppendUpdateInfo(System.Text.StringBuilder sb, PluginUpdateInfo updateInfo) + { + sb.AppendLine($">> UPDATE AVAILABLE: {updateInfo.PluginName}"); + sb.AppendLine($" Current Version: {updateInfo.CurrentVersion}"); + sb.AppendLine($" Latest Version: {updateInfo.LatestVersion}"); + sb.AppendLine($" Release Date: {updateInfo.ReleaseDate}"); + if (!string.IsNullOrEmpty(updateInfo.PackageUrl)) + { + sb.AppendLine($" Thunderstore URL: {updateInfo.PackageUrl}"); + } + sb.AppendLine(); // Add blank line between updates + } + + /// + /// Information about an installed plugin + /// + private class InstalledPluginInfo + { + public string GUID { get; set; } + public string Name { get; set; } + public string Version { get; set; } + } + + /// + /// Information about an available plugin update + /// + private class PluginUpdateInfo + { + public string PluginName { get; set; } + public string CurrentVersion { get; set; } + public string LatestVersion { get; set; } + public string ReleaseDate { get; set; } + public string PackageUrl { get; set; } + } + + /// + /// Thunderstore package data structure + /// + private class ThunderstorePackage + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("owner")] + public string Owner { get; set; } + + [JsonPropertyName("package_url")] + public string PackageUrl { get; set; } + + [JsonPropertyName("date_created")] + public string DateCreated { get; set; } + + [JsonPropertyName("date_updated")] + public string DateUpdated { get; set; } + + [JsonPropertyName("rating_score")] + public int RatingScore { get; set; } + + [JsonPropertyName("is_pinned")] + public bool IsPinned { get; set; } + + [JsonPropertyName("is_deprecated")] + public bool IsDeprecated { get; set; } + + [JsonPropertyName("has_nsfw_content")] + public bool HasNsfwContent { get; set; } + + [JsonPropertyName("categories")] + public List Categories { get; set; } + + [JsonPropertyName("communities")] + public List Communities { get; set; } + + [JsonPropertyName("versions")] + public List Versions { get; set; } + } + + /// + /// Thunderstore package version data structure + /// + private class ThunderstoreVersion + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("icon")] + public string Icon { get; set; } + + [JsonPropertyName("version_number")] + public string VersionNumber { get; set; } + + [JsonPropertyName("dependencies")] + public List Dependencies { get; set; } + + [JsonPropertyName("download_url")] + public string DownloadUrl { get; set; } + + [JsonPropertyName("downloads")] + public int Downloads { get; set; } + + [JsonPropertyName("date_created")] + public string DateCreated { get; set; } + + [JsonPropertyName("website_url")] + public string WebsiteUrl { get; set; } + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } + } +} diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index de3536f..8759bd2 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -1,6 +1,9 @@ using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; +using VampireCommandFramework.Common; +using System.Threading.Tasks; +using BepInEx.Configuration; namespace VampireCommandFramework; @@ -8,10 +11,17 @@ namespace VampireCommandFramework; internal class Plugin : BasePlugin { private Harmony _harmony; + + // Configuration + private static ConfigEntry EnableVersionCheck; public override void Load() { Common.Log.Instance = Log; + + // Initialize configuration + EnableVersionCheck = Config.Bind("Version Check", "EnableVersionCheck", true, + "Enable automatic checking for plugin updates on Thunderstore at startup"); if (!Breadstone.VWorld.IsServer) { @@ -30,6 +40,31 @@ public override void Load() IL2CPPChainloader.Instance.Plugins.TryGetValue(PluginInfo.PLUGIN_GUID, out var info); Log.LogMessage($"VCF Loaded: {info?.Metadata.Version}"); + + // Check for plugin updates on Thunderstore after all plugins are loaded + if (EnableVersionCheck.Value) + { + IL2CPPChainloader.Instance.Finished += () => + { + _ = Task.Run(async () => + { + try + { + // Add a small delay to ensure everything is fully initialized + await Task.Delay(2000); + await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(); + } + catch (System.Exception ex) + { + Log.LogWarning($"Version check failed: {ex.Message}"); + } + }); + }; + } + else + { + Log.LogInfo("Plugin version checking is disabled. Enable it in the config if you want to check for updates."); + } } public override bool Unload() diff --git a/VCF.Core/VCF.Core.csproj b/VCF.Core/VCF.Core.csproj index df9bdde..2660bee 100644 --- a/VCF.Core/VCF.Core.csproj +++ b/VCF.Core/VCF.Core.csproj @@ -24,6 +24,7 @@ + From f0e425b4ef4367b57ab85ab6fcddbea34e9d7f42 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 20 Oct 2025 19:33:32 -0400 Subject: [PATCH 11/29] When admins auth do a version check and let them know what needs updating. --- VCF.Core/Breadstone/AdminAuthPatch.cs | 22 +++++++ VCF.Core/Common/ThunderstoreVersionChecker.cs | 59 +++++++++++++++++-- VCF.Core/Common/UnityMainThreadDispatcher.cs | 55 +++++++++++++++++ VCF.Core/Plugin.cs | 4 +- 4 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 VCF.Core/Breadstone/AdminAuthPatch.cs create mode 100644 VCF.Core/Common/UnityMainThreadDispatcher.cs diff --git a/VCF.Core/Breadstone/AdminAuthPatch.cs b/VCF.Core/Breadstone/AdminAuthPatch.cs new file mode 100644 index 0000000..bde8b92 --- /dev/null +++ b/VCF.Core/Breadstone/AdminAuthPatch.cs @@ -0,0 +1,22 @@ +using HarmonyLib; +using ProjectM; +using ProjectM.Network; +using System.Threading.Tasks; +using Unity.Collections; +using VampireCommandFramework.Common; + +namespace VampireCommandFramework.Breadstone; + +[HarmonyPatch(typeof(AdminAuthSystem), nameof(AdminAuthSystem.OnUpdate))] +internal class AdminAuthPatch +{ + public static void Prefix(AdminAuthSystem __instance) + { + var entities = __instance._Query.ToEntityArray(Allocator.Temp); + foreach (var entity in entities) + { + var fromCharacter = __instance.EntityManager.GetComponentData(entity); + Task.Run(async() => await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(fromCharacter.User)); + } + } +} diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs index 004ce0c..5679c8c 100644 --- a/VCF.Core/Common/ThunderstoreVersionChecker.cs +++ b/VCF.Core/Common/ThunderstoreVersionChecker.cs @@ -1,11 +1,16 @@ +using BepInEx.Unity.IL2CPP; +using ProjectM; +using ProjectM.Network; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using System.Linq; -using BepInEx.Unity.IL2CPP; +using Unity.Collections; +using Unity.Entities; +using VampireCommandFramework.Breadstone; namespace VampireCommandFramework.Common; @@ -23,24 +28,64 @@ static ThunderstoreVersionChecker() _httpClient.Timeout = TimeSpan.FromSeconds(30); } + static void SendMessageToClient(Entity userEntity, string message) + { + if (userEntity == default) return; + + // Queue ECS operations for main thread execution to avoid IL2CPP threading issues + UnityMainThreadDispatcher.Enqueue(() => + { + try + { + // Now we're on main thread - safe to access ECS components + if (VWorld.Server?.EntityManager == null) return; + if (!VWorld.Server.EntityManager.Exists(userEntity)) return; + if (!VWorld.Server.EntityManager.HasComponent(userEntity)) return; + + var user = VWorld.Server.EntityManager.GetComponentData(userEntity); + if (!user.IsConnected) return; + + var msg = new FixedString512Bytes(message); + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg); + } + catch (Exception ex) + { + Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}"); + } + }); + } + + static void LogInfoAndSendMessageToClient(Entity userEntity, string message) + { + Log.Info(message); + SendMessageToClient(userEntity, message); + } + + static void LogWarningAndSendMessageToClient(Entity userEntity, string message) + { + + Log.Warning(message); + SendMessageToClient(userEntity, message.Color(Color.Gold)); + } + /// /// Checks all installed plugins for newer versions on Thunderstore /// - public static async Task CheckAllPluginVersionsAsync() + public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) { try { - Log.Info("Starting plugin version check..."); + LogInfoAndSendMessageToClient(userEntity, "Starting plugin version check..."); // Get all loaded plugins var installedPlugins = GetInstalledPlugins(); - Log.Info($"Found {installedPlugins.Count} installed plugins to check"); + LogInfoAndSendMessageToClient(userEntity, $"Found {installedPlugins.Count} installed plugins to check"); // Get all packages from Thunderstore API for V Rising community var thunderstorePackages = await GetThunderstorePackagesAsync(); if (thunderstorePackages == null || thunderstorePackages.Count == 0) { - Log.Warning("Could not retrieve Thunderstore package data"); + LogWarningAndSendMessageToClient(userEntity, "Could not retrieve Thunderstore package data"); return; } @@ -58,12 +103,14 @@ public static async Task CheckAllPluginVersionsAsync() { updatesFound = true; AppendUpdateInfo(resultMessage, updateInfo); + SendMessageToClient(userEntity, $"Update available for {plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Gold)} -> {updateInfo.LatestVersion.Color(Color.Green)}"); } } if (!updatesFound) { resultMessage.AppendLine("* All plugins are up to date!"); + LogInfoAndSendMessageToClient(userEntity, "All installed plugins are up to date!"); } else { diff --git a/VCF.Core/Common/UnityMainThreadDispatcher.cs b/VCF.Core/Common/UnityMainThreadDispatcher.cs new file mode 100644 index 0000000..a4fadca --- /dev/null +++ b/VCF.Core/Common/UnityMainThreadDispatcher.cs @@ -0,0 +1,55 @@ +using BepInEx.Unity.IL2CPP.Utils.Collections; +using ProjectM.Physics; +using System; +using System.Collections; +using System.Collections.Concurrent; +using UnityEngine; + +namespace VampireCommandFramework.Common; + +/// +/// Unity-based main thread dispatcher using MonoBehaviour. +/// Allows safe execution of Unity/ECS operations from background threads. +/// +public static class UnityMainThreadDispatcher +{ + static readonly ConcurrentQueue _actionQueue = new(); + static MonoBehaviour monoBehaviour; + + /// + /// Queue an action to be executed on the main thread during the next Update cycle + /// + public static void Enqueue(Action action) + { + if (action == null) return; + + if (monoBehaviour == null) + { + var go = new GameObject("VampireCommandFramework"); + monoBehaviour = go.AddComponent(); + UnityEngine.Object.DontDestroyOnLoad(go); + monoBehaviour.StartCoroutine(RunOnMainThread().WrapToIl2Cpp()); + } + + _actionQueue.Enqueue(action); + } + + static IEnumerator RunOnMainThread() + { + while (true) + { + yield return null; + while (_actionQueue.TryDequeue(out var action)) + { + try + { + action.Invoke(); + } + catch (Exception ex) + { + Log.Error($"Error executing main thread action: {ex.Message}"); + } + } + } + } +} diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index 8759bd2..f62c56c 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -18,7 +18,7 @@ internal class Plugin : BasePlugin public override void Load() { Common.Log.Instance = Log; - + // Initialize configuration EnableVersionCheck = Config.Bind("Version Check", "EnableVersionCheck", true, "Enable automatic checking for plugin updates on Thunderstore at startup"); @@ -50,8 +50,6 @@ public override void Load() { try { - // Add a small delay to ensure everything is fully initialized - await Task.Delay(2000); await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(); } catch (System.Exception ex) From 10691f086d62bf741821870796f2b8293c517ad0 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 20 Oct 2025 19:33:46 -0400 Subject: [PATCH 12/29] Fixing line endings --- VCF.Core/Registry/CommandHistory.cs | 2 +- VCF.Tests/RemainderParameterTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index 4f4bdbb..aa6bb34 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -127,7 +127,7 @@ internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string i if (selectedCommand.Command != null && selectedCommand.Args != null) { var argsCopy = selectedCommand.Args.ToArray(); - argsCopy[0] = ctx; // Ensure the context is current + argsCopy[0] = ctx; // Ensure the context is current return executeCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); } else diff --git a/VCF.Tests/RemainderParameterTests.cs b/VCF.Tests/RemainderParameterTests.cs index c822de7..a83a43b 100644 --- a/VCF.Tests/RemainderParameterTests.cs +++ b/VCF.Tests/RemainderParameterTests.cs @@ -120,9 +120,9 @@ public void RemainderWithOptionalParameters_HandlesAllScenarios() [Test] public void RemainderCommand_OverloadingResolution_SelectsCorrectCommand() { - CommandRegistry.RegisterCommandType(typeof(ConflictingCommands)); - - // If it's ambiguous, should prompt for selection + CommandRegistry.RegisterCommandType(typeof(ConflictingCommands)); + + // If it's ambiguous, should prompt for selection var ctx1 = new AssertReplyContext(); var result1 = CommandRegistry.Handle(ctx1, ".test arg1 arg2"); Assert.That(result1, Is.EqualTo(CommandResult.Success)); From 93ca6a5674c5942e1df0b7c9d97af126f1719873 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 20 Oct 2025 20:36:30 -0400 Subject: [PATCH 13/29] Adds .version command to VCF for allowing anyone to see what plugins are installed and at what version --- README.md | 69 ++++++++++++++++++- VCF.Core/Basics/VersionCommands.cs | 15 ++++ VCF.Core/Common/ThunderstoreVersionChecker.cs | 31 +++++++++ VCF.Core/Plugin.cs | 1 + 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 VCF.Core/Basics/VersionCommands.cs diff --git a/README.md b/README.md index f81a722..30db779 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,73 @@ When your input could match multiple command variations, you'll see a list of op ## Universal Configuration Management Built-in commands for managing BepInEx configurations across all plugins: -- `.config dump ` - View plugin configuration -- `.config set
` - Modify settings +- `.config dump ` - View plugin configuration (admin only) +- `.config set
` - Modify settings (admin only) + +## Plugin Version Management +VCF includes tools to help you track and manage plugin versions on your server. + +### Commands: +- `.version` - Lists all installed plugins with their current versions (available to all users) + +### Automatic Update Checking +VCF automatically checks for plugin updates from Thunderstore in two scenarios: +1. **When the server starts up** - Checks all plugins and logs results to the server console +2. **When an admin authenticates** - Checks all plugins and sends update notifications directly to that admin in-game + +This feature: +- **Scans all installed BepInEx plugins** against the Thunderstore database +- **Uses semantic versioning** to determine if updates are available +- **Logs results to the server console** on startup +- **Sends in-game messages to admins** when they authenticate +- **Is configurable** - can be enabled/disabled via the config file + +### Configuration Options: +- `EnableVersionCheck` (default: true) - Enable/disable automatic update checking + +### Example Server Console Output (Startup): +``` +[Info : VampireCommandFramework] Starting plugin version check... +[Info : VampireCommandFramework] Found 15 installed plugins to check +[Info : VampireCommandFramework] Retrieved 156 packages from Thunderstore +[Info : VampireCommandFramework] Version check completed for 15 plugins: +>> UPDATE AVAILABLE: BloodyCore + Current Version: 1.2.0 + Latest Version: 1.3.1 + Release Date: 2023-12-15T10:30:00Z + Thunderstore URL: https://thunderstore.io/c/v-rising/p/Trodi/BloodyCore/ + +>> UPDATE AVAILABLE: Wetstone + Current Version: 1.0.0 + Latest Version: 1.2.0 + Release Date: 2023-12-10T08:15:22Z + Thunderstore URL: https://thunderstore.io/c/v-rising/p/molenzwiebel/Wetstone/ + +! Updates are available for the plugins listed above. +* Note: Updates must be installed manually - VCF only identifies available updates. +``` + +**OR if all plugins are up to date:** +``` +[Info : VampireCommandFramework] Version check completed for 15 plugins: +* All plugins are up to date! +``` + +### Example In-Game Messages (Admin Authentication): +When an admin authenticates, they receive individual messages for each available update: +``` +[VCF] Starting plugin version check... +[VCF] Found 15 installed plugins to check +[VCF] Update available for BloodyCore: 1.2.0 -> 1.3.1 +[VCF] Update available for Wetstone: 1.0.0 -> 1.2.0 +[VCF] All installed plugins are up to date! +``` + +**Important Notes:** +- The automatic version checker **logs to the server console at startup** and **sends messages to admins when they authenticate** +- The version checker **only identifies available updates** - it does not automatically download or install them +- Updates must be installed manually by the server operator +- The `.version` command provides a quick way for any player to see what's currently installed (without update checking) diff --git a/VCF.Core/Basics/VersionCommands.cs b/VCF.Core/Basics/VersionCommands.cs new file mode 100644 index 0000000..4bf7ed9 --- /dev/null +++ b/VCF.Core/Basics/VersionCommands.cs @@ -0,0 +1,15 @@ +using VampireCommandFramework.Common; + +namespace VampireCommandFramework.Basics; + +internal static class VersionCommands +{ + [Command("version", description: "Lists all installed plugins and their versions")] + public static void VersionCommand(ICommandContext ctx) + { + // Get the user entity if this is a ChatCommandContext + var userEntity = ctx is ChatCommandContext chatCtx ? chatCtx.Event.SenderUserEntity : default; + + ThunderstoreVersionChecker.ListAllPluginVersions(userEntity); + } +} diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs index 5679c8c..9d3c286 100644 --- a/VCF.Core/Common/ThunderstoreVersionChecker.cs +++ b/VCF.Core/Common/ThunderstoreVersionChecker.cs @@ -68,6 +68,37 @@ static void LogWarningAndSendMessageToClient(Entity userEntity, string message) SendMessageToClient(userEntity, message.Color(Color.Gold)); } + /// + /// Lists all installed plugins with their current versions (no update checking) + /// + public static void ListAllPluginVersions(Entity userEntity = default) + { + try + { + // Get all loaded plugins + var installedPlugins = GetInstalledPlugins(); + + if (installedPlugins.Count == 0) + { + LogInfoAndSendMessageToClient(userEntity, "No plugins found."); + return; + } + + LogInfoAndSendMessageToClient(userEntity, $"Installed Plugins ({installedPlugins.Count}):"); + + // Sort plugins by name for easier reading + foreach (var plugin in installedPlugins.OrderBy(p => p.Name)) + { + var message = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}"; + SendMessageToClient(userEntity, message); + } + } + catch (Exception ex) + { + Log.Error($"Error listing plugin versions: {ex.Message}"); + } + } + /// /// Checks all installed plugins for newer versions on Thunderstore /// diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index f62c56c..5cd9d1d 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -36,6 +36,7 @@ public override void Load() CommandRegistry.RegisterCommandType(typeof(Basics.HelpCommands)); CommandRegistry.RegisterCommandType(typeof(Basics.BepInExConfigCommands)); CommandRegistry.RegisterCommandType(typeof(Basics.RepeatCommands)); + CommandRegistry.RegisterCommandType(typeof(Basics.VersionCommands)); IL2CPPChainloader.Instance.Plugins.TryGetValue(PluginInfo.PLUGIN_GUID, out var info); From 144f8ea9c982e6b23e8e7865a6906e9424a2f652 Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 6 Nov 2025 07:06:32 -0500 Subject: [PATCH 14/29] * Version command admin only now * Only version check for those who succeed authing as admin --- VCF.Core/Basics/VersionCommands.cs | 2 +- VCF.Core/Breadstone/AdminAuthPatch.cs | 19 ++++- VCF.Core/Common/ThunderstoreVersionChecker.cs | 80 ++++++++----------- 3 files changed, 53 insertions(+), 48 deletions(-) diff --git a/VCF.Core/Basics/VersionCommands.cs b/VCF.Core/Basics/VersionCommands.cs index 4bf7ed9..1c6ca8b 100644 --- a/VCF.Core/Basics/VersionCommands.cs +++ b/VCF.Core/Basics/VersionCommands.cs @@ -4,7 +4,7 @@ namespace VampireCommandFramework.Basics; internal static class VersionCommands { - [Command("version", description: "Lists all installed plugins and their versions")] + [Command("version", description: "Lists all installed plugins and their versions", adminOnly: true)] public static void VersionCommand(ICommandContext ctx) { // Get the user entity if this is a ChatCommandContext diff --git a/VCF.Core/Breadstone/AdminAuthPatch.cs b/VCF.Core/Breadstone/AdminAuthPatch.cs index bde8b92..c5a8e85 100644 --- a/VCF.Core/Breadstone/AdminAuthPatch.cs +++ b/VCF.Core/Breadstone/AdminAuthPatch.cs @@ -1,8 +1,11 @@ using HarmonyLib; using ProjectM; using ProjectM.Network; +using Stunlock.Network; +using System.Collections.Generic; using System.Threading.Tasks; using Unity.Collections; +using Unity.Entities; using VampireCommandFramework.Common; namespace VampireCommandFramework.Breadstone; @@ -10,13 +13,27 @@ namespace VampireCommandFramework.Breadstone; [HarmonyPatch(typeof(AdminAuthSystem), nameof(AdminAuthSystem.OnUpdate))] internal class AdminAuthPatch { + static List authing = []; public static void Prefix(AdminAuthSystem __instance) { var entities = __instance._Query.ToEntityArray(Allocator.Temp); foreach (var entity in entities) { var fromCharacter = __instance.EntityManager.GetComponentData(entity); - Task.Run(async() => await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(fromCharacter.User)); + authing.Add(fromCharacter.User); } } + + public static void Postfix(AdminAuthSystem __instance) + { + foreach (var entity in authing) + { + var user = __instance.EntityManager.GetComponentData(entity); + if (user.IsAdmin) + { + Task.Run(async () => await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(entity)); + } + } + authing.Clear(); + } } diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs index 9d3c286..307857e 100644 --- a/VCF.Core/Common/ThunderstoreVersionChecker.cs +++ b/VCF.Core/Common/ThunderstoreVersionChecker.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -14,9 +15,7 @@ namespace VampireCommandFramework.Common; -/// -/// Handles version checking against the Thunderstore API -/// +// Handles version checking against the Thunderstore API internal static class ThunderstoreVersionChecker { private static readonly HttpClient _httpClient = new HttpClient(); @@ -58,19 +57,17 @@ static void SendMessageToClient(Entity userEntity, string message) static void LogInfoAndSendMessageToClient(Entity userEntity, string message) { Log.Info(message); - SendMessageToClient(userEntity, message); + var formattedMessage = $"[vcf] ".Color(Color.Primary) + message.Color(Color.Beige); + SendMessageToClient(userEntity, formattedMessage); } static void LogWarningAndSendMessageToClient(Entity userEntity, string message) { - Log.Warning(message); - SendMessageToClient(userEntity, message.Color(Color.Gold)); + var formattedMessage = $"[vcf] ".Color(Color.Primary) + message.Color(Color.Gold); + SendMessageToClient(userEntity, formattedMessage); } - /// - /// Lists all installed plugins with their current versions (no update checking) - /// public static void ListAllPluginVersions(Entity userEntity = default) { try @@ -89,8 +86,9 @@ public static void ListAllPluginVersions(Entity userEntity = default) // Sort plugins by name for easier reading foreach (var plugin in installedPlugins.OrderBy(p => p.Name)) { - var message = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}"; - SendMessageToClient(userEntity, message); + var pluginMessage = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}"; + var formattedMessage = $"[vcf] ".Color(Color.Primary) + pluginMessage; + SendMessageToClient(userEntity, formattedMessage); } } catch (Exception ex) @@ -99,9 +97,7 @@ public static void ListAllPluginVersions(Entity userEntity = default) } } - /// - /// Checks all installed plugins for newer versions on Thunderstore - /// + public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) { try @@ -124,7 +120,7 @@ public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) // Check each plugin against Thunderstore data var updatesFound = false; - var resultMessage = new System.Text.StringBuilder(); + var resultMessage = new StringBuilder(); resultMessage.AppendLine($"Version check completed for {installedPlugins.Count} plugins:"); foreach (var plugin in installedPlugins) @@ -134,7 +130,10 @@ public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) { updatesFound = true; AppendUpdateInfo(resultMessage, updateInfo); - SendMessageToClient(userEntity, $"Update available for {plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Gold)} -> {updateInfo.LatestVersion.Color(Color.Green)}"); + + var updateMessage = $"Update available for {plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Gold)} -> {updateInfo.LatestVersion.Color(Color.Green)}"; + var formattedMessage = $"[vcf] ".Color(Color.Primary) + updateMessage; + SendMessageToClient(userEntity, formattedMessage); } } @@ -158,9 +157,7 @@ public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) } } - /// - /// Gets information about all installed BepInEx plugins - /// + // Gets information about all installed BepInEx plugins private static List GetInstalledPlugins() { var plugins = new List(); @@ -182,9 +179,8 @@ private static List GetInstalledPlugins() return plugins; } - /// - /// Retrieves all V Rising packages from Thunderstore API - /// + + // Retrieves all V Rising packages from Thunderstore API private static async Task> GetThunderstorePackagesAsync() { try @@ -212,9 +208,8 @@ private static async Task> GetThunderstorePackagesAsyn } } - /// - /// Checks if a plugin has an update available on Thunderstore - /// + + // Checks if a plugin has an update available on Thunderstore private static PluginUpdateInfo CheckPluginForUpdate(InstalledPluginInfo plugin, List thunderstorePackages) { try @@ -262,9 +257,8 @@ private static PluginUpdateInfo CheckPluginForUpdate(InstalledPluginInfo plugin, } } - /// - /// Compares two version strings to determine if the second is newer - /// + + // Compares two version strings to determine if the second is newer private static bool IsNewerVersion(string currentVersion, string latestVersion) { try @@ -280,9 +274,8 @@ private static bool IsNewerVersion(string currentVersion, string latestVersion) } } - /// - /// Normalizes version strings to ensure proper Version parsing - /// + + // Normalizes version strings to ensure proper Version parsing private static string NormalizeVersion(string version) { if (string.IsNullOrWhiteSpace(version)) @@ -301,9 +294,8 @@ private static string NormalizeVersion(string version) return version; } - /// - /// Appends information about available update to the StringBuilder - /// + + // Appends information about available update to the StringBuilder private static void AppendUpdateInfo(System.Text.StringBuilder sb, PluginUpdateInfo updateInfo) { sb.AppendLine($">> UPDATE AVAILABLE: {updateInfo.PluginName}"); @@ -317,9 +309,8 @@ private static void AppendUpdateInfo(System.Text.StringBuilder sb, PluginUpdateI sb.AppendLine(); // Add blank line between updates } - /// - /// Information about an installed plugin - /// + + // Information about an installed plugin private class InstalledPluginInfo { public string GUID { get; set; } @@ -327,9 +318,8 @@ private class InstalledPluginInfo public string Version { get; set; } } - /// - /// Information about an available plugin update - /// + + // Information about an available plugin update private class PluginUpdateInfo { public string PluginName { get; set; } @@ -339,9 +329,8 @@ private class PluginUpdateInfo public string PackageUrl { get; set; } } - /// - /// Thunderstore package data structure - /// + + // Thunderstore package data structure private class ThunderstorePackage { [JsonPropertyName("name")] @@ -384,9 +373,8 @@ private class ThunderstorePackage public List Versions { get; set; } } - /// - /// Thunderstore package version data structure - /// + + // Thunderstore package version data structure private class ThunderstoreVersion { [JsonPropertyName("name")] From 83970b081404224bde30c4bf90b7e7c4f9dac8b5 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 12 Jan 2026 19:29:47 -0500 Subject: [PATCH 15/29] Should only be checking the name not dependencies for the package for Version updates --- VCF.Core/Common/ThunderstoreVersionChecker.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs index 307857e..e9ca7a1 100644 --- a/VCF.Core/Common/ThunderstoreVersionChecker.cs +++ b/VCF.Core/Common/ThunderstoreVersionChecker.cs @@ -215,10 +215,7 @@ private static PluginUpdateInfo CheckPluginForUpdate(InstalledPluginInfo plugin, try { // Try to find matching package by GUID first, then by name - var matchingPackage = thunderstorePackages.FirstOrDefault(p => - p.Versions?.Any(v => v.Dependencies?.Any(d => d.Contains(plugin.GUID)) == true) == true) - ?? thunderstorePackages.FirstOrDefault(p => - string.Equals(p.Name, plugin.Name, StringComparison.OrdinalIgnoreCase)); + var matchingPackage = thunderstorePackages.FirstOrDefault(p => string.Equals(p.Name, plugin.Name, StringComparison.OrdinalIgnoreCase)); if (matchingPackage?.Versions == null || matchingPackage.Versions.Count == 0) { From 623419f1d4eb4abc29a00590fa77fe37a2844232 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 21:46:03 -0400 Subject: [PATCH 16/29] Deciding to remove Thunderstore Version checking of Plugins and only have a command for listing the current version of plugins after deciding potentially too many issues --- VCF.Core/Basics/VersionCommands.cs | 3 +- VCF.Core/Breadstone/AdminAuthPatch.cs | 39 -- VCF.Core/Common/ThunderstoreVersionChecker.cs | 410 ------------------ VCF.Core/Common/VersionChecker.cs | 140 ++++++ VCF.Core/Plugin.cs | 33 -- 5 files changed, 142 insertions(+), 483 deletions(-) delete mode 100644 VCF.Core/Breadstone/AdminAuthPatch.cs delete mode 100644 VCF.Core/Common/ThunderstoreVersionChecker.cs create mode 100644 VCF.Core/Common/VersionChecker.cs diff --git a/VCF.Core/Basics/VersionCommands.cs b/VCF.Core/Basics/VersionCommands.cs index 1c6ca8b..94e9af3 100644 --- a/VCF.Core/Basics/VersionCommands.cs +++ b/VCF.Core/Basics/VersionCommands.cs @@ -1,3 +1,4 @@ +using Unity.Entities; using VampireCommandFramework.Common; namespace VampireCommandFramework.Basics; @@ -10,6 +11,6 @@ public static void VersionCommand(ICommandContext ctx) // Get the user entity if this is a ChatCommandContext var userEntity = ctx is ChatCommandContext chatCtx ? chatCtx.Event.SenderUserEntity : default; - ThunderstoreVersionChecker.ListAllPluginVersions(userEntity); + VersionChecker.ListAllPluginVersions(userEntity); } } diff --git a/VCF.Core/Breadstone/AdminAuthPatch.cs b/VCF.Core/Breadstone/AdminAuthPatch.cs deleted file mode 100644 index c5a8e85..0000000 --- a/VCF.Core/Breadstone/AdminAuthPatch.cs +++ /dev/null @@ -1,39 +0,0 @@ -using HarmonyLib; -using ProjectM; -using ProjectM.Network; -using Stunlock.Network; -using System.Collections.Generic; -using System.Threading.Tasks; -using Unity.Collections; -using Unity.Entities; -using VampireCommandFramework.Common; - -namespace VampireCommandFramework.Breadstone; - -[HarmonyPatch(typeof(AdminAuthSystem), nameof(AdminAuthSystem.OnUpdate))] -internal class AdminAuthPatch -{ - static List authing = []; - public static void Prefix(AdminAuthSystem __instance) - { - var entities = __instance._Query.ToEntityArray(Allocator.Temp); - foreach (var entity in entities) - { - var fromCharacter = __instance.EntityManager.GetComponentData(entity); - authing.Add(fromCharacter.User); - } - } - - public static void Postfix(AdminAuthSystem __instance) - { - foreach (var entity in authing) - { - var user = __instance.EntityManager.GetComponentData(entity); - if (user.IsAdmin) - { - Task.Run(async () => await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(entity)); - } - } - authing.Clear(); - } -} diff --git a/VCF.Core/Common/ThunderstoreVersionChecker.cs b/VCF.Core/Common/ThunderstoreVersionChecker.cs deleted file mode 100644 index e9ca7a1..0000000 --- a/VCF.Core/Common/ThunderstoreVersionChecker.cs +++ /dev/null @@ -1,410 +0,0 @@ -using BepInEx.Unity.IL2CPP; -using ProjectM; -using ProjectM.Network; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Unity.Collections; -using Unity.Entities; -using VampireCommandFramework.Breadstone; - -namespace VampireCommandFramework.Common; - -// Handles version checking against the Thunderstore API -internal static class ThunderstoreVersionChecker -{ - private static readonly HttpClient _httpClient = new HttpClient(); - private const string THUNDERSTORE_API_BASE = "https://thunderstore.io/c/v-rising/api/v1/package/"; - - static ThunderstoreVersionChecker() - { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "VampireCommandFramework-VersionChecker/1.0"); - _httpClient.Timeout = TimeSpan.FromSeconds(30); - } - - static void SendMessageToClient(Entity userEntity, string message) - { - if (userEntity == default) return; - - // Queue ECS operations for main thread execution to avoid IL2CPP threading issues - UnityMainThreadDispatcher.Enqueue(() => - { - try - { - // Now we're on main thread - safe to access ECS components - if (VWorld.Server?.EntityManager == null) return; - if (!VWorld.Server.EntityManager.Exists(userEntity)) return; - if (!VWorld.Server.EntityManager.HasComponent(userEntity)) return; - - var user = VWorld.Server.EntityManager.GetComponentData(userEntity); - if (!user.IsConnected) return; - - var msg = new FixedString512Bytes(message); - ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg); - } - catch (Exception ex) - { - Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}"); - } - }); - } - - static void LogInfoAndSendMessageToClient(Entity userEntity, string message) - { - Log.Info(message); - var formattedMessage = $"[vcf] ".Color(Color.Primary) + message.Color(Color.Beige); - SendMessageToClient(userEntity, formattedMessage); - } - - static void LogWarningAndSendMessageToClient(Entity userEntity, string message) - { - Log.Warning(message); - var formattedMessage = $"[vcf] ".Color(Color.Primary) + message.Color(Color.Gold); - SendMessageToClient(userEntity, formattedMessage); - } - - public static void ListAllPluginVersions(Entity userEntity = default) - { - try - { - // Get all loaded plugins - var installedPlugins = GetInstalledPlugins(); - - if (installedPlugins.Count == 0) - { - LogInfoAndSendMessageToClient(userEntity, "No plugins found."); - return; - } - - LogInfoAndSendMessageToClient(userEntity, $"Installed Plugins ({installedPlugins.Count}):"); - - // Sort plugins by name for easier reading - foreach (var plugin in installedPlugins.OrderBy(p => p.Name)) - { - var pluginMessage = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}"; - var formattedMessage = $"[vcf] ".Color(Color.Primary) + pluginMessage; - SendMessageToClient(userEntity, formattedMessage); - } - } - catch (Exception ex) - { - Log.Error($"Error listing plugin versions: {ex.Message}"); - } - } - - - public static async Task CheckAllPluginVersionsAsync(Entity userEntity=default) - { - try - { - LogInfoAndSendMessageToClient(userEntity, "Starting plugin version check..."); - - // Get all loaded plugins - var installedPlugins = GetInstalledPlugins(); - LogInfoAndSendMessageToClient(userEntity, $"Found {installedPlugins.Count} installed plugins to check"); - - // Get all packages from Thunderstore API for V Rising community - var thunderstorePackages = await GetThunderstorePackagesAsync(); - if (thunderstorePackages == null || thunderstorePackages.Count == 0) - { - LogWarningAndSendMessageToClient(userEntity, "Could not retrieve Thunderstore package data"); - return; - } - - Log.Info($"Retrieved {thunderstorePackages.Count} packages from Thunderstore"); - - // Check each plugin against Thunderstore data - var updatesFound = false; - var resultMessage = new StringBuilder(); - resultMessage.AppendLine($"Version check completed for {installedPlugins.Count} plugins:"); - - foreach (var plugin in installedPlugins) - { - var updateInfo = CheckPluginForUpdate(plugin, thunderstorePackages); - if (updateInfo != null) - { - updatesFound = true; - AppendUpdateInfo(resultMessage, updateInfo); - - var updateMessage = $"Update available for {plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Gold)} -> {updateInfo.LatestVersion.Color(Color.Green)}"; - var formattedMessage = $"[vcf] ".Color(Color.Primary) + updateMessage; - SendMessageToClient(userEntity, formattedMessage); - } - } - - if (!updatesFound) - { - resultMessage.AppendLine("* All plugins are up to date!"); - LogInfoAndSendMessageToClient(userEntity, "All installed plugins are up to date!"); - } - else - { - resultMessage.AppendLine("! Updates are available for the plugins listed above."); - resultMessage.AppendLine("* Note: Updates must be installed manually - VCF only identifies available updates."); - } - - // Log the complete results as a single message - Log.Info(resultMessage.ToString()); - } - catch (Exception ex) - { - Log.Error($"Error during version check: {ex.Message}"); - } - } - - // Gets information about all installed BepInEx plugins - private static List GetInstalledPlugins() - { - var plugins = new List(); - - foreach (var pluginKvp in IL2CPPChainloader.Instance.Plugins) - { - var pluginInfo = pluginKvp.Value; - if (pluginInfo?.Metadata != null) - { - plugins.Add(new InstalledPluginInfo - { - GUID = pluginInfo.Metadata.GUID, - Name = pluginInfo.Metadata.Name, - Version = pluginInfo.Metadata.Version.ToString() - }); - } - } - - return plugins; - } - - - // Retrieves all V Rising packages from Thunderstore API - private static async Task> GetThunderstorePackagesAsync() - { - try - { - var response = await _httpClient.GetAsync(THUNDERSTORE_API_BASE); - if (!response.IsSuccessStatusCode) - { - Log.Warning($"Failed to retrieve Thunderstore data: HTTP {response.StatusCode}"); - return null; - } - - var jsonContent = await response.Content.ReadAsStringAsync(); - var packages = JsonSerializer.Deserialize>(jsonContent, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - // All packages from the V Rising API endpoint are V Rising packages - return packages ?? new List(); - } - catch (Exception ex) - { - Log.Error($"Error retrieving Thunderstore packages: {ex.Message}"); - return null; - } - } - - - // Checks if a plugin has an update available on Thunderstore - private static PluginUpdateInfo CheckPluginForUpdate(InstalledPluginInfo plugin, List thunderstorePackages) - { - try - { - // Try to find matching package by GUID first, then by name - var matchingPackage = thunderstorePackages.FirstOrDefault(p => string.Equals(p.Name, plugin.Name, StringComparison.OrdinalIgnoreCase)); - - if (matchingPackage?.Versions == null || matchingPackage.Versions.Count == 0) - { - return null; - } - - // Get the latest version - var latestVersion = matchingPackage.Versions - .OrderByDescending(v => DateTime.Parse(v.DateCreated)) - .FirstOrDefault(); - - if (latestVersion == null) - { - return null; - } - - // Compare versions - if (IsNewerVersion(plugin.Version, latestVersion.VersionNumber)) - { - return new PluginUpdateInfo - { - PluginName = plugin.Name, - CurrentVersion = plugin.Version, - LatestVersion = latestVersion.VersionNumber, - ReleaseDate = latestVersion.DateCreated, - PackageUrl = matchingPackage.PackageUrl - }; - } - - return null; - } - catch (Exception ex) - { - Log.Debug($"Error checking update for {plugin.Name}: {ex.Message}"); - return null; - } - } - - - // Compares two version strings to determine if the second is newer - private static bool IsNewerVersion(string currentVersion, string latestVersion) - { - try - { - var current = new Version(NormalizeVersion(currentVersion)); - var latest = new Version(NormalizeVersion(latestVersion)); - return latest > current; - } - catch - { - // If version parsing fails, do string comparison as fallback - return string.Compare(currentVersion, latestVersion, StringComparison.OrdinalIgnoreCase) < 0; - } - } - - - // Normalizes version strings to ensure proper Version parsing - private static string NormalizeVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) - return "0.0.0"; - - // Remove any non-numeric prefixes (like 'v') - version = version.TrimStart('v', 'V'); - - // Ensure at least 3 parts (Major.Minor.Patch) - var parts = version.Split('.'); - if (parts.Length == 1) - return $"{parts[0]}.0.0"; - if (parts.Length == 2) - return $"{parts[0]}.{parts[1]}.0"; - - return version; - } - - - // Appends information about available update to the StringBuilder - private static void AppendUpdateInfo(System.Text.StringBuilder sb, PluginUpdateInfo updateInfo) - { - sb.AppendLine($">> UPDATE AVAILABLE: {updateInfo.PluginName}"); - sb.AppendLine($" Current Version: {updateInfo.CurrentVersion}"); - sb.AppendLine($" Latest Version: {updateInfo.LatestVersion}"); - sb.AppendLine($" Release Date: {updateInfo.ReleaseDate}"); - if (!string.IsNullOrEmpty(updateInfo.PackageUrl)) - { - sb.AppendLine($" Thunderstore URL: {updateInfo.PackageUrl}"); - } - sb.AppendLine(); // Add blank line between updates - } - - - // Information about an installed plugin - private class InstalledPluginInfo - { - public string GUID { get; set; } - public string Name { get; set; } - public string Version { get; set; } - } - - - // Information about an available plugin update - private class PluginUpdateInfo - { - public string PluginName { get; set; } - public string CurrentVersion { get; set; } - public string LatestVersion { get; set; } - public string ReleaseDate { get; set; } - public string PackageUrl { get; set; } - } - - - // Thunderstore package data structure - private class ThunderstorePackage - { - [JsonPropertyName("name")] - public string Name { get; set; } - - [JsonPropertyName("full_name")] - public string FullName { get; set; } - - [JsonPropertyName("owner")] - public string Owner { get; set; } - - [JsonPropertyName("package_url")] - public string PackageUrl { get; set; } - - [JsonPropertyName("date_created")] - public string DateCreated { get; set; } - - [JsonPropertyName("date_updated")] - public string DateUpdated { get; set; } - - [JsonPropertyName("rating_score")] - public int RatingScore { get; set; } - - [JsonPropertyName("is_pinned")] - public bool IsPinned { get; set; } - - [JsonPropertyName("is_deprecated")] - public bool IsDeprecated { get; set; } - - [JsonPropertyName("has_nsfw_content")] - public bool HasNsfwContent { get; set; } - - [JsonPropertyName("categories")] - public List Categories { get; set; } - - [JsonPropertyName("communities")] - public List Communities { get; set; } - - [JsonPropertyName("versions")] - public List Versions { get; set; } - } - - - // Thunderstore package version data structure - private class ThunderstoreVersion - { - [JsonPropertyName("name")] - public string Name { get; set; } - - [JsonPropertyName("full_name")] - public string FullName { get; set; } - - [JsonPropertyName("description")] - public string Description { get; set; } - - [JsonPropertyName("icon")] - public string Icon { get; set; } - - [JsonPropertyName("version_number")] - public string VersionNumber { get; set; } - - [JsonPropertyName("dependencies")] - public List Dependencies { get; set; } - - [JsonPropertyName("download_url")] - public string DownloadUrl { get; set; } - - [JsonPropertyName("downloads")] - public int Downloads { get; set; } - - [JsonPropertyName("date_created")] - public string DateCreated { get; set; } - - [JsonPropertyName("website_url")] - public string WebsiteUrl { get; set; } - - [JsonPropertyName("is_active")] - public bool IsActive { get; set; } - } -} diff --git a/VCF.Core/Common/VersionChecker.cs b/VCF.Core/Common/VersionChecker.cs new file mode 100644 index 0000000..9e58f1c --- /dev/null +++ b/VCF.Core/Common/VersionChecker.cs @@ -0,0 +1,140 @@ +using BepInEx.Unity.IL2CPP; +using ProjectM; +using ProjectM.Network; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Unity.Collections; +using Unity.Entities; +using VampireCommandFramework.Breadstone; + +namespace VampireCommandFramework.Common; + + +internal static class VersionChecker +{ + public static void ListAllPluginVersions(Entity userEntity = default) + { + try + { + // Get all loaded plugins + var installedPlugins = GetInstalledPlugins(); + + if (installedPlugins.Count == 0) + { + LogInfoAndSendMessageToClient(userEntity, "No plugins found."); + return; + } + + LogInfoAndSendMessageToClient(userEntity, $"Installed Plugins ({installedPlugins.Count}):"); + + // Sort plugins by name for easier reading + foreach (var plugin in installedPlugins.OrderBy(p => p.Name)) + { + var pluginMessage = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}"; + var formattedMessage = $"[vcf] ".Color(Color.Primary) + pluginMessage; + SendMessageToClient(userEntity, formattedMessage); + } + } + catch (Exception ex) + { + Log.Error($"Error listing plugin versions: {ex.Message}"); + } + } + + static void SendMessageToClient(Entity userEntity, string message) + { + if (userEntity == default) return; + + // Queue ECS operations for main thread execution to avoid IL2CPP threading issues + UnityMainThreadDispatcher.Enqueue(() => + { + try + { + // Now we're on main thread - safe to access ECS components + if (VWorld.Server?.EntityManager == null) return; + if (!VWorld.Server.EntityManager.Exists(userEntity)) return; + if (!VWorld.Server.EntityManager.HasComponent(userEntity)) return; + + var user = VWorld.Server.EntityManager.GetComponentData(userEntity); + if (!user.IsConnected) return; + + var msg = new FixedString512Bytes(message); + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg); + } + catch (Exception ex) + { + Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}"); + } + }); + } + + static void LogInfoAndSendMessageToClient(Entity userEntity, string message) + { + Log.Info(message); + SendMessageToClient(userEntity, message); + } + + static void LogWarningAndSendMessageToClient(Entity userEntity, string message) + { + + Log.Warning(message); + SendMessageToClient(userEntity, message.Color(Color.Gold)); + } + + /// Gets information about all installed BepInEx plugins + private static List GetInstalledPlugins() + { + var plugins = new List(); + + foreach (var pluginKvp in IL2CPPChainloader.Instance.Plugins) + { + var pluginInfo = pluginKvp.Value; + if (pluginInfo?.Metadata != null) + { + plugins.Add(new InstalledPluginInfo + { + GUID = pluginInfo.Metadata.GUID, + Name = pluginInfo.Metadata.Name, + Version = pluginInfo.Metadata.Version.ToString() + }); + } + } + + return plugins; + } + + /// Normalizes version strings to ensure proper Version parsing + private static string NormalizeVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return "0.0.0"; + + // Remove any non-numeric prefixes (like 'v') + version = version.TrimStart('v', 'V'); + + // Ensure at least 3 parts (Major.Minor.Patch) + var parts = version.Split('.'); + if (parts.Length == 1) + return $"{parts[0]}.0.0"; + if (parts.Length == 2) + return $"{parts[0]}.{parts[1]}.0"; + + return version; + } + + + /// Information about an installed plugin + private class InstalledPluginInfo + { + public string GUID { get; set; } + public string Name { get; set; } + public string Version { get; set; } + } + + +} diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index 5cd9d1d..33ee1b8 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -1,9 +1,6 @@ using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; -using VampireCommandFramework.Common; -using System.Threading.Tasks; -using BepInEx.Configuration; namespace VampireCommandFramework; @@ -12,17 +9,10 @@ internal class Plugin : BasePlugin { private Harmony _harmony; - // Configuration - private static ConfigEntry EnableVersionCheck; - public override void Load() { Common.Log.Instance = Log; - // Initialize configuration - EnableVersionCheck = Config.Bind("Version Check", "EnableVersionCheck", true, - "Enable automatic checking for plugin updates on Thunderstore at startup"); - if (!Breadstone.VWorld.IsServer) { Log.LogMessage("Note: Vampire Command Framework is loading on the client but only adds functionality on the server at this time, seeing this message is not a problem or bug."); @@ -41,29 +31,6 @@ public override void Load() IL2CPPChainloader.Instance.Plugins.TryGetValue(PluginInfo.PLUGIN_GUID, out var info); Log.LogMessage($"VCF Loaded: {info?.Metadata.Version}"); - - // Check for plugin updates on Thunderstore after all plugins are loaded - if (EnableVersionCheck.Value) - { - IL2CPPChainloader.Instance.Finished += () => - { - _ = Task.Run(async () => - { - try - { - await ThunderstoreVersionChecker.CheckAllPluginVersionsAsync(); - } - catch (System.Exception ex) - { - Log.LogWarning($"Version check failed: {ex.Message}"); - } - }); - }; - } - else - { - Log.LogInfo("Plugin version checking is disabled. Enable it in the config if you want to check for updates."); - } } public override bool Unload() From 628c858c0fcd7b2adf023fdecbb7ac8f24904d94 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:18:27 -0400 Subject: [PATCH 17/29] Fixing _remainder to work properly with command groups and commands that have multiple words and also handling the short hand having a different number of words --- VCF.Core/Registry/CommandRegistry.cs | 52 +++++++- VCF.Tests/RemainderParameterTests.cs | 189 +++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index bffdee2..e255217 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -609,14 +609,56 @@ private static string ExtractRemainderFromOriginalInput(string originalInput, Co // Remove the prefix (.) var afterPrefix = originalInput.Substring(DEFAULT_PREFIX.Length); - // Split by first space to separate command from parameters - var firstSpaceIndex = afterPrefix.IndexOf(' '); - if (firstSpaceIndex == -1) + // Count words to skip by detecting which name variant (full or shorthand) was used in the input + int commandWordCount = 0; + + if (command.GroupAttribute != null) + { + var groupName = command.GroupAttribute.Name; + var groupShortHand = command.GroupAttribute.ShortHand; + + if (groupShortHand != null && afterPrefix.StartsWith(groupShortHand + " ", StringComparison.OrdinalIgnoreCase)) + commandWordCount += groupShortHand.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + else + commandWordCount += groupName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + } + + var cmdName = command.Attribute.Name; + var cmdShortHand = command.Attribute.ShortHand; + + if (cmdShortHand != null) + { + // Skip past the group words to check which command name variant follows + var checkPos = 0; + for (int w = 0; w < commandWordCount; w++) + { + while (checkPos < afterPrefix.Length && afterPrefix[checkPos] != ' ') checkPos++; + while (checkPos < afterPrefix.Length && afterPrefix[checkPos] == ' ') checkPos++; + } + var afterGroup = afterPrefix.Substring(checkPos); + + if (afterGroup.StartsWith(cmdShortHand + " ", StringComparison.OrdinalIgnoreCase) + || afterGroup.Equals(cmdShortHand, StringComparison.OrdinalIgnoreCase)) + commandWordCount += cmdShortHand.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + else + commandWordCount += cmdName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + } + else { - return ""; // No parameters at all + commandWordCount += cmdName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; } - var parametersText = afterPrefix.Substring(firstSpaceIndex + 1); + var pos = 0; + for (int w = 0; w < commandWordCount; w++) + { + while (pos < afterPrefix.Length && afterPrefix[pos] != ' ') pos++; + while (pos < afterPrefix.Length && afterPrefix[pos] == ' ') pos++; + } + + if (pos >= afterPrefix.Length) + return ""; + + var parametersText = afterPrefix.Substring(pos); // If remainder is the first parameter, return all parameters if (remainderParameterIndex == 0) diff --git a/VCF.Tests/RemainderParameterTests.cs b/VCF.Tests/RemainderParameterTests.cs index a83a43b..80c9287 100644 --- a/VCF.Tests/RemainderParameterTests.cs +++ b/VCF.Tests/RemainderParameterTests.cs @@ -50,6 +50,70 @@ public void TestRemainder(ICommandContext ctx, string arg1, string _remainder) } } + [CommandGroup("grp")] + public class GroupedRemainderCommands + { + [Command("go", description: "Go with remainder")] + public void Go(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Grp go: '{_remainder}'"); + } + + [Command("tag", description: "Tag with prefix and remainder")] + public void Tag(ICommandContext ctx, string name, string _remainder) + { + ctx.Reply($"Grp tag: {name}, '{_remainder}'"); + } + } + + [CommandGroup("msg", shortHand: "m")] + public class ShorthandGroupedRemainderCommands + { + [Command("send", description: "Send with remainder")] + public void Send(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Message: '{_remainder}'"); + } + } + + public class MultiWordRemainderCommands + { + [Command("fancy thing", description: "Multi-word command with remainder")] + public void FancyThing(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Fancy thing: '{_remainder}'"); + } + } + + [CommandGroup("stuff")] + public class GroupedMultiWordRemainderCommands + { + [Command("do thing", description: "Grouped multi-word command with remainder")] + public void DoThing(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Stuff do thing: '{_remainder}'"); + } + } + + public class DifferentWordCountShorthandCommands + { + [Command("long command", shortHand: "lc", description: "Different word count shorthand")] + public void LongCommand(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Long command: '{_remainder}'"); + } + } + + [CommandGroup("my group", shortHand: "mg")] + public class DifferentWordCountGroupShorthandCommands + { + [Command("run", description: "Different word count group shorthand")] + public void Run(ICommandContext ctx, string _remainder) + { + ctx.Reply($"Run: '{_remainder}'"); + } + } + #endregion [Test] @@ -140,5 +204,130 @@ public void RemainderCommand_OverloadingResolution_SelectsCorrectCommand() Assert.That(result3, Is.EqualTo(CommandResult.Success)); ctx3.AssertReplyContains("Remainder test: arg1, ''"); } + + [Test] + public void GroupedCommand_RemainderParameter_ExtractsCorrectly() + { + CommandRegistry.RegisterCommandType(typeof(GroupedRemainderCommands)); + + // Multiple words as remainder + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".grp go hello world"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Grp go: 'hello world'"); + + // Empty remainder + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".grp go"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Grp go: ''"); + + // Several words as remainder + var ctx3 = new AssertReplyContext(); + var result3 = CommandRegistry.Handle(ctx3, ".grp go one two three"); + Assert.That(result3, Is.EqualTo(CommandResult.Success)); + ctx3.AssertReplyContains("Grp go: 'one two three'"); + } + + [Test] + public void GroupedCommand_RemainderWithRequiredParam_Works() + { + CommandRegistry.RegisterCommandType(typeof(GroupedRemainderCommands)); + + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".grp tag myname this is the description"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Grp tag: myname, 'this is the description'"); + } + + [Test] + public void ShorthandGroupedCommand_RemainderParameter_Works() + { + CommandRegistry.RegisterCommandType(typeof(ShorthandGroupedRemainderCommands)); + + // Full group name + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".msg send hello world"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Message: 'hello world'"); + + // Shorthand group name + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".m send hello world"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Message: 'hello world'"); + } + + [Test] + public void MultiWordCommand_RemainderParameter_ExtractsCorrectly() + { + CommandRegistry.RegisterCommandType(typeof(MultiWordRemainderCommands)); + + // Multi-word command with remainder + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".fancy thing some extra text"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Fancy thing: 'some extra text'"); + + // Multi-word command with empty remainder + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".fancy thing"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Fancy thing: ''"); + } + + [Test] + public void GroupedMultiWordCommand_RemainderParameter_ExtractsCorrectly() + { + CommandRegistry.RegisterCommandType(typeof(GroupedMultiWordRemainderCommands)); + + // Grouped + multi-word command with remainder + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".stuff do thing some filter text"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Stuff do thing: 'some filter text'"); + + // Grouped + multi-word command with empty remainder + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".stuff do thing"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Stuff do thing: ''"); + } + + [Test] + public void DifferentWordCountShorthand_RemainderParameter_ExtractsCorrectly() + { + CommandRegistry.RegisterCommandType(typeof(DifferentWordCountShorthandCommands)); + + // Full name (2 words to skip) + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".long command top 10 players"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Long command: 'top 10 players'"); + + // Shorthand (1 word to skip) + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".lc top 10 players"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Long command: 'top 10 players'"); + } + + [Test] + public void DifferentWordCountGroupShorthand_RemainderParameter_ExtractsCorrectly() + { + CommandRegistry.RegisterCommandType(typeof(DifferentWordCountGroupShorthandCommands)); + + // Full group name (2+1=3 words to skip) + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".my group run some reason here"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("Run: 'some reason here'"); + + // Shorthand group name (1+1=2 words to skip) + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".mg run some reason here"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("Run: 'some reason here'"); + } } } \ No newline at end of file From c603235c2e6b3022e07cb5c4a5211796372f170e Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:31:14 -0400 Subject: [PATCH 18/29] SImplifying VersionChecker removing unnecessary code that we don't have the Thunderstore version checking anymore --- VCF.Core/Common/VersionChecker.cs | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/VCF.Core/Common/VersionChecker.cs b/VCF.Core/Common/VersionChecker.cs index 9e58f1c..00792d0 100644 --- a/VCF.Core/Common/VersionChecker.cs +++ b/VCF.Core/Common/VersionChecker.cs @@ -4,10 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; using Unity.Collections; using Unity.Entities; using VampireCommandFramework.Breadstone; @@ -79,13 +75,6 @@ static void LogInfoAndSendMessageToClient(Entity userEntity, string message) SendMessageToClient(userEntity, message); } - static void LogWarningAndSendMessageToClient(Entity userEntity, string message) - { - - Log.Warning(message); - SendMessageToClient(userEntity, message.Color(Color.Gold)); - } - /// Gets information about all installed BepInEx plugins private static List GetInstalledPlugins() { @@ -108,25 +97,6 @@ private static List GetInstalledPlugins() return plugins; } - /// Normalizes version strings to ensure proper Version parsing - private static string NormalizeVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) - return "0.0.0"; - - // Remove any non-numeric prefixes (like 'v') - version = version.TrimStart('v', 'V'); - - // Ensure at least 3 parts (Major.Minor.Patch) - var parts = version.Split('.'); - if (parts.Length == 1) - return $"{parts[0]}.0.0"; - if (parts.Length == 2) - return $"{parts[0]}.{parts[1]}.0"; - - return version; - } - /// Information about an installed plugin private class InstalledPluginInfo From 26e008b2a3b6278dba168be9f0d842ff033aec78 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:31:26 -0400 Subject: [PATCH 19/29] Fixing line endings --- VCF.Core/Common/UnityMainThreadDispatcher.cs | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/VCF.Core/Common/UnityMainThreadDispatcher.cs b/VCF.Core/Common/UnityMainThreadDispatcher.cs index a4fadca..9841e27 100644 --- a/VCF.Core/Common/UnityMainThreadDispatcher.cs +++ b/VCF.Core/Common/UnityMainThreadDispatcher.cs @@ -14,8 +14,8 @@ namespace VampireCommandFramework.Common; public static class UnityMainThreadDispatcher { static readonly ConcurrentQueue _actionQueue = new(); - static MonoBehaviour monoBehaviour; - + static MonoBehaviour monoBehaviour; + /// /// Queue an action to be executed on the main thread during the next Update cycle /// @@ -23,22 +23,22 @@ public static void Enqueue(Action action) { if (action == null) return; - if (monoBehaviour == null) + if (monoBehaviour == null) { var go = new GameObject("VampireCommandFramework"); monoBehaviour = go.AddComponent(); - UnityEngine.Object.DontDestroyOnLoad(go); - monoBehaviour.StartCoroutine(RunOnMainThread().WrapToIl2Cpp()); - } - + UnityEngine.Object.DontDestroyOnLoad(go); + monoBehaviour.StartCoroutine(RunOnMainThread().WrapToIl2Cpp()); + } + _actionQueue.Enqueue(action); } static IEnumerator RunOnMainThread() { - while (true) - { - yield return null; + while (true) + { + yield return null; while (_actionQueue.TryDequeue(out var action)) { try @@ -49,7 +49,7 @@ static IEnumerator RunOnMainThread() { Log.Error($"Error executing main thread action: {ex.Message}"); } - } + } } } } From ccd6b0cd9ecacfe89aa14306c8c21a1be5daf28d Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:50:08 -0400 Subject: [PATCH 20/29] Remove Thunderstore version checking documentation and fix .version description Removed the Automatic Update Checking section from the README as the Thunderstore version checking feature was removed. Fixed .version command description to correctly state it is admin only. --- README.md | 62 +------------------------------------------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/README.md b/README.md index 30db779..78c4f26 100644 --- a/README.md +++ b/README.md @@ -218,67 +218,7 @@ Built-in commands for managing BepInEx configurations across all plugins: VCF includes tools to help you track and manage plugin versions on your server. ### Commands: -- `.version` - Lists all installed plugins with their current versions (available to all users) - -### Automatic Update Checking -VCF automatically checks for plugin updates from Thunderstore in two scenarios: -1. **When the server starts up** - Checks all plugins and logs results to the server console -2. **When an admin authenticates** - Checks all plugins and sends update notifications directly to that admin in-game - -This feature: -- **Scans all installed BepInEx plugins** against the Thunderstore database -- **Uses semantic versioning** to determine if updates are available -- **Logs results to the server console** on startup -- **Sends in-game messages to admins** when they authenticate -- **Is configurable** - can be enabled/disabled via the config file - -### Configuration Options: -- `EnableVersionCheck` (default: true) - Enable/disable automatic update checking - -### Example Server Console Output (Startup): -``` -[Info : VampireCommandFramework] Starting plugin version check... -[Info : VampireCommandFramework] Found 15 installed plugins to check -[Info : VampireCommandFramework] Retrieved 156 packages from Thunderstore -[Info : VampireCommandFramework] Version check completed for 15 plugins: ->> UPDATE AVAILABLE: BloodyCore - Current Version: 1.2.0 - Latest Version: 1.3.1 - Release Date: 2023-12-15T10:30:00Z - Thunderstore URL: https://thunderstore.io/c/v-rising/p/Trodi/BloodyCore/ - ->> UPDATE AVAILABLE: Wetstone - Current Version: 1.0.0 - Latest Version: 1.2.0 - Release Date: 2023-12-10T08:15:22Z - Thunderstore URL: https://thunderstore.io/c/v-rising/p/molenzwiebel/Wetstone/ - -! Updates are available for the plugins listed above. -* Note: Updates must be installed manually - VCF only identifies available updates. -``` - -**OR if all plugins are up to date:** -``` -[Info : VampireCommandFramework] Version check completed for 15 plugins: -* All plugins are up to date! -``` - -### Example In-Game Messages (Admin Authentication): -When an admin authenticates, they receive individual messages for each available update: -``` -[VCF] Starting plugin version check... -[VCF] Found 15 installed plugins to check -[VCF] Update available for BloodyCore: 1.2.0 -> 1.3.1 -[VCF] Update available for Wetstone: 1.0.0 -> 1.2.0 -[VCF] All installed plugins are up to date! -``` - -**Important Notes:** -- The automatic version checker **logs to the server console at startup** and **sends messages to admins when they authenticate** -- The version checker **only identifies available updates** - it does not automatically download or install them -- Updates must be installed manually by the server operator -- The `.version` command provides a quick way for any player to see what's currently installed (without update checking) - +- `.version` - Lists all installed plugins and their current versions (admin only) ## Help From 414c020c76771e25d96396eec7228c5b48448b5b Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:52:06 -0400 Subject: [PATCH 21/29] Fix command history replay using stale context instead of current context The .! and .! # commands were passing the original stored args array to executeCommandWithArgs instead of the copy with the updated context. --- VCF.Core/Registry/CommandHistory.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index aa6bb34..cbd8251 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -128,7 +128,7 @@ internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string i { var argsCopy = selectedCommand.Args.ToArray(); argsCopy[0] = ctx; // Ensure the context is current - return executeCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); + return executeCommandWithArgs(ctx, selectedCommand.Command, argsCopy); } else { @@ -146,7 +146,9 @@ internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string i // If Command and Args are available (successfully parsed), use them directly if (mostRecent.Command != null && mostRecent.Args != null) { - return executeCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); + var argsCopy = mostRecent.Args.ToArray(); + argsCopy[0] = ctx; + return executeCommandWithArgs(ctx, mostRecent.Command, argsCopy); } else { From 001a98a4a8dbffbfc3b8f7c7b47b234880a15de1 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 24 Mar 2026 22:55:59 -0400 Subject: [PATCH 22/29] Remove UnityMainThreadDispatcher as it is no longer needed Was only used for marshalling async Thunderstore web requests back to the main thread. Since version checking is now synchronous from chat commands which already run on the main thread, the dispatcher is unnecessary. --- VCF.Core/Common/UnityMainThreadDispatcher.cs | 55 -------------------- VCF.Core/Common/VersionChecker.cs | 31 +++++------ 2 files changed, 13 insertions(+), 73 deletions(-) delete mode 100644 VCF.Core/Common/UnityMainThreadDispatcher.cs diff --git a/VCF.Core/Common/UnityMainThreadDispatcher.cs b/VCF.Core/Common/UnityMainThreadDispatcher.cs deleted file mode 100644 index 9841e27..0000000 --- a/VCF.Core/Common/UnityMainThreadDispatcher.cs +++ /dev/null @@ -1,55 +0,0 @@ -using BepInEx.Unity.IL2CPP.Utils.Collections; -using ProjectM.Physics; -using System; -using System.Collections; -using System.Collections.Concurrent; -using UnityEngine; - -namespace VampireCommandFramework.Common; - -/// -/// Unity-based main thread dispatcher using MonoBehaviour. -/// Allows safe execution of Unity/ECS operations from background threads. -/// -public static class UnityMainThreadDispatcher -{ - static readonly ConcurrentQueue _actionQueue = new(); - static MonoBehaviour monoBehaviour; - - /// - /// Queue an action to be executed on the main thread during the next Update cycle - /// - public static void Enqueue(Action action) - { - if (action == null) return; - - if (monoBehaviour == null) - { - var go = new GameObject("VampireCommandFramework"); - monoBehaviour = go.AddComponent(); - UnityEngine.Object.DontDestroyOnLoad(go); - monoBehaviour.StartCoroutine(RunOnMainThread().WrapToIl2Cpp()); - } - - _actionQueue.Enqueue(action); - } - - static IEnumerator RunOnMainThread() - { - while (true) - { - yield return null; - while (_actionQueue.TryDequeue(out var action)) - { - try - { - action.Invoke(); - } - catch (Exception ex) - { - Log.Error($"Error executing main thread action: {ex.Message}"); - } - } - } - } -} diff --git a/VCF.Core/Common/VersionChecker.cs b/VCF.Core/Common/VersionChecker.cs index 00792d0..d0aa6bc 100644 --- a/VCF.Core/Common/VersionChecker.cs +++ b/VCF.Core/Common/VersionChecker.cs @@ -46,27 +46,22 @@ static void SendMessageToClient(Entity userEntity, string message) { if (userEntity == default) return; - // Queue ECS operations for main thread execution to avoid IL2CPP threading issues - UnityMainThreadDispatcher.Enqueue(() => + try { - try - { - // Now we're on main thread - safe to access ECS components - if (VWorld.Server?.EntityManager == null) return; - if (!VWorld.Server.EntityManager.Exists(userEntity)) return; - if (!VWorld.Server.EntityManager.HasComponent(userEntity)) return; + if (VWorld.Server?.EntityManager == null) return; + if (!VWorld.Server.EntityManager.Exists(userEntity)) return; + if (!VWorld.Server.EntityManager.HasComponent(userEntity)) return; - var user = VWorld.Server.EntityManager.GetComponentData(userEntity); - if (!user.IsConnected) return; + var user = VWorld.Server.EntityManager.GetComponentData(userEntity); + if (!user.IsConnected) return; - var msg = new FixedString512Bytes(message); - ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg); - } - catch (Exception ex) - { - Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}"); - } - }); + var msg = new FixedString512Bytes(message); + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg); + } + catch (Exception ex) + { + Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}"); + } } static void LogInfoAndSendMessageToClient(Entity userEntity, string message) From 36d0bcf074c43be196cfd987ed66ff8fc376652a Mon Sep 17 00:00:00 2001 From: Amber Date: Sat, 11 Apr 2026 09:27:31 -0400 Subject: [PATCH 23/29] Input parsing now uses CommandRegistry.ParseInput and fixed _remainder not working with assembly-prefixed commands --- VCF.Core/Registry/CommandCache.cs | 7 +- VCF.Core/Registry/CommandHistory.cs | 30 +---- VCF.Core/Registry/CommandRegistry.cs | 62 +++++---- VCF.Core/Registry/ParsedCommandInput.cs | 22 ++++ VCF.Tests/AssemblyCommandTests.cs | 166 +++++++++++++++++++++++- 5 files changed, 229 insertions(+), 58 deletions(-) create mode 100644 VCF.Core/Registry/ParsedCommandInput.cs diff --git a/VCF.Core/Registry/CommandCache.cs b/VCF.Core/Registry/CommandCache.cs index 748f08a..d51da92 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -171,10 +171,9 @@ internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName { // Check if this remainder command can handle the provided parameter count var remainderParams = remainderCmd.Method.GetParameters(); - var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 1; // Exclude _remainder itself - var maxParams = remainderParams.Length - 1; // Exclude _remainder - - if (parameters.Length >= requiredParams && parameters.Length <= maxParams) + var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and _remainder itself + + if (parameters.Length >= requiredParams) { exactMatches.Add((remainderCmd, parameters)); } diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index cbd8251..49b217b 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -269,32 +269,12 @@ private static (CommandMetadata command, object[] args) ParseCommandForHistory(I return (null, null); } - // Remove the prefix for processing - string afterPrefix = input.Substring(CommandRegistry.DEFAULT_PREFIX.Length); - - // Check if this could be an assembly-specific command - string assemblyName = null; - string commandInput = 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 = CommandRegistry.AssemblyCommandMap.Keys.Any(assemblyName => - assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } + // Parse assembly prefix, command, and remainder in one place + var parsed = CommandRegistry.ParseInput(input); // Get command(s) based on input - we need to access the cache through CommandRegistry - var matchedCommand = CommandRegistry.GetCommandFromCache(commandInput, assemblyName); - + var matchedCommand = CommandRegistry.GetCommandFromCache(parsed.CommandInput, parsed.AssemblyName); + if (matchedCommand == null || !matchedCommand.IsMatched) { matchedCommand = CommandRegistry.GetCommandFromCache(input); @@ -312,7 +292,7 @@ private static (CommandMetadata command, object[] args) ParseCommandForHistory(I { if (!CommandRegistry.CanCommandExecute(ctx, command)) continue; - var (success, commandArgs, error) = CommandRegistry.TryConvertParameters(ctx, command, cmdArgs, input); + var (success, commandArgs, error) = CommandRegistry.TryConvertParameters(ctx, command, cmdArgs, parsed.CommandInput); if (success) { return (command, commandArgs); diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index e255217..e12de21 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -42,6 +42,31 @@ internal static void Reset() // Store pending commands for selection private static Dictionary commands)> _pendingCommands = new(); + internal static ParsedCommandInput ParseInput(string input) + { + string afterPrefix = input.Substring(DEFAULT_PREFIX.Length); + int spaceIndex = afterPrefix.IndexOf(' '); + if (spaceIndex > 0) + { + string potentialAssemblyName = afterPrefix.Substring(0, spaceIndex); + bool isValidAssembly = AssemblyCommandMap.Keys.Any(an => + an.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); + if (isValidAssembly) + { + string afterAssembly = afterPrefix.Substring(spaceIndex + 1); + string commandInput = DEFAULT_PREFIX + afterAssembly; + + // Only treat as assembly-qualified if that assembly has a matching command + var assemblyMatch = _cache.GetCommandFromAssembly(commandInput, potentialAssemblyName); + if (assemblyMatch != null && assemblyMatch.IsMatched) + { + return new ParsedCommandInput(potentialAssemblyName, commandInput, afterAssembly); + } + } + } + return new ParsedCommandInput(null, input, afterPrefix); + } + internal static CacheResult GetCommandFromCache(string input, string assemblyName = null) { if (assemblyName != null) @@ -96,8 +121,9 @@ internal static IEnumerable FindCloseMatches(ICommandContext ctx, string return Enumerable.Empty(); } - // Remove the prefix if it exists to match command names better - var normalizedInput = input[1..].ToLowerInvariant(); + // Remove the prefix (and assembly if present) to match command names better + var parsed = ParseInput(input); + var normalizedInput = parsed.AfterPrefixAndAssembly.ToLowerInvariant(); var maxDistance = Math.Max(maxFixedDistance, (int)Math.Ceiling(normalizedInput.Length * maxRelativeDistance)); @@ -227,34 +253,14 @@ public static CommandResult Handle(ICommandContext ctx, string input) return CommandHistory.HandleHistoryCommand(ctx, input.Trim(), Handle, ExecuteCommandWithArgs); } - // 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(assemblyName => - assemblyName.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } + // Parse assembly prefix, command, and remainder in one place + var parsed = ParseInput(input); // Get command(s) based on input CacheResult matchedCommand = null; - if (assemblyName != null) + if (parsed.HasAssembly) { - matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); + matchedCommand = _cache.GetCommandFromAssembly(parsed.CommandInput, parsed.AssemblyName); } if (matchedCommand == null || !matchedCommand.IsMatched) { @@ -279,7 +285,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) if (commands.Count() == 1) { var (command, args) = commands.First(); - return ExecuteCommand(ctx, command, args, input); + return ExecuteCommand(ctx, command, args, parsed.CommandInput); } // Multiple commands match, try to convert parameters for each @@ -290,7 +296,7 @@ public static CommandResult Handle(ICommandContext ctx, string input) { if (!CanCommandExecute(ctx, command)) continue; - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args, parsed.CommandInput); if (success) { successfulCommands.Add((command, commandArgs, null)); diff --git a/VCF.Core/Registry/ParsedCommandInput.cs b/VCF.Core/Registry/ParsedCommandInput.cs new file mode 100644 index 0000000..e09648c --- /dev/null +++ b/VCF.Core/Registry/ParsedCommandInput.cs @@ -0,0 +1,22 @@ +namespace VampireCommandFramework.Registry; + +internal readonly struct ParsedCommandInput +{ + // The assembly name if present, otherwise null + internal string AssemblyName { get; } + + // The command input with assembly prefix stripped but "." prefix restored + internal string CommandInput { get; } + + // The text after the prefix and assembly, for fuzzy matching + internal string AfterPrefixAndAssembly { get; } + + internal bool HasAssembly => AssemblyName != null; + + internal ParsedCommandInput(string assemblyName, string commandInput, string afterPrefixAndAssembly) + { + AssemblyName = assemblyName; + CommandInput = commandInput; + AfterPrefixAndAssembly = afterPrefixAndAssembly; + } +} diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs index b84b013..d797178 100644 --- a/VCF.Tests/AssemblyCommandTests.cs +++ b/VCF.Tests/AssemblyCommandTests.cs @@ -47,6 +47,69 @@ public void AddCommand(ICommandContext ctx, int a, int b) } } + // Assembly + remainder-only command + public class MyMod1RemainderCommands + { + [Command("echo", description: "MyMod1 echo with remainder")] + public void EchoRemainder(ICommandContext ctx, string _remainder) + { + ctx.Reply($"MyMod1 remainder: '{_remainder}'"); + } + } + + // Assembly + param + remainder command + public class MyMod1ParamRemainderCommands + { + [Command("say", description: "MyMod1 say with prefix and remainder")] + public void SayWithRemainder(ICommandContext ctx, string prefix, string _remainder) + { + ctx.Reply($"MyMod1 {prefix}: {_remainder}"); + } + } + + // Assembly + grouped command + remainder + [CommandGroup("grp")] + public class MyMod1GroupedRemainderCommands + { + [Command("go", description: "MyMod1 grouped go with remainder")] + public void Go(ICommandContext ctx, string _remainder) + { + ctx.Reply($"MyMod1 grp go: '{_remainder}'"); + } + } + + // Assembly whose name collides with a command group name + public class GrpAssemblyCommands + { + [Command("unrelated", description: "Unrelated command in grp assembly")] + public void Unrelated(ICommandContext ctx) + { + ctx.Reply("grp assembly unrelated"); + } + } + + // Command group whose name collides with an assembly name + [CommandGroup("grp")] + public class GrpGroupCommands + { + [Command("go", description: "Go command in grp group")] + public void Go(ICommandContext ctx, string _remainder) + { + ctx.Reply($"grp group go: '{_remainder}'"); + } + } + + // Assembly + grouped command + shorthand group + remainder + [CommandGroup("msg", shortHand: "m")] + public class MyMod1ShorthandGroupRemainderCommands + { + [Command("send", description: "MyMod1 send with remainder")] + public void Send(ICommandContext ctx, string _remainder) + { + ctx.Reply($"MyMod1 msg send: '{_remainder}'"); + } + } + #endregion #region Helper Methods @@ -81,7 +144,7 @@ private void RegisterCommandsWithMockAssembly(System.Type commandType, string mo var newCommandMetadata = entry.Key with { AssemblyName = mockAssemblyName }; mockCommandCache[newCommandMetadata] = entry.Value; - // Update CommandCache + // Update CommandCache (_newCache) foreach(var cacheEntry in CommandRegistry._cache._newCache.Values) { foreach(var commandList in cacheEntry.Values) @@ -95,6 +158,18 @@ private void RegisterCommandsWithMockAssembly(System.Type commandType, string mo } } } + + // Update CommandCache (_remainderCache) + foreach(var remainderList in CommandRegistry._cache._remainderCache.Values) + { + for (int i = 0; i < remainderList.Count; i++) + { + if (remainderList[i].Method == entry.Key.Method) + { + remainderList[i] = newCommandMetadata; + } + } + } } else { @@ -229,6 +304,95 @@ public void SameCommandNameInDifferentAssemblies_UsesCorrectOne() ctx2.AssertReplyContains("MyMod2 test command executed"); } + [Test] + public void AssemblySpecificCommand_WithRemainder_ExtractsCorrectly() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1RemainderCommands), "MyMod1"); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".MyMod1 echo hello world this is text"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 remainder: 'hello world this is text'"); + } + + [Test] + public void AssemblySpecificCommand_WithParamAndRemainder_ExtractsCorrectly() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1ParamRemainderCommands), "MyMod1"); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".MyMod1 say INFO this is the message"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 INFO: this is the message"); + } + + [Test] + public void AssemblySpecificCommand_GroupedWithRemainder_ExtractsCorrectly() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1GroupedRemainderCommands), "MyMod1"); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".MyMod1 grp go hello world"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 grp go: 'hello world'"); + } + + [Test] + public void AssemblySpecificCommand_GroupedWithRemainder_EmptyRemainder() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1GroupedRemainderCommands), "MyMod1"); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".MyMod1 grp go"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 grp go: ''"); + } + + [Test] + public void AssemblySpecificCommand_ShorthandGroupWithRemainder_FullAndShort() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1ShorthandGroupRemainderCommands), "MyMod1"); + + // Full group name + var ctx1 = new AssertReplyContext(); + var result1 = CommandRegistry.Handle(ctx1, ".MyMod1 msg send hello world"); + Assert.That(result1, Is.EqualTo(CommandResult.Success)); + ctx1.AssertReplyContains("MyMod1 msg send: 'hello world'"); + + // Shorthand group name + var ctx2 = new AssertReplyContext(); + var result2 = CommandRegistry.Handle(ctx2, ".MyMod1 m send hello world"); + Assert.That(result2, Is.EqualTo(CommandResult.Success)); + ctx2.AssertReplyContains("MyMod1 msg send: 'hello world'"); + } + + [Test] + public void AssemblySpecificCommand_WithRemainder_EmptyRemainder() + { + RegisterCommandsWithMockAssembly(typeof(MyMod1RemainderCommands), "MyMod1"); + + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".MyMod1 echo"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("MyMod1 remainder: ''"); + } + + [Test] + public void AssemblyNameMatchesGroupName_FallsBackToGroupCommand() + { + // Register an assembly named "grp" that has no "go" command + RegisterCommandsWithMockAssembly(typeof(GrpAssemblyCommands), "grp"); + // Register a command group named "grp" with a "go" command (in a different assembly) + CommandRegistry.RegisterCommandType(typeof(GrpGroupCommands)); + + // ".grp go hello" — assembly "grp" exists but has no "go" command, + // so it should fall back to global lookup and match the "grp" command group's "go" command + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".grp go hello world"); + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyContains("grp group go: 'hello world'"); + } + #endregion } } From c73976c28e64917626fd55941ffd099832a04c45 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 12 Apr 2026 11:08:46 -0400 Subject: [PATCH 24/29] Moving saving command history to be async to not halt the chat processing thread. --- VCF.Core/Registry/CommandHistory.cs | 65 +++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index 49b217b..307d25f 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading; using BepInEx; using VampireCommandFramework.Common; @@ -20,17 +21,30 @@ public static class CommandHistory // Command history directory path private static string HistoryDirectory => Path.Combine(Path.Combine(Paths.ConfigPath, PluginInfo.PLUGIN_NAME), "CommandHistory"); - + + // Using a regular Queue with explicit locking instead of ConcurrentQueue so that the + // empty-check + thread-exit and enqueue + thread-start are each done as one atomic operation, + // preventing the save thread from exiting while new work is being enqueued. + private static readonly Queue<(string filePath, string[] inputs)> _saveQueue = new(); + private static volatile bool _saveThreadRunning = false; + #endregion #region Public Methods internal static void Reset() { + WaitOnSaves(); _commandHistory.Clear(); _loadedHistories.Clear(); } + internal static void WaitOnSaves() + { + while (_saveThreadRunning) + Thread.Sleep(1); + } + internal static bool IsHistoryLoaded(string contextName) { return _loadedHistories.Contains(contextName); @@ -188,16 +202,19 @@ private static void SaveHistoryToFile(string contextName, List<(string input, Co { try { - if (!Directory.Exists(HistoryDirectory)) - { - Directory.CreateDirectory(HistoryDirectory); - } - - // Use a safe filename by replacing invalid characters var safeFileName = string.Join("_", contextName.Split(Path.GetInvalidFileNameChars())); string filePath = Path.Combine(HistoryDirectory, $"{safeFileName}.txt"); - var inputsOnly = history.Select(h => h.input).ToArray(); - File.WriteAllLines(filePath, inputsOnly); + var inputs = history.Select(h => h.input).ToArray(); + + lock (_saveQueue) + { + _saveQueue.Enqueue((filePath, inputs)); + if (!_saveThreadRunning) + { + _saveThreadRunning = true; + new Thread(ProcessSaveQueue) { IsBackground = true, Name = "VCF-HistorySave" }.Start(); + } + } } catch (Exception ex) { @@ -205,6 +222,36 @@ private static void SaveHistoryToFile(string contextName, List<(string input, Co } } + private static bool TryDequeueFromSaveQueue(out (string filePath, string[] inputs) item) + { + lock (_saveQueue) + { + if (_saveQueue.TryDequeue(out item)) + return true; + + _saveThreadRunning = false; + return false; + } + } + + private static void ProcessSaveQueue() + { + while (TryDequeueFromSaveQueue(out var item)) + { + try + { + if (!Directory.Exists(HistoryDirectory)) + Directory.CreateDirectory(HistoryDirectory); + + File.WriteAllLines(item.filePath, item.inputs); + } + catch (Exception ex) + { + Log.Error($"Failed to save command history to {item.filePath}: {ex.Message}"); + } + } + } + private static void LoadHistoryFromFile(ICommandContext ctx, string contextName) { try From abffa43cb6452853b6572c8ac7c0d62232968294 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 12 Apr 2026 15:10:50 -0400 Subject: [PATCH 25/29] Changing _remainder to use a Remainder parameter which if used has to be the last argument for the command --- VCF.Core/Basics/HelpCommand.cs | 11 ++- VCF.Core/Framework/RemainderAttribute.cs | 6 ++ VCF.Core/Registry/CommandCache.cs | 11 +-- VCF.Core/Registry/CommandRegistry.cs | 40 ++++++-- VCF.Tests/AssemblyCommandTests.cs | 119 +++++++++++++++++++++-- VCF.Tests/HelpTests.cs | 17 ++++ VCF.Tests/RemainderParameterTests.cs | 65 ++++++++----- 7 files changed, 220 insertions(+), 49 deletions(-) create mode 100644 VCF.Core/Framework/RemainderAttribute.cs diff --git a/VCF.Core/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index 074a521..eebec17 100644 --- a/VCF.Core/Basics/HelpCommand.cs +++ b/VCF.Core/Basics/HelpCommand.cs @@ -160,11 +160,14 @@ internal static string GetOrGenerateUsage(CommandMetadata command) var usageText = command.Attribute.Usage; if (string.IsNullOrWhiteSpace(usageText)) { - var usages = command.Parameters.Select( - p => !p.HasDefaultValue + var usages = command.Parameters.Select(p => + { + if (CommandRegistry.IsRemainderParameter(p)) + return $"<{p.Name}...>".Color(Color.LightGrey); + return !p.HasDefaultValue ? $"({p.Name})".Color(Color.LightGrey) // todo could compress this for the cases with no defaulting - : $"[{p.Name}={p.DefaultValue}]".Color(Color.Green) - ); + : $"[{p.Name}={p.DefaultValue}]".Color(Color.Green); + }); usageText = string.Join(" ", usages); } diff --git a/VCF.Core/Framework/RemainderAttribute.cs b/VCF.Core/Framework/RemainderAttribute.cs new file mode 100644 index 0000000..252da29 --- /dev/null +++ b/VCF.Core/Framework/RemainderAttribute.cs @@ -0,0 +1,6 @@ +using System; + +namespace VampireCommandFramework; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class RemainderAttribute : Attribute { } diff --git a/VCF.Core/Registry/CommandCache.cs b/VCF.Core/Registry/CommandCache.cs index d51da92..985fc3a 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -20,9 +20,8 @@ internal void AddCommand(string key, ParameterInfo[] parameters, CommandMetadata var p = parameters.Length; var d = parameters.Where(p => p.HasDefaultValue).Count(); - bool hasRemainder = parameters.Length > 0 && - parameters[parameters.Length - 1].Name == "_remainder" && - parameters[parameters.Length - 1].ParameterType == typeof(string); + bool hasRemainder = parameters.Length > 0 && + CommandRegistry.IsRemainderParameter(parameters[parameters.Length - 1]); if (!_newCache.ContainsKey(key)) { @@ -45,7 +44,7 @@ internal void AddCommand(string key, ParameterInfo[] parameters, CommandMetadata } else { - // Original logic for non-_remainder commands + // Original logic for non-remainder commands for (var i = p - d; i <= p; i++) { _newCache[key] = _newCache.GetValueOrDefault(key, new()) ?? new(); @@ -101,7 +100,7 @@ internal CacheResult GetCommand(string rawInput) { // Check if this remainder command can handle the provided parameter count var remainderParams = remainderCmd.Method.GetParameters(); - var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and _remainder itself + var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and the [Remainder] parameter itself if (parameters.Length >= requiredParams) { @@ -171,7 +170,7 @@ internal CacheResult GetCommandFromAssembly(string rawInput, string assemblyName { // Check if this remainder command can handle the provided parameter count var remainderParams = remainderCmd.Method.GetParameters(); - var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and _remainder itself + var requiredParams = remainderParams.Count(p => !p.HasDefaultValue) - 2; // Exclude ctx and the [Remainder] parameter itself if (parameters.Length >= requiredParams) { diff --git a/VCF.Core/Registry/CommandRegistry.cs b/VCF.Core/Registry/CommandRegistry.cs index e12de21..be3016c 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -98,12 +98,14 @@ internal static bool CanCommandExecute(ICommandContext ctx, CommandMetadata comm return true; } + internal static bool IsRemainderParameter(ParameterInfo p) + => p.ParameterType == typeof(string) + && p.IsDefined(typeof(RemainderAttribute), inherit: false); + internal static bool HasRemainderParameter(CommandMetadata command) { if (command.Parameters.Length == 0) return false; - - var lastParam = command.Parameters[command.Parameters.Length - 1]; - return lastParam.Name == "_remainder" && lastParam.ParameterType == typeof(string); + return IsRemainderParameter(command.Parameters[command.Parameters.Length - 1]); } internal static IEnumerable FindCloseMatches(ICommandContext ctx, string input) @@ -291,10 +293,15 @@ public static CommandResult Handle(ICommandContext ctx, string 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)>(); + var deniedCommands = new List(); foreach (var (command, args) in commands) { - if (!CanCommandExecute(ctx, command)) continue; + if (!CanCommandExecute(ctx, command)) + { + deniedCommands.Add(command); + continue; + } var (success, commandArgs, error) = TryConvertParameters(ctx, command, args, parsed.CommandInput); if (success) @@ -310,6 +317,16 @@ public static CommandResult Handle(ICommandContext ctx, string input) // Case 1: No command succeeded if (successfulCommands.Count == 0) { + // If every candidate was rejected by middleware (e.g. all admin-only + // for a non-admin caller) and none actually failed parameter conversion, + // emit the same "[denied]" reply the single-command path uses instead of + // the misleading "parameter conversion errors" message. + if (failedCommands.Count == 0 && deniedCommands.Count > 0) + { + ctx.SysReply($"{"[denied]".Color(Color.Red)} {deniedCommands[0].Attribute.Name.ToString().Color(Color.Gold)}"); + return CommandResult.Denied; + } + 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) @@ -441,7 +458,7 @@ private static (bool Success, object[] Args, string Error) TryConvertParametersW { var argCount = args?.Length ?? 0; var paramsCount = command.Parameters.Length; - var remainderIndex = paramsCount - 1; // _remainder is always last + var remainderIndex = paramsCount - 1; // the [Remainder] parameter is always last // Calculate minimum required parameters (non-optional, non-remainder) var requiredParamCount = 0; @@ -949,11 +966,20 @@ private static void RegisterMethod(Assembly assembly, CommandGroupAttribute grou var parameters = paramInfos.Skip(1).ToArray(); + for (var i = 0; i < parameters.Length - 1; i++) + { + if (parameters[i].IsDefined(typeof(RemainderAttribute), inherit: false)) + { + Log.Error($"Method {method.Name.ToString().Color(Color.Gold)} has [Remainder] on parameter {parameters[i].Name.ToString().Color(Color.Gold)} which is not the last parameter. [Remainder] must be on the last parameter. Command will be ignored."); + return; + } + } + var canConvert = parameters.All(param => { - if (param.Name == "_remainder" && param.ParameterType == typeof(string)) + if (IsRemainderParameter(param)) { - Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a _remainder parameter"); + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a remainder parameter ({param.Name})"); return true; } diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs index d797178..b508954 100644 --- a/VCF.Tests/AssemblyCommandTests.cs +++ b/VCF.Tests/AssemblyCommandTests.cs @@ -51,9 +51,9 @@ public void AddCommand(ICommandContext ctx, int a, int b) public class MyMod1RemainderCommands { [Command("echo", description: "MyMod1 echo with remainder")] - public void EchoRemainder(ICommandContext ctx, string _remainder) + public void EchoRemainder(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"MyMod1 remainder: '{_remainder}'"); + ctx.Reply($"MyMod1 remainder: '{message}'"); } } @@ -61,9 +61,9 @@ public void EchoRemainder(ICommandContext ctx, string _remainder) public class MyMod1ParamRemainderCommands { [Command("say", description: "MyMod1 say with prefix and remainder")] - public void SayWithRemainder(ICommandContext ctx, string prefix, string _remainder) + public void SayWithRemainder(ICommandContext ctx, string prefix, [Remainder] string body) { - ctx.Reply($"MyMod1 {prefix}: {_remainder}"); + ctx.Reply($"MyMod1 {prefix}: {body}"); } } @@ -72,9 +72,9 @@ public void SayWithRemainder(ICommandContext ctx, string prefix, string _remaind public class MyMod1GroupedRemainderCommands { [Command("go", description: "MyMod1 grouped go with remainder")] - public void Go(ICommandContext ctx, string _remainder) + public void Go(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"MyMod1 grp go: '{_remainder}'"); + ctx.Reply($"MyMod1 grp go: '{message}'"); } } @@ -93,9 +93,9 @@ public void Unrelated(ICommandContext ctx) public class GrpGroupCommands { [Command("go", description: "Go command in grp group")] - public void Go(ICommandContext ctx, string _remainder) + public void Go(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"grp group go: '{_remainder}'"); + ctx.Reply($"grp group go: '{message}'"); } } @@ -104,9 +104,51 @@ public void Go(ICommandContext ctx, string _remainder) public class MyMod1ShorthandGroupRemainderCommands { [Command("send", description: "MyMod1 send with remainder")] - public void Send(ICommandContext ctx, string _remainder) + public void Send(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"MyMod1 msg send: '{_remainder}'"); + ctx.Reply($"MyMod1 msg send: '{message}'"); + } + } + + // Two admin-only remainder commands with the same command name but + // different non-remainder parameter counts, intended to be registered + // under two different mock assembly names. + public class AdminRemainderCommandsA + { + [Command("foo", adminOnly: true)] + public void FooA(ICommandContext ctx, string first, [Remainder] string rest) + { + ctx.Reply($"A: {first}/{rest}"); + } + } + + public class AdminRemainderCommandsB + { + [Command("foo", adminOnly: true)] + public void FooB(ICommandContext ctx, string first, string second, [Remainder] string rest) + { + ctx.Reply($"B: {first}/{second}/{rest}"); + } + } + + // Admin-only variant that would succeed conversion for any string arg. + public class AdminOnlyBarCommand + { + [Command("bar", adminOnly: true)] + public void BarAdmin(ICommandContext ctx, string first, [Remainder] string rest) + { + ctx.Reply($"admin bar: {first}/{rest}"); + } + } + + // Non-admin variant that requires an int first param and will therefore + // fail parameter conversion when called with a non-numeric first arg. + public class NonAdminBarCommand + { + [Command("bar")] + public void BarUser(ICommandContext ctx, int num, [Remainder] string rest) + { + ctx.Reply($"user bar: {num}/{rest}"); } } @@ -377,6 +419,63 @@ public void AssemblySpecificCommand_WithRemainder_EmptyRemainder() ctx.AssertReplyContains("MyMod1 remainder: ''"); } + [Test] + public void MultiCandidate_AllAdminOnlyWithRemainder_NonAdmin_RepliesDenied() + { + // Reproduces the real-world `.announce addline` bug: multiple assemblies + // register the same command string, every candidate is adminOnly, and at + // least one uses [Remainder]. A non-admin caller used to see a bogus + // "Failed to execute command due to parameter conversion errors" header + // with an empty bullet list because both candidates were silently skipped + // by CanCommandExecute before any parameter conversion was attempted. + RegisterCommandsWithMockAssembly(typeof(AdminRemainderCommandsA), "FooModA"); + RegisterCommandsWithMockAssembly(typeof(AdminRemainderCommandsB), "FooModB"); + + var ctx = new AssertReplyContext { IsAdmin = false }; + var result = CommandRegistry.Handle(ctx, ".foo alpha beta gamma"); + + Assert.That(result, Is.EqualTo(CommandResult.Denied)); + ctx.AssertReplyContains("[denied]"); + ctx.AssertReplyDoesntContain("parameter conversion errors"); + } + + [Test] + public void MultiCandidate_AllAdminOnlyWithRemainder_Admin_StillDispatches() + { + // Guard: the fix must not regress the admin path. With both candidates + // admin-allowed, Handle should either execute one (Case 2) or show the + // disambiguation prompt (Case 3) — both return CommandResult.Success. + RegisterCommandsWithMockAssembly(typeof(AdminRemainderCommandsA), "FooModA"); + RegisterCommandsWithMockAssembly(typeof(AdminRemainderCommandsB), "FooModB"); + + var ctx = new AssertReplyContext { IsAdmin = true }; + var result = CommandRegistry.Handle(ctx, ".foo alpha beta gamma"); + + Assert.That(result, Is.EqualTo(CommandResult.Success)); + ctx.AssertReplyDoesntContain("[denied]"); + ctx.AssertReplyDoesntContain("parameter conversion errors"); + } + + [Test] + public void MultiCandidate_MixedDeniedAndConversionFailure_ReportsConversionErrorWithoutDenied() + { + // When one candidate is denied by middleware AND another candidate fails + // parameter conversion, the reply must report the real conversion error + // and must NOT mix the denied candidate into the bullet list — mislabelling + // a denial as a conversion error would be its own lie. + RegisterCommandsWithMockAssembly(typeof(AdminOnlyBarCommand), "BarModAdmin"); + RegisterCommandsWithMockAssembly(typeof(NonAdminBarCommand), "BarModUser"); + + var ctx = new AssertReplyContext { IsAdmin = false }; + var result = CommandRegistry.Handle(ctx, ".bar hello world"); + + Assert.That(result, Is.EqualTo(CommandResult.UsageError)); + ctx.AssertReplyContains("Failed to execute command due to parameter conversion errors"); + ctx.AssertReplyContains("BarModUser"); + ctx.AssertReplyDoesntContain("BarModAdmin"); + ctx.AssertReplyDoesntContain("[denied]"); + } + [Test] public void AssemblyNameMatchesGroupName_FallsBackToGroupCommand() { diff --git a/VCF.Tests/HelpTests.cs b/VCF.Tests/HelpTests.cs index 9da6e0a..f40371e 100644 --- a/VCF.Tests/HelpTests.cs +++ b/VCF.Tests/HelpTests.cs @@ -192,6 +192,23 @@ public void GenerateHelpText_GeneratesUsage_DefaultParam() Assert.That(text, Is.EqualTo($".{commandName} [{paramName}={paramValue}]")); } + [Test] + public void GenerateHelpText_GeneratesUsage_RemainderParam() + { + var (commandName, _, description) = Any.ThreeStrings(); + var paramName = Any.String(); + var param = A.Fake(); + A.CallTo(() => param.Name).Returns(paramName); + A.CallTo(() => param.ParameterType).Returns(typeof(string)); + A.CallTo(() => param.IsDefined(typeof(RemainderAttribute), false)).Returns(true); + + var command = new CommandMetadata(new CommandAttribute(commandName, null, usage: null, description: description), null, null, null, new[] { param }, null, null, null); + + var text = HelpCommands.GetShortHelp(command); + + Assert.That(text, Is.EqualTo($".{commandName} <{paramName}...>")); + } + [Test] public void FullHelp_Usage_Includes_IConverterUsage() { diff --git a/VCF.Tests/RemainderParameterTests.cs b/VCF.Tests/RemainderParameterTests.cs index 80c9287..0c2d5eb 100644 --- a/VCF.Tests/RemainderParameterTests.cs +++ b/VCF.Tests/RemainderParameterTests.cs @@ -17,21 +17,21 @@ public void Setup() public class RemainderCommands { [Command("echo", description: "Echoes the remainder text")] - public void EchoRemainder(ICommandContext ctx, string _remainder) + public void EchoRemainder(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"Remainder: '{_remainder}'"); + ctx.Reply($"Remainder: '{message}'"); } [Command("say", description: "Says something with a prefix")] - public void SayWithPrefix(ICommandContext ctx, string prefix, string _remainder) + public void SayWithPrefix(ICommandContext ctx, string prefix, [Remainder] string body) { - ctx.Reply($"{prefix}: {_remainder}"); + ctx.Reply($"{prefix}: {body}"); } [Command("optional", description: "Command with optional parameter and remainder")] - public void OptionalWithRemainder(ICommandContext ctx, string required, int optional = 42, string _remainder = "") + public void OptionalWithRemainder(ICommandContext ctx, string required, int optional = 42, [Remainder] string reason = "") { - ctx.Reply($"Required: {required}, Optional: {optional}, Remainder: '{_remainder}'"); + ctx.Reply($"Required: {required}, Optional: {optional}, Remainder: '{reason}'"); } } @@ -44,9 +44,9 @@ public void TestRegular(ICommandContext ctx, string arg1, string arg2) } [Command("test", description: "Test with remainder")] - public void TestRemainder(ICommandContext ctx, string arg1, string _remainder) + public void TestRemainder(ICommandContext ctx, string arg1, [Remainder] string rest) { - ctx.Reply($"Remainder test: {arg1}, '{_remainder}'"); + ctx.Reply($"Remainder test: {arg1}, '{rest}'"); } } @@ -54,15 +54,15 @@ public void TestRemainder(ICommandContext ctx, string arg1, string _remainder) public class GroupedRemainderCommands { [Command("go", description: "Go with remainder")] - public void Go(ICommandContext ctx, string _remainder) + public void Go(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"Grp go: '{_remainder}'"); + ctx.Reply($"Grp go: '{message}'"); } [Command("tag", description: "Tag with prefix and remainder")] - public void Tag(ICommandContext ctx, string name, string _remainder) + public void Tag(ICommandContext ctx, string name, [Remainder] string description) { - ctx.Reply($"Grp tag: {name}, '{_remainder}'"); + ctx.Reply($"Grp tag: {name}, '{description}'"); } } @@ -70,18 +70,18 @@ public void Tag(ICommandContext ctx, string name, string _remainder) public class ShorthandGroupedRemainderCommands { [Command("send", description: "Send with remainder")] - public void Send(ICommandContext ctx, string _remainder) + public void Send(ICommandContext ctx, [Remainder] string message) { - ctx.Reply($"Message: '{_remainder}'"); + ctx.Reply($"Message: '{message}'"); } } public class MultiWordRemainderCommands { [Command("fancy thing", description: "Multi-word command with remainder")] - public void FancyThing(ICommandContext ctx, string _remainder) + public void FancyThing(ICommandContext ctx, [Remainder] string text) { - ctx.Reply($"Fancy thing: '{_remainder}'"); + ctx.Reply($"Fancy thing: '{text}'"); } } @@ -89,18 +89,27 @@ public void FancyThing(ICommandContext ctx, string _remainder) public class GroupedMultiWordRemainderCommands { [Command("do thing", description: "Grouped multi-word command with remainder")] - public void DoThing(ICommandContext ctx, string _remainder) + public void DoThing(ICommandContext ctx, [Remainder] string filter) { - ctx.Reply($"Stuff do thing: '{_remainder}'"); + ctx.Reply($"Stuff do thing: '{filter}'"); + } + } + + public class InvalidRemainderPositionCommands + { + [Command("badremainder", description: "Has [Remainder] on a non-last parameter — should be rejected at registration")] + public void BadRemainder(ICommandContext ctx, [Remainder] string text, int trailing) + { + ctx.Reply($"{text} {trailing}"); } } public class DifferentWordCountShorthandCommands { [Command("long command", shortHand: "lc", description: "Different word count shorthand")] - public void LongCommand(ICommandContext ctx, string _remainder) + public void LongCommand(ICommandContext ctx, [Remainder] string args) { - ctx.Reply($"Long command: '{_remainder}'"); + ctx.Reply($"Long command: '{args}'"); } } @@ -108,9 +117,9 @@ public void LongCommand(ICommandContext ctx, string _remainder) public class DifferentWordCountGroupShorthandCommands { [Command("run", description: "Different word count group shorthand")] - public void Run(ICommandContext ctx, string _remainder) + public void Run(ICommandContext ctx, [Remainder] string reason) { - ctx.Reply($"Run: '{_remainder}'"); + ctx.Reply($"Run: '{reason}'"); } } @@ -329,5 +338,17 @@ public void DifferentWordCountGroupShorthand_RemainderParameter_ExtractsCorrectl Assert.That(result2, Is.EqualTo(CommandResult.Success)); ctx2.AssertReplyContains("Run: 'some reason here'"); } + + [Test] + public void Remainder_OnNonLastParameter_IsRejected() + { + CommandRegistry.RegisterCommandType(typeof(InvalidRemainderPositionCommands)); + + // Registration should refuse to install the command (it logs an error and returns). + // Invoking it should therefore fall through as an unknown command. + var ctx = new AssertReplyContext(); + var result = CommandRegistry.Handle(ctx, ".badremainder hello 3"); + Assert.That(result, Is.Not.EqualTo(CommandResult.Success)); + } } } \ No newline at end of file From 289b266662c8c00dab76b3bca47dd1361491e331 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 12 Apr 2026 19:01:54 -0400 Subject: [PATCH 26/29] Chat messages had a potential to be processed on the client out of order so adding a frame delay on the server inbetween each of them to try to better enforce their order. --- VCF.Core/Framework/ChatCommandContext.cs | 9 +- VCF.Core/Framework/ChatDrainPatch.cs | 41 ++++++ VCF.Core/Framework/ChatMessageQueue.cs | 124 ++++++++++++++++++ VCF.Core/Plugin.cs | 2 + VCF.Tests/ChatMessageQueueTests.cs | 158 +++++++++++++++++++++++ 5 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 VCF.Core/Framework/ChatDrainPatch.cs create mode 100644 VCF.Core/Framework/ChatMessageQueue.cs create mode 100644 VCF.Tests/ChatMessageQueueTests.cs diff --git a/VCF.Core/Framework/ChatCommandContext.cs b/VCF.Core/Framework/ChatCommandContext.cs index 2a94b80..954c9b6 100644 --- a/VCF.Core/Framework/ChatCommandContext.cs +++ b/VCF.Core/Framework/ChatCommandContext.cs @@ -1,9 +1,7 @@ -using Engine.Console; -using ProjectM; -using ProjectM.Network; +using ProjectM.Network; using System; -using Unity.Collections; using VampireCommandFramework.Breadstone; +using VampireCommandFramework.Framework; namespace VampireCommandFramework; @@ -49,8 +47,7 @@ public void Reply(string v) if (v.Length > maxMessageLength) v = v[..maxMessageLength]; - FixedString512Bytes unityMessage = v; - ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, User, ref unityMessage); + ChatMessageQueue.Send(User, v); } // todo: expand this, just throw from here as void and build a handler that can message user/log. diff --git a/VCF.Core/Framework/ChatDrainPatch.cs b/VCF.Core/Framework/ChatDrainPatch.cs new file mode 100644 index 0000000..0410163 --- /dev/null +++ b/VCF.Core/Framework/ChatDrainPatch.cs @@ -0,0 +1,41 @@ +using System; +using HarmonyLib; +using ProjectM; +using ProjectM.Network; +using Unity.Collections; +using VampireCommandFramework.Breadstone; +using VampireCommandFramework.Common; + +namespace VampireCommandFramework.Framework; + +// Harmony wiring for ChatMessageQueue: installs the production SendSink and +// drains one queued message per user per server frame. +internal static class ChatDrainPatch +{ + public static void Install() + { + ChatMessageQueue.SendSink = static (userObj, message) => + { + var user = (User)userObj; + FixedString512Bytes unityMessage = message; + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref unityMessage); + }; + } + + [HarmonyPatch(typeof(ServerBootstrapSystem), nameof(ServerBootstrapSystem.OnUpdate))] + public static class DrainTick_Patch + { + [HarmonyPostfix] + public static void Postfix() + { + try + { + ChatMessageQueue.DrainOneTick(); + } + catch (Exception e) + { + Log.Error($"ChatMessageQueue.DrainOneTick failed: {e}"); + } + } + } +} diff --git a/VCF.Core/Framework/ChatMessageQueue.cs b/VCF.Core/Framework/ChatMessageQueue.cs new file mode 100644 index 0000000..b435a52 --- /dev/null +++ b/VCF.Core/Framework/ChatMessageQueue.cs @@ -0,0 +1,124 @@ +using ProjectM.Network; +using System; +using System.Collections.Generic; + +namespace VampireCommandFramework.Framework; + +// Serializes outgoing chat replies per user at most one per server frame. +// +// V Rising batches multiple chat messages created in the same server tick into +// one network snapshot, and the client has been observed to render that batch +// in a reshuffled order (e.g. '.2 - ModB' above '.1 - ModA' for a paginated +// disambiguation reply). Forcing at most one outbound message per user per +// server frame ensures each message lands in its own snapshot and therefore +// cannot be reordered against another message from the same conversation. +// +// Type erasure: the queue value carries the sender's User struct for the drain +// path, but this module deliberately stores it as `object` (boxed) rather than +// `ProjectM.Network.User`. Referencing the IL2CPP-interop User type in static +// field metadata would force the test host (where the Unity runtime is not +// initialized) to fail class loading with TypeLoadException on every +// ChatMessageQueueTests entry. The production SendSink in ChatDrainPatch.cs +// unboxes `object` back to `User` at send time; tests store `null` and their +// replacement SendSink ignores the user parameter entirely. +// +// Threading: this module is only ever touched from the server main thread +// (Harmony postfix on ServerBootstrapSystem.OnUpdate + synchronous Reply calls +// from command handlers). No locks. +internal static class ChatMessageQueue +{ + // Keyed on User.PlatformId. Value carries the boxed User alongside its + // pending message queue so the drain path has the user in hand without a + // side-map lookup. See the "Type erasure" note above for why User is + // stored as object here. + internal static readonly Dictionary Queue)> _queues = new(); + internal static readonly HashSet _sentThisTick = new(); + + // Production wires this to a ServerChatUtils-based sink in + // ChatDrainPatch.Install() that unboxes `object` to `User`. Tests overwrite + // it with a collector lambda that ignores the user argument. + internal static Action SendSink = static (_, _) => { }; + + // Production entry point, called from ChatCommandContext.Reply with the + // real User struct. Boxes the User here so the call site just passes the + // typed struct; the shared core stores it as `object` in the tuple. + internal static void Send(User user, string message) + { + Send(user.PlatformId, user, message); + } + + // Test-only convenience: tests never have a real User to pass. Stores null + // in the User slot and relies on the test SendSink ignoring it. + internal static void Send(ulong platformId, string message) + { + Send(platformId, null, message); + } + + // Shared core. The `user` object is stored as-is in the queue tuple and + // handed back to the SendSink at drain time. Production passes a boxed + // User; tests pass null. + private static void Send(ulong platformId, object user, string message) + { + var canFastPath = + !_sentThisTick.Contains(platformId) + && (!_queues.TryGetValue(platformId, out var existing) || existing.Queue.Count == 0); + + if (canFastPath) + { + SendSink(user, message); + _sentThisTick.Add(platformId); + return; + } + + if (!_queues.TryGetValue(platformId, out var entry)) + { + entry = (user, new Queue()); + } + else + { + // Refresh the stored User — a reconnect may have handed us a + // different component value under the same PlatformId. + entry = (user, entry.Queue); + } + entry.Queue.Enqueue(message); + _queues[platformId] = entry; + } + + internal static void DrainOneTick() + { + _sentThisTick.Clear(); + + if (_queues.Count == 0) return; + + // Snapshot keys because we may remove entries from the dictionary as + // queues empty out. + var platformIds = new List(_queues.Keys); + foreach (var platformId in platformIds) + { + if (!_queues.TryGetValue(platformId, out var entry) || entry.Queue.Count == 0) + { + _queues.Remove(platformId); + continue; + } + + var message = entry.Queue.Dequeue(); + SendSink(entry.User, message); + _sentThisTick.Add(platformId); + + if (entry.Queue.Count == 0) _queues.Remove(platformId); + } + } + + internal static void Clear(ulong platformId) + { + _queues.Remove(platformId); + _sentThisTick.Remove(platformId); + } + + internal static void ResetForTests() + { + _queues.Clear(); + _sentThisTick.Clear(); + SendSink = static (_, _) => { }; + } +} diff --git a/VCF.Core/Plugin.cs b/VCF.Core/Plugin.cs index 33ee1b8..816f116 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -23,6 +23,8 @@ public override void Load() _harmony = new Harmony(PluginInfo.PLUGIN_GUID); _harmony.PatchAll(); + Framework.ChatDrainPatch.Install(); + CommandRegistry.RegisterCommandType(typeof(Basics.HelpCommands)); CommandRegistry.RegisterCommandType(typeof(Basics.BepInExConfigCommands)); CommandRegistry.RegisterCommandType(typeof(Basics.RepeatCommands)); diff --git a/VCF.Tests/ChatMessageQueueTests.cs b/VCF.Tests/ChatMessageQueueTests.cs new file mode 100644 index 0000000..8083fb8 --- /dev/null +++ b/VCF.Tests/ChatMessageQueueTests.cs @@ -0,0 +1,158 @@ +using NUnit.Framework; +using VampireCommandFramework.Framework; + +namespace VCF.Tests; + +// Covers the mechanics of ChatMessageQueue — the fast-path / enqueue decision, +// the one-per-user-per-tick drain, and Clear. Tests use the Send(ulong, string) +// overload so they never have to fabricate a ProjectM.Network.User (which would +// need an initialized IL2CPP runtime). The test SendSink ignores the User it +// receives and records only the message text. +public class ChatMessageQueueTests +{ + private List _sent = null!; + + [SetUp] + public void SetUp() + { + ChatMessageQueue.ResetForTests(); + _sent = new List(); + ChatMessageQueue.SendSink = (_, message) => _sent.Add(message); + } + + [TearDown] + public void TearDown() + { + ChatMessageQueue.ResetForTests(); + } + + [Test] + public void Send_FirstMessage_SendsImmediately() + { + ChatMessageQueue.Send(1, "a"); + + Assert.That(_sent, Is.EqualTo(new[] { "a" })); + Assert.That(ChatMessageQueue._queues.ContainsKey(1), Is.False); + Assert.That(ChatMessageQueue._sentThisTick, Contains.Item(1UL)); + } + + [Test] + public void Send_SecondMessageSameTick_Queues() + { + ChatMessageQueue.Send(1, "a"); + ChatMessageQueue.Send(1, "b"); + + Assert.That(_sent, Is.EqualTo(new[] { "a" })); + Assert.That(ChatMessageQueue._queues.ContainsKey(1), Is.True); + Assert.That(ChatMessageQueue._queues[1].Queue, Is.EqualTo(new[] { "b" })); + } + + [Test] + public void Send_SameTickDifferentUsers_BothSendImmediately() + { + ChatMessageQueue.Send(1, "a"); + ChatMessageQueue.Send(2, "x"); + + Assert.That(_sent, Is.EqualTo(new[] { "a", "x" })); + Assert.That(ChatMessageQueue._queues, Is.Empty); + Assert.That(ChatMessageQueue._sentThisTick, Is.EquivalentTo(new[] { 1UL, 2UL })); + } + + [Test] + public void DrainOneTick_ClearsFlagsAndSendsOnePerUser() + { + // Each user: first Send fast-paths, three more queue up. So each + // queue should end up with 3 pending messages. + ChatMessageQueue.Send(1, "a1"); + ChatMessageQueue.Send(1, "a2"); + ChatMessageQueue.Send(1, "a3"); + ChatMessageQueue.Send(1, "a4"); + ChatMessageQueue.Send(2, "b1"); + ChatMessageQueue.Send(2, "b2"); + ChatMessageQueue.Send(2, "b3"); + ChatMessageQueue.Send(2, "b4"); + + Assert.That(_sent, Is.EqualTo(new[] { "a1", "b1" })); + Assert.That(ChatMessageQueue._queues[1].Queue.Count, Is.EqualTo(3)); + Assert.That(ChatMessageQueue._queues[2].Queue.Count, Is.EqualTo(3)); + + _sent.Clear(); + ChatMessageQueue.DrainOneTick(); + + // One additional send per user, FIFO: a2 and b2. + Assert.That(_sent, Is.EquivalentTo(new[] { "a2", "b2" })); + Assert.That(ChatMessageQueue._queues[1].Queue.Count, Is.EqualTo(2)); + Assert.That(ChatMessageQueue._queues[2].Queue.Count, Is.EqualTo(2)); + Assert.That(ChatMessageQueue._sentThisTick, Is.EquivalentTo(new[] { 1UL, 2UL })); + } + + [Test] + public void DrainOneTick_FollowedBySendSameTick_Queues() + { + // Simulates the real server frame sequence: + // (a) DrainOneTick runs early in the frame. + // (b) Later in the same frame, a command handler calls ctx.Reply + // → ChatMessageQueue.Send. It must see that this user already + // used their send this frame and enqueue instead. + ChatMessageQueue.Send(1, "pre"); // queue nothing; just mark user 1 + Assert.That(ChatMessageQueue._sentThisTick, Contains.Item(1UL)); + + // Simulate the start of the next server frame: DrainOneTick clears + // _sentThisTick at the top. Queue is empty, so no send happens. + _sent.Clear(); + ChatMessageQueue.DrainOneTick(); + Assert.That(_sent, Is.Empty); + Assert.That(ChatMessageQueue._sentThisTick, Is.Empty); + + // First Reply of the new frame: fast-path send. + ChatMessageQueue.Send(1, "frame2-first"); + Assert.That(_sent, Is.EqualTo(new[] { "frame2-first" })); + + // Second Reply same frame: queue because flag is now set. + ChatMessageQueue.Send(1, "frame2-second"); + Assert.That(_sent, Is.EqualTo(new[] { "frame2-first" })); + Assert.That(ChatMessageQueue._queues[1].Queue, Is.EqualTo(new[] { "frame2-second" })); + } + + [Test] + public void DrainOneTick_Empties_RemovesQueueEntry() + { + ChatMessageQueue.Send(1, "a"); + ChatMessageQueue.Send(1, "b"); + Assert.That(ChatMessageQueue._queues[1].Queue.Count, Is.EqualTo(1)); + + // Next frame drains 'b', queue becomes empty → entry removed. + ChatMessageQueue.DrainOneTick(); + + Assert.That(_sent, Is.EqualTo(new[] { "a", "b" })); + Assert.That(ChatMessageQueue._queues.ContainsKey(1), Is.False); + } + + [Test] + public void Clear_DropsQueueAndFlag() + { + ChatMessageQueue.Send(1, "a"); + ChatMessageQueue.Send(1, "b"); + ChatMessageQueue.Send(1, "c"); + + ChatMessageQueue.Clear(1); + + Assert.That(ChatMessageQueue._queues.ContainsKey(1), Is.False); + Assert.That(ChatMessageQueue._sentThisTick, Does.Not.Contain(1UL)); + } + + [Test] + public void Send_AfterClear_IsImmediate() + { + ChatMessageQueue.Send(1, "a"); + ChatMessageQueue.Send(1, "b"); + ChatMessageQueue.Clear(1); + + _sent.Clear(); + ChatMessageQueue.Send(1, "fresh"); + + Assert.That(_sent, Is.EqualTo(new[] { "fresh" })); + Assert.That(ChatMessageQueue._queues.ContainsKey(1), Is.False); + Assert.That(ChatMessageQueue._sentThisTick, Contains.Item(1UL)); + } +} From f4f427c9cf4ee939f7c41a86c856fc7127ab3ac3 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 13 Apr 2026 15:47:56 -0400 Subject: [PATCH 27/29] Updated README.md with remainder parameter information --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 78c4f26..068d215 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,28 @@ public class AdminCommands ``` - Executes with: `.admin ban PlayerName` +## Remainder Parameters +For commands that take free-form text (a message, a reason, a timezone ID), mark the final `string` parameter with `[Remainder]` to capture everything the user typed after the preceding arguments, with spacing and quotes preserved. + +```csharp +[Command("say")] +public void Say(ICommandContext ctx, string channel, [Remainder] string message) + => ctx.Reply($"[{channel}] {message}"); +``` + +- `.say global hello everyone` → `channel = "global"`, `message = "hello everyone"` +- `.say global "hello, friends!"` → `message = "\"hello, friends!\""` (quotes kept as typed) + +**Rules:** +- Must be the **last** parameter. Violations are rejected at registration time. +- Must be of type `string`. +- Captures the rest of the raw input verbatim, including quotes, whitespace, and special characters. +- Can be optional with a default value: `[Remainder] string reason = null`. +- Parameters before the remainder are parsed normally: type conversion, defaults, and custom converters all still apply. +- Composes with overloading: the framework picks a remainder variant when enough preceding arguments are present. + +Auto-generated `.help` output renders a remainder parameter as ``, distinct from `(name)` for required and `[name=default]` for optional, so users get a visual cue for free, unless you supply a manual `usage:` string on `[Command]`. + ## Command Overloading You can now create multiple commands with the same name but different parameter types: @@ -192,6 +214,26 @@ Command not found: .tleport Did you mean: .teleport, .teleport-home, .tp ``` +### Free-form Text Arguments +Some commands accept free-form text as their final argument, so you can type multi-word values (a message, a reason, a path) **without wrapping them in quotes**. For example: +``` +.announce Server restart in 5 minutes +``` +`Server restart in 5 minutes` is captured as a single argument. If you do type quotes, they're preserved as part of the text. + +You can spot these commands in `.help `: each parameter is shown in a distinct shape so you can tell at a glance which one eats the rest of the line. +- `(name)`: a **required** argument (single word or quoted). +- `[name=default]`: an **optional** argument with a default value. +- ``: a **remainder** argument that captures everything you type after the preceding arguments as this one value, spaces and all. + +So a help line like: +``` +.announce (channel) +``` +means `channel` is a single required word and `message...` eats everything else you type. + +Note: if a command's author supplied a custom usage string, `.help` shows that text verbatim instead of the shapes above, so the `` marker is only guaranteed for commands using the default help rendering. + ### Plugin-Specific Commands Players can execute commands from specific plugins to avoid conflicts: ``` From fca843338a359c8d983b882e039c5056c983969b Mon Sep 17 00:00:00 2001 From: deca Date: Mon, 13 Apr 2026 13:35:27 -0700 Subject: [PATCH 28/29] =?UTF-8?q?=F0=9F=92=9A=20Unit=20test=20fix=20for=20?= =?UTF-8?q?non-determism=20in=20parallel=20and=20across=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VCF.Tests/AssertReplyContext.cs | 4 ++-- VCF.Tests/RepeatCommandsTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VCF.Tests/AssertReplyContext.cs b/VCF.Tests/AssertReplyContext.cs index 0e0be36..526df74 100644 --- a/VCF.Tests/AssertReplyContext.cs +++ b/VCF.Tests/AssertReplyContext.cs @@ -9,7 +9,7 @@ public class AssertReplyContext : ICommandContext private StringBuilder _sb = new(); public IServiceProvider Services => throw new NotImplementedException(); - public string Name { get; set; } = nameof(AssertReplyContext); + public string Name { get; set; } = $"{nameof(AssertReplyContext)}_{Guid.NewGuid():N}"; // Add unique identifier for each context instance public string ContextId { get; set; } = Guid.NewGuid().ToString("N"); @@ -28,7 +28,7 @@ public void Reply(string v) public void AssertReply(string expected) { - Assert.That(RepliedTextLfAndTrimmed(), Is.EqualTo(expected)); + Assert.That(RepliedTextLfAndTrimmed(), Is.EqualTo(expected.Replace("\r\n", "\n"))); } public void AssertReplyContains(string expected) { diff --git a/VCF.Tests/RepeatCommandsTests.cs b/VCF.Tests/RepeatCommandsTests.cs index 01e0790..0d375ac 100644 --- a/VCF.Tests/RepeatCommandsTests.cs +++ b/VCF.Tests/RepeatCommandsTests.cs @@ -510,7 +510,7 @@ public void CommandHistory_LimitedToMaximumEntries() ctx.AssertReplyContains("10. .echo message1"); // Get the full response to check message0 is not present - var fullReplyCtx = new AssertReplyContext(); + var fullReplyCtx = new AssertReplyContext { Name = ctx.Name }; CommandRegistry.Handle(fullReplyCtx, ".! list"); Assert.That(fullReplyCtx.RepliedTextLfAndTrimmed().Contains("message0"), Is.False, "The oldest message should have been removed due to history size limit"); From 52fedb722c51dc6b070545bd82f56f3ec32839a2 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 13 Apr 2026 18:00:47 -0400 Subject: [PATCH 29/29] Command history wasn't marked as loaded if the history file was missing or empty. Now always mark the history as being loaded no matter what the state of the history file is. --- VCF.Core/Registry/CommandHistory.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs index 307d25f..555f0ef 100644 --- a/VCF.Core/Registry/CommandHistory.cs +++ b/VCF.Core/Registry/CommandHistory.cs @@ -297,13 +297,15 @@ private static void LoadHistoryFromFile(ICommandContext ctx, string contextName) } _commandHistory[contextName] = reconstructedHistory; - _loadedHistories.Add(contextName); } catch (Exception ex) { Log.Error($"Failed to load command history for context {contextName}: {ex.Message}"); - _loadedHistories.Add(contextName); // Mark as loaded to avoid repeated attempts } + finally + { + _loadedHistories.Add(contextName); + } } private static (CommandMetadata command, object[] args) ParseCommandForHistory(ICommandContext ctx, string input)