diff --git a/README.md b/README.md index f81a722..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: ``` @@ -211,9 +253,14 @@ 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 and their current versions (admin only) ## Help 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/Basics/HelpCommand.cs b/VCF.Core/Basics/HelpCommand.cs index fa01f8f..eebec17 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()); @@ -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/Basics/VersionCommands.cs b/VCF.Core/Basics/VersionCommands.cs new file mode 100644 index 0000000..94e9af3 --- /dev/null +++ b/VCF.Core/Basics/VersionCommands.cs @@ -0,0 +1,16 @@ +using Unity.Entities; +using VampireCommandFramework.Common; + +namespace VampireCommandFramework.Basics; + +internal static class VersionCommands +{ + [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 + var userEntity = ctx is ChatCommandContext chatCtx ? chatCtx.Event.SenderUserEntity : default; + + VersionChecker.ListAllPluginVersions(userEntity); + } +} 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 diff --git a/VCF.Core/Common/VersionChecker.cs b/VCF.Core/Common/VersionChecker.cs new file mode 100644 index 0000000..d0aa6bc --- /dev/null +++ b/VCF.Core/Common/VersionChecker.cs @@ -0,0 +1,105 @@ +using BepInEx.Unity.IL2CPP; +using ProjectM; +using ProjectM.Network; +using System; +using System.Collections.Generic; +using System.Linq; +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; + + try + { + 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); + } + + /// 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; + } + + + /// 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/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/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/Plugin.cs b/VCF.Core/Plugin.cs index de3536f..816f116 100644 --- a/VCF.Core/Plugin.cs +++ b/VCF.Core/Plugin.cs @@ -8,7 +8,7 @@ namespace VampireCommandFramework; internal class Plugin : BasePlugin { private Harmony _harmony; - + public override void Load() { Common.Log.Instance = Log; @@ -23,9 +23,12 @@ 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)); + CommandRegistry.RegisterCommandType(typeof(Basics.VersionCommands)); IL2CPPChainloader.Instance.Plugins.TryGetValue(PluginInfo.PLUGIN_GUID, out var info); 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..985fc3a 100644 --- a/VCF.Core/Registry/CommandCache.cs +++ b/VCF.Core/Registry/CommandCache.cs @@ -10,44 +10,66 @@ 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 && + CommandRegistry.IsRemainderParameter(parameters[parameters.Length - 1]); + 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) { var lowerRawInput = rawInput.ToLowerInvariant(); List possibleMatches = new(); - List exactMatches = new(); + List<(CommandMetadata Command, string[] Args)> exactMatches = new(); foreach (var (key, argCounts) in _newCache) { @@ -64,37 +86,52 @@ 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) + // Add all commands that match the exact parameter count, paired with their parameters + foreach (var cmd in cmds) { - return new CacheResult(exactMatches, parameters, null); + exactMatches.Add((cmd, parameters)); } - else + } + + // Check remainder commands for this key + if (_remainderCache.TryGetValue(key, out var remainderCommands)) + { + foreach (var remainderCmd in remainderCommands) { - return new CacheResult(exactMatches, Array.Empty(), null); + // 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 the [Remainder] parameter itself + + if (parameters.Length >= requiredParams) + { + exactMatches.Add((remainderCmd, parameters)); + } } } - else + + 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); + } } } } } - // 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 +139,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 +156,53 @@ 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) + // 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, parameters, null); + 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))) { - return new CacheResult(exactMatches, Array.Empty(), null); + // 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 the [Remainder] parameter itself + + if (parameters.Length >= requiredParams) + { + exactMatches.Add((remainderCmd, parameters)); + } } } - else + + 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.Assembly.GetName().Name.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))); + .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))); + } } } } } - // 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) @@ -161,19 +213,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); + } } } } @@ -183,6 +251,7 @@ internal void RemoveCommandsFromType(Type t) internal void Clear() { _newCache.Clear(); + _remainderCache.Clear(); } internal void Reset() diff --git a/VCF.Core/Registry/CommandHistory.cs b/VCF.Core/Registry/CommandHistory.cs new file mode 100644 index 0000000..555f0ef --- /dev/null +++ b/VCF.Core/Registry/CommandHistory.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using BepInEx; +using VampireCommandFramework.Common; + +namespace VampireCommandFramework.Registry; + +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"); + + // 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); + } + + internal static void EnsureHistoryLoaded(ICommandContext ctx) + { + var contextName = ctx.Name; + if (!_loadedHistories.Contains(contextName)) + { + LoadHistoryFromFile(ctx, contextName); + } + } + + internal static void AddToHistory(ICommandContext ctx, string input, CommandMetadata command, object[] args) + { + var contextName = ctx.Name; + + // 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]; + + // 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(contextName, history); + } + + internal static CommandResult HandleHistoryCommand(ICommandContext ctx, string input, Func handleCommand, Func executeCommandWithArgs) + { + var contextName = ctx.Name; + + // Remove the ".!" prefix + string command = input.Substring(2).Trim(); + + // 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; + } + + // 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) + { + var argsCopy = selectedCommand.Args.ToArray(); + argsCopy[0] = ctx; // Ensure the context is current + return executeCommandWithArgs(ctx, selectedCommand.Command, argsCopy); + } + 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) + { + var argsCopy = mostRecent.Args.ToArray(); + argsCopy[0] = ctx; + return executeCommandWithArgs(ctx, mostRecent.Command, argsCopy); + } + 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(string contextName, List<(string input, CommandMetadata Command, object[] Args)> history) + { + try + { + var safeFileName = string.Join("_", contextName.Split(Path.GetInvalidFileNameChars())); + string filePath = Path.Combine(HistoryDirectory, $"{safeFileName}.txt"); + 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) + { + Log.Error($"Failed to save command history for context {contextName}: {ex.Message}"); + } + } + + 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 + { + // 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)) + { + 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)>(); + + 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[contextName] = reconstructedHistory; + } + catch (Exception ex) + { + Log.Error($"Failed to load command history for context {contextName}: {ex.Message}"); + } + finally + { + _loadedHistories.Add(contextName); + } + } + + 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); + } + + // 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(parsed.CommandInput, parsed.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, parsed.CommandInput); + if (success) + { + return (command, commandArgs); + } + } + + return (null, null); + } + catch (Exception) + { + return (null, null); + } + } + + #endregion +} 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..be3016c 100644 --- a/VCF.Core/Registry/CommandRegistry.cs +++ b/VCF.Core/Registry/CommandRegistry.cs @@ -1,843 +1,1053 @@ -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 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(a => - a.GetName().Name.Equals(potentialAssemblyName, StringComparison.OrdinalIgnoreCase)); - - if (isValidAssembly) - { - assemblyName = potentialAssemblyName; - commandInput = "." + afterPrefix.Substring(spaceIndex + 1); - } - } - - // Get command(s) based on input - CacheResult matchedCommand = null; - if (assemblyName != null) - { - matchedCommand = _cache.GetCommandFromAssembly(commandInput, assemblyName); - } - if (matchedCommand == null || !matchedCommand.IsMatched) - { - matchedCommand = _cache.GetCommand(input); - } - - var (commands, args) = (matchedCommand.Commands, matchedCommand.Args); - - if (!matchedCommand.IsMatched) - { - if (!matchedCommand.HasPartial) return CommandResult.Unmatched; // NOT FOUND - - foreach (var possible in matchedCommand.PartialMatches) - { - ctx.SysReply(HelpCommands.GetShortHelp(possible)); - } - - return CommandResult.UsageError; - } - - // If there's only one command, handle it directly - if (commands.Count() == 1) - { - return ExecuteCommand(ctx, commands.First(), args, input); - } - - // Multiple commands match, try to convert parameters for each - var successfulCommands = new List<(CommandMetadata Command, object[] Args, string Error)>(); - var failedCommands = new List<(CommandMetadata Command, string Error)>(); - - foreach (var command in commands) - { - if (!CanCommandExecute(ctx, command)) continue; - - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); - if (success) - { - successfulCommands.Add((command, commandArgs, null)); - } - else - { - failedCommands.Add((command, error)); - } - } - - // Case 1: No command succeeded - if (successfulCommands.Count == 0) - { - var sb = new StringBuilder(); - sb.AppendLine($"{"[error]".Color(Color.Red)} Failed to execute command due to parameter conversion errors:"); - foreach (var (command, error) in failedCommands) - { - string assemblyInfo = command.Assembly.GetName().Name; - sb.AppendLine($" - {command.Attribute.Name} ({assemblyInfo}): {error}"); - } - ctx.SysPaginatedReply(sb); - return CommandResult.UsageError; - } - - // Case 2: Only one command succeeded - if (successfulCommands.Count == 1) - { - var (command, commandArgs, _) = successfulCommands[0]; - AddToCommandHistory(ctx.Name, input, command, commandArgs); - return ExecuteCommandWithArgs(ctx, command, commandArgs); - } - - // Case 3: Multiple commands succeeded - store and ask user to select - _pendingCommands[ctx.Name] = (input, successfulCommands); - - { - var sb = new StringBuilder(); - sb.AppendLine($"Multiple commands match this input. Select one by typing {B(".<#>").Color(Color.Command)}:"); - for (int i = 0; i < successfulCommands.Count; i++) - { - var (command, _, _) = successfulCommands[i]; - var cmdAssembly = command.Assembly.GetName().Name; - var description = command.Attribute.Description; - sb.AppendLine($" {("." + (i + 1).ToString()).Color(Color.Command)} - {cmdAssembly.Bold().Color(Color.Primary)} - {B(command.Attribute.Name)} {command.Attribute.Description}"); - sb.AppendLine(" " + HelpCommands.GetShortHelp(command)); - } - ctx.SysPaginatedReply(sb); - } - - return CommandResult.Success; - } - - // Add these helper methods: - - private static CommandResult HandleCommandSelection(ICommandContext ctx, int selectedIndex) - { - if (!_pendingCommands.TryGetValue(ctx.Name, out var pendingCommands) || pendingCommands.commands.Count == 0) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} No command selection is pending."); - return CommandResult.CommandError; - } - - if (selectedIndex < 1 || selectedIndex > pendingCommands.commands.Count) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid selection. Please select a number between {"1".Color(Color.Gold)} and {pendingCommands.commands.Count.ToString().Color(Color.Gold)}."); - return CommandResult.UsageError; - } - - var (command, args, _) = pendingCommands.commands[selectedIndex - 1]; - - AddToCommandHistory(ctx.Name, pendingCommands.input, command, args); - var result = ExecuteCommandWithArgs(ctx, command, args); - _pendingCommands.Remove(ctx.Name); - return result; - } - - private static (bool Success, object[] Args, string Error) TryConvertParameters(ICommandContext ctx, CommandMetadata command, string[] args) - { - var argCount = args?.Length ?? 0; - var paramsCount = command.Parameters.Length; - var commandArgs = new object[paramsCount + 1]; - commandArgs[0] = ctx; - - // Special case for commands with no parameters - if (paramsCount == 0 && argCount == 0) - { - return (true, commandArgs, null); - } - - // Handle parameter count mismatch - if (argCount > paramsCount) - { - return (false, null, $"Too many parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - else if (argCount < paramsCount) - { - var canDefault = command.Parameters.Skip(argCount).All(p => p.HasDefaultValue); - if (!canDefault) - { - return (false, null, $"Missing required parameters: expected {paramsCount.ToString().Color(Color.Gold)}, got {argCount.ToString().Color(Color.Gold)}"); - } - for (var i = argCount; i < paramsCount; i++) - { - commandArgs[i + 1] = command.Parameters[i].DefaultValue; - } - } - - // If we have arguments to convert, process them - if (argCount > 0) - { - for (var i = 0; i < argCount; i++) - { - var param = command.Parameters[i]; - var arg = args[i]; - bool conversionSuccess = false; - string conversionError = null; - - try - { - // Custom Converter - if (_converters.TryGetValue(param.ParameterType, out var customConverter)) - { - var (converter, convertMethod, converterContextType) = customConverter; - - // IMPORTANT CHANGE: Return special error code for unassignable context - if (!converterContextType.IsAssignableFrom(ctx.GetType())) - { - // Signal internal error with a special return format - return (false, null, $"INTERNAL_ERROR:Converter type {converterContextType.Name.ToString().Color(Color.Gold)} is not assignable from {ctx.GetType().Name.ToString().Color(Color.Gold)}"); - } - - object result; - var tryParseArgs = new object[] { ctx, arg }; - try - { - result = convertMethod.Invoke(converter, tryParseArgs); - commandArgs[i + 1] = result; - conversionSuccess = true; - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException e) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; - } - else - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; - } - } - catch (Exception) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error converting parameter"; - } - } - else - { - var defaultConverter = TypeDescriptor.GetConverter(param.ParameterType); - try - { - var val = defaultConverter.ConvertFromInvariantString(arg); - - // Separate, more robust enum validation - if (param.ParameterType.IsEnum) - { - bool isDefined = false; - - // For numeric input, we need to check if the value is defined - if (int.TryParse(arg, out int enumIntVal)) - { - isDefined = Enum.IsDefined(param.ParameterType, enumIntVal); - - if (!isDefined) - { - return (false, null, $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Invalid enum value '{arg.ToString().Color(Color.Gold)}' for {param.ParameterType.Name.ToString().Color(Color.Gold)}"); - } - } - } - - commandArgs[i + 1] = val; - conversionSuccess = true; - } - catch (Exception e) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): {e.Message}"; - } - } - } - catch (Exception ex) - { - conversionError = $"Parameter {i + 1} ({param.Name.ToString().Color(Color.Gold)}): Unexpected error: {ex.Message}"; - } - - if (!conversionSuccess) - { - return (false, null, conversionError); - } - } - } - - return (true, commandArgs, null); - } - - private static CommandResult ExecuteCommand(ICommandContext ctx, CommandMetadata command, string[] args, string input) - { - // Handle Context Type not matching command - if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - return CommandResult.InternalError; - } - - // Try to convert parameters - var (success, commandArgs, error) = TryConvertParameters(ctx, command, args); - if (!success) - { - // Check for special internal error flag - if (error != null && error.StartsWith("INTERNAL_ERROR:")) - { - string actualError = error.Substring("INTERNAL_ERROR:".Length); - Log.Warning(actualError); - ctx.InternalError(); - return CommandResult.InternalError; - } - - ctx.SysReply($"{"[error]".Color(Color.Red)} {error}"); - return CommandResult.UsageError; - } - - AddToCommandHistory(ctx.Name, input, command, commandArgs); - return ExecuteCommandWithArgs(ctx, command, commandArgs); - } - - private static CommandResult ExecuteCommandWithArgs(ICommandContext ctx, CommandMetadata command, object[] commandArgs) - { - // Handle Context Type not matching command - if (!command.ContextType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ContextType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - return CommandResult.InternalError; - } - - // Then handle this invocation's context not being valid for the command classes custom constructor - if (command.Constructor != null && !command.ConstructorType.IsAssignableFrom(ctx?.GetType())) - { - Log.Warning($"Matched [{command.Attribute.Name.ToString().Color(Color.Gold)}] but can not assign {command.ConstructorType.Name.ToString().Color(Color.Gold)} from {ctx?.GetType().Name.ToString().Color(Color.Gold)}"); - ctx.InternalError(); - return CommandResult.InternalError; - } - - object instance = null; - // construct command's type with context if declared only in a non-static class and on a non-static method - if (!command.Method.IsStatic && !(command.Method.DeclaringType.IsAbstract && command.Method.DeclaringType.IsSealed)) - { - try - { - instance = command.Constructor == null ? Activator.CreateInstance(command.Method.DeclaringType) : command.Constructor.Invoke(new[] { ctx }); - } - catch (TargetInvocationException tie) - { - if (tie.InnerException is CommandException ce) - { - ctx.SysReply(ce.Message); - } - else - { - ctx.InternalError(); - } - - return CommandResult.InternalError; - } - } - - // Handle Middlewares - if (!CanCommandExecute(ctx, command)) - { - ctx.SysReply($"{"[denied]".Color(Color.Red)} {command.Attribute.Name.ToString().Color(Color.Gold)}"); - return CommandResult.Denied; - } - - HandleBeforeExecute(ctx, command); - - // Execute Command - try - { - command.Method.Invoke(instance, commandArgs); - } - catch (TargetInvocationException tie) when (tie.InnerException is CommandException e) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} {e.Message}"); - return CommandResult.CommandError; - } - catch (Exception e) - { - Log.Warning($"Hit unexpected exception executing command {command.Attribute.Id.ToString().Color(Color.Gold)}\n: {e}"); - ctx.InternalError(); - return CommandResult.InternalError; - } - - HandleAfterExecute(ctx, command); - - return CommandResult.Success; - } - - private static void AddToCommandHistory(string contextName, string input, CommandMetadata command, object[] args) - { - // Create the history list for this context if it doesn't exist yet - if (!_commandHistory.TryGetValue(contextName, out var history)) - { - history = new List<(string input, CommandMetadata Command, object[] Args)>(); - _commandHistory[contextName] = history; - } - - // Add the new command to the beginning of the list - history.Insert(0, (input, command, args)); - - // Keep only the most recent MAX_COMMAND_HISTORY commands - if (history.Count > MAX_COMMAND_HISTORY) - { - history.RemoveAt(history.Count - 1); - } - } - - private static void HandleCommandHistory(ICommandContext ctx, string input) - { - // Remove the ".!" prefix - string command = input.Substring(2).Trim(); - - // Check if the command history exists for this context - if (!_commandHistory.TryGetValue(ctx.Name, out var history) || history.Count == 0) - { - ctx.SysReply($"{"[error]".Color(Color.Red)} No command history available."); - return; - } - - // Handle .! list or .! l commands - if (command == "list" || command == "l") - { - var sb = new StringBuilder(); - sb.AppendLine("Command history:"); - - for (int i = 0; i < history.Count; i++) - { - sb.AppendLine($"{(i + 1).ToString().Color(Color.Gold)}. {history[i].input.Color(Color.Command)}"); - } - - ctx.SysPaginatedReply(sb); - return; - } - - // Handle .! # to execute a specific command by number - if (int.TryParse(command, out int index) && index > 0 && index <= history.Count) - { - var selectedCommand = history[index - 1]; - ctx.SysReply($"Executing command {index.ToString().Color(Color.Gold)}: {selectedCommand.input.Color(Color.Command)}"); - ExecuteCommandWithArgs(ctx, selectedCommand.Command, selectedCommand.Args); - return; - } - - // If just .! is provided, execute the most recent command - if (string.IsNullOrWhiteSpace(command)) - { - var mostRecent = history[0]; - ctx.SysReply($"Repeating most recent command: {mostRecent.input.Color(Color.Command)}"); - ExecuteCommandWithArgs(ctx, mostRecent.Command, mostRecent.Args); - return; - } - - // Invalid command - ctx.SysReply($"{"[error]".Color(Color.Red)} Invalid command history selection. Use {".! list".Color(Color.Command)} to see available commands or {".! #".Color(Color.Command)} to execute a specific command."); - } - - public static void UnregisterConverter(Type converter) - { - if (!IsGenericConverterContext(converter) && !IsSpecificConverterContext(converter)) - { - return; - } - - var args = converter.BaseType.GenericTypeArguments; - var convertFrom = args.FirstOrDefault(); - if (convertFrom == null) - { - Log.Warning($"Could not resolve converter type {converter.Name.ToString().Color(Color.Gold)}"); - return; - } - - if (_converters.ContainsKey(convertFrom)) - { - _converters.Remove(convertFrom); - Log.Info($"Unregistered converter {converter.Name}"); - } - else - { - Log.Warning($"Call to UnregisterConverter for a converter that was not registered. Type: {converter.Name.ToString().Color(Color.Gold)}"); - } - } - - internal static bool IsGenericConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<>).Name; - internal static bool IsSpecificConverterContext(Type rootType) => rootType?.BaseType?.Name == typeof(CommandArgumentConverter<,>).Name; - - public static void RegisterConverter(Type converter) - { - // check base type - var isGenericContext = IsGenericConverterContext(converter); - var isSpecificContext = IsSpecificConverterContext(converter); - - if (!isGenericContext && !isSpecificContext) - { - return; - } - - Log.Debug($"Trying to process {converter} as specifc={isSpecificContext} generic={isGenericContext}"); - - object converterInstance = Activator.CreateInstance(converter); - MethodInfo methodInfo = converter.GetMethod("Parse", BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); - if (methodInfo == null) - { - // can't bud - Log.Error("Can't find TryParse that matches"); - return; - } - - var args = converter.BaseType.GenericTypeArguments; - var convertFrom = args.FirstOrDefault(); - if (convertFrom == null) - { - Log.Error("Can't determine generic base type to convert from. "); - return; - } - - Type contextType = typeof(ICommandContext); - if (isSpecificContext) - { - if (args.Length != 2 || !typeof(ICommandContext).IsAssignableFrom(args[1])) - { - Log.Error("Can't determine generic base type to convert from."); - return; - } - - contextType = args[1]; - } - - - _converters.Add(convertFrom, (converterInstance, methodInfo, contextType)); - } - - public static void RegisterAll() => RegisterAll(Assembly.GetCallingAssembly()); - - public static void RegisterAll(Assembly assembly) - { - var types = assembly.GetTypes(); - - // Register Converters first as typically commands will depend on them. - foreach (var type in types) - { - RegisterConverter(type); - } - - foreach (var type in types) - { - RegisterCommandType(type); - } - } - - public static void RegisterCommandType(Type type) - { - var groupAttr = type.GetCustomAttribute(); - var assembly = type.Assembly; - if (groupAttr != null) - { - // handle groups - IDK later - } - - var methods = type.GetMethods(); - - ConstructorInfo contextConstructor = type.GetConstructors() - .Where(c => c.GetParameters().Length == 1 && typeof(ICommandContext).IsAssignableFrom(c.GetParameters().SingleOrDefault()?.ParameterType)) - .FirstOrDefault(); - - foreach (var method in methods) - { - RegisterMethod(assembly, groupAttr, contextConstructor, method); - } - } - - private static void RegisterMethod(Assembly assembly, CommandGroupAttribute groupAttr, ConstructorInfo customConstructor, MethodInfo method) - { - var commandAttr = method.GetCustomAttribute(); - if (commandAttr == null) return; - - // check for CommandContext as first argument to method - var paramInfos = method.GetParameters(); - var first = paramInfos.FirstOrDefault(); - if (first == null || first.ParameterType is ICommandContext) - { - Log.Error($"Method {method.Name.ToString().Color(Color.Gold)} has no CommandContext as first argument"); - return; - } - - var parameters = paramInfos.Skip(1).ToArray(); - - var canConvert = parameters.All(param => - { - if (_converters.ContainsKey(param.ParameterType)) - { - Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a parameter of type {param.ParameterType.Name.ToString().Color(Color.Gold)} which is registered as a converter"); - return true; - } - - var converter = TypeDescriptor.GetConverter(param.ParameterType); - if (converter == null || - !converter.CanConvertFrom(typeof(string))) - { - Log.Warning($"Parameter {param.Name.ToString().Color(Color.Gold)} could not be converted, so {method.Name.ToString().Color(Color.Gold)} will be ignored."); - return false; - } - - return true; - }); - - if (!canConvert) return; - - var constructorType = customConstructor?.GetParameters().Single().ParameterType; - - var command = new CommandMetadata(commandAttr, assembly, method, customConstructor, parameters, first.ParameterType, constructorType, groupAttr); - - // todo include prefix and group in here, this shoudl be a string match - // todo handle collisons here - - // BAD CODE INC.. permute and cache keys -> command - var groupNames = groupAttr == null ? new[] { "" } : groupAttr.ShortHand == null ? new[] { $"{groupAttr.Name} " } : new[] { $"{groupAttr.Name} ", $"{groupAttr.ShortHand} ", }; - var names = commandAttr.ShortHand == null ? new[] { commandAttr.Name } : new[] { commandAttr.Name, commandAttr.ShortHand }; - var prefix = DEFAULT_PREFIX; // TODO: get from attribute/config - List keys = new(); - foreach (var group in groupNames) - { - foreach (var name in names) - { - var key = $"{prefix}{group}{name}"; - _cache.AddCommand(key, parameters, command); - keys.Add(key); - } - } - - AssemblyCommandMap.TryGetValue(assembly, out var commandKeyCache); - commandKeyCache ??= new(); - commandKeyCache[command] = keys; - AssemblyCommandMap[assembly] = commandKeyCache; - } - - internal static Dictionary>> AssemblyCommandMap { get; } = new(); - - public static void UnregisterAssembly() => UnregisterAssembly(Assembly.GetCallingAssembly()); - - public static void UnregisterAssembly(Assembly assembly) - { - foreach (var type in assembly.DefinedTypes) - { - _cache.RemoveCommandsFromType(type); - UnregisterConverter(type); - // TODO: There's a lot of nasty cases involving cross mod converters that need testing - // as of right now the guidance should be to avoid depending on converters from a different mod - // especially if you're hot reloading either. - } - - AssemblyCommandMap.Remove(assembly); - } -} +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; + +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(); + CommandHistory.Reset(); + _pendingCommands.Clear(); + } + + // 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(); + + 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) + { + return _cache.GetCommandFromAssembly(input, assemblyName); + } + return _cache.GetCommand(input); + } + + 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 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; + return IsRemainderParameter(command.Parameters[command.Parameters.Length - 1]); + } + + 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 (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)); + + // 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) + { + // Load command history for this user if it's their first command this session + CommandHistory.EnsureHistoryLoaded(ctx); + + // 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(".!")) + { + return CommandHistory.HandleHistoryCommand(ctx, input.Trim(), Handle, ExecuteCommandWithArgs); + } + + // Parse assembly prefix, command, and remainder in one place + var parsed = ParseInput(input); + + // Get command(s) based on input + CacheResult matchedCommand = null; + if (parsed.HasAssembly) + { + matchedCommand = _cache.GetCommandFromAssembly(parsed.CommandInput, parsed.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, parsed.CommandInput); + } + + // 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)) + { + deniedCommands.Add(command); + continue; + } + + var (success, commandArgs, error) = TryConvertParameters(ctx, command, args, parsed.CommandInput); + if (success) + { + successfulCommands.Add((command, commandArgs, null)); + } + else + { + failedCommands.Add((command, error)); + } + } + + // 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) + { + 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]; + CommandHistory.AddToHistory(ctx, input, command, commandArgs); + return ExecuteCommandWithArgs(ctx, command, commandArgs); + } + + // Case 3: Multiple commands succeeded - store and ask user to select + var pendingKey = ctx.Name; + _pendingCommands[pendingKey] = (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) + { + var pendingKey = 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; + } + + 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]; + + CommandHistory.AddToHistory(ctx, pendingCommands.input, command, args); + var result = ExecuteCommandWithArgs(ctx, command, args); + _pendingCommands.Remove(pendingKey); + return result; + } + + 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; + 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; // the [Remainder] parameter 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); + + // 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 + { + commandWordCount += cmdName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + } + + 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) + { + 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; + } + + CommandHistory.AddToHistory(ctx, 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; + } + + 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(); + + 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 (IsRemainderParameter(param)) + { + Log.Debug($"Method {method.Name.ToString().Color(Color.Gold)} has a remainder parameter ({param.Name})"); + 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); + } +} 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.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 @@ + diff --git a/VCF.Tests/AssemblyCommandTests.cs b/VCF.Tests/AssemblyCommandTests.cs index 2c43609..b508954 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 { @@ -50,6 +47,111 @@ 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, [Remainder] string message) + { + ctx.Reply($"MyMod1 remainder: '{message}'"); + } + } + + // Assembly + param + remainder command + public class MyMod1ParamRemainderCommands + { + [Command("say", description: "MyMod1 say with prefix and remainder")] + public void SayWithRemainder(ICommandContext ctx, string prefix, [Remainder] string body) + { + ctx.Reply($"MyMod1 {prefix}: {body}"); + } + } + + // Assembly + grouped command + remainder + [CommandGroup("grp")] + public class MyMod1GroupedRemainderCommands + { + [Command("go", description: "MyMod1 grouped go with remainder")] + public void Go(ICommandContext ctx, [Remainder] string message) + { + ctx.Reply($"MyMod1 grp go: '{message}'"); + } + } + + // 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, [Remainder] string message) + { + ctx.Reply($"grp group go: '{message}'"); + } + } + + // 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, [Remainder] string message) + { + 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}"); + } + } + #endregion #region Helper Methods @@ -62,18 +164,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,10 +183,10 @@ 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 + // Update CommandCache (_newCache) foreach(var cacheEntry in CommandRegistry._cache._newCache.Values) { foreach(var commandList in cacheEntry.Values) @@ -104,6 +200,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 { @@ -114,8 +222,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; } } @@ -238,6 +346,152 @@ 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 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() + { + // 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 } } diff --git a/VCF.Tests/AssertReplyContext.cs b/VCF.Tests/AssertReplyContext.cs index c42f515..526df74 100644 --- a/VCF.Tests/AssertReplyContext.cs +++ b/VCF.Tests/AssertReplyContext.cs @@ -9,7 +9,10 @@ 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"); public bool IsAdmin { get; set; } @@ -25,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/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)); + } +} 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] 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 new file mode 100644 index 0000000..0c2d5eb --- /dev/null +++ b/VCF.Tests/RemainderParameterTests.cs @@ -0,0 +1,354 @@ +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, [Remainder] string message) + { + ctx.Reply($"Remainder: '{message}'"); + } + + [Command("say", description: "Says something with a prefix")] + public void SayWithPrefix(ICommandContext ctx, string prefix, [Remainder] string body) + { + ctx.Reply($"{prefix}: {body}"); + } + + [Command("optional", description: "Command with optional parameter and remainder")] + public void OptionalWithRemainder(ICommandContext ctx, string required, int optional = 42, [Remainder] string reason = "") + { + ctx.Reply($"Required: {required}, Optional: {optional}, Remainder: '{reason}'"); + } + } + + 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, [Remainder] string rest) + { + ctx.Reply($"Remainder test: {arg1}, '{rest}'"); + } + } + + [CommandGroup("grp")] + public class GroupedRemainderCommands + { + [Command("go", description: "Go with remainder")] + public void Go(ICommandContext ctx, [Remainder] string message) + { + ctx.Reply($"Grp go: '{message}'"); + } + + [Command("tag", description: "Tag with prefix and remainder")] + public void Tag(ICommandContext ctx, string name, [Remainder] string description) + { + ctx.Reply($"Grp tag: {name}, '{description}'"); + } + } + + [CommandGroup("msg", shortHand: "m")] + public class ShorthandGroupedRemainderCommands + { + [Command("send", description: "Send with remainder")] + public void Send(ICommandContext ctx, [Remainder] string message) + { + ctx.Reply($"Message: '{message}'"); + } + } + + public class MultiWordRemainderCommands + { + [Command("fancy thing", description: "Multi-word command with remainder")] + public void FancyThing(ICommandContext ctx, [Remainder] string text) + { + ctx.Reply($"Fancy thing: '{text}'"); + } + } + + [CommandGroup("stuff")] + public class GroupedMultiWordRemainderCommands + { + [Command("do thing", description: "Grouped multi-word command with remainder")] + public void DoThing(ICommandContext ctx, [Remainder] string filter) + { + 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, [Remainder] string args) + { + ctx.Reply($"Long command: '{args}'"); + } + } + + [CommandGroup("my group", shortHand: "mg")] + public class DifferentWordCountGroupShorthandCommands + { + [Command("run", description: "Different word count group shorthand")] + public void Run(ICommandContext ctx, [Remainder] string reason) + { + ctx.Reply($"Run: '{reason}'"); + } + } + + #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, ''"); + } + + [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'"); + } + + [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 diff --git a/VCF.Tests/RepeatCommandsTests.cs b/VCF.Tests/RepeatCommandsTests.cs index af87771..0d375ac 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 @@ -34,10 +108,244 @@ 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() + { + // 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 +375,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 +455,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"); } @@ -202,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"); @@ -221,10 +529,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