From 452f0bb139deb9e29eacc0adbbbabde5cfabe115 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 16:09:46 +0000 Subject: [PATCH] Describe chat commands in a machine readable way The only description of a chat command was its usage string, which is meant to be read by a human. A user interface which wants to offer the commands to a player needs the parts separately: the command, what it does, and one entry per parameter. Add ChatCommandInfo and ChatCommandParameterInfo, built from the metadata which is already there - ChatCommandHelpAttribute for the command and its required character status, ArgumentAttribute for the short names and whether a parameter is required, ValidValuesAttribute for the accepted values. Name and description come from the display attribute, so they are returned in the language of the player. The description which is passed to ChatCommandHelpAttribute was never stored anywhere. Keep it as a fallback for commands which have no display attribute, instead of discarding it. The reset info command had its texts hard coded in English. It now refers to resources like every other chat command does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA --- .../ChatCommands/ChatCommandHelpAttribute.cs | 10 ++ .../PlugIns/ChatCommands/ChatCommandInfo.cs | 28 +++++ .../ChatCommands/ChatCommandParameterInfo.cs | 21 ++++ .../ChatCommands/ChatCommandTypeExtensions.cs | 110 ++++++++++++++++-- .../PlugIns/ChatCommands/CommandExtensions.cs | 29 ++++- .../Properties/PlugInResources.Designer.cs | 20 +++- src/GameLogic/Properties/PlugInResources.resx | 6 + .../Resets/ResetInfoChatCommandPlugIn.cs | 2 +- 8 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 src/GameLogic/PlugIns/ChatCommands/ChatCommandInfo.cs create mode 100644 src/GameLogic/PlugIns/ChatCommands/ChatCommandParameterInfo.cs diff --git a/src/GameLogic/PlugIns/ChatCommands/ChatCommandHelpAttribute.cs b/src/GameLogic/PlugIns/ChatCommands/ChatCommandHelpAttribute.cs index b66e3ee..b8ad53e 100644 --- a/src/GameLogic/PlugIns/ChatCommands/ChatCommandHelpAttribute.cs +++ b/src/GameLogic/PlugIns/ChatCommands/ChatCommandHelpAttribute.cs @@ -39,6 +39,7 @@ public class ChatCommandHelpAttribute : Attribute public ChatCommandHelpAttribute(string command, string description, Type? argumentsType) : this(command, argumentsType, CharacterStatus.Normal) { + this.Description = description; } /// @@ -64,6 +65,15 @@ public class ChatCommandHelpAttribute : Attribute /// public CharacterStatus MinimumCharacterStatus { get; } + /// + /// Gets the description of the command, if one was specified. + /// + /// + /// This is only a fallback for commands without a . + /// A description defined there can be translated, so it's preferred. + /// + public string? Description { get; } + /// /// Gets the type of the arguments of the chat command. /// diff --git a/src/GameLogic/PlugIns/ChatCommands/ChatCommandInfo.cs b/src/GameLogic/PlugIns/ChatCommands/ChatCommandInfo.cs new file mode 100644 index 0000000..b52edea --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/ChatCommandInfo.cs @@ -0,0 +1,28 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +/// +/// Describes a chat command in a machine-readable way. +/// +/// +/// In contrast to , which is a text +/// meant to be read by a human, this description keeps the parts of a command +/// separated. That allows a user interface to offer the commands of a player +/// without requiring the player to know or type any of them. +/// +/// The command, including the slash, e.g. /item. +/// The name of the command, in the requested language. +/// The description of the command, in the requested language. +/// The character status which is required to execute the command. +/// The usage text, as it's shown by the help command. +/// The parameters of the command, in the order in which they are expected when they are passed without their short names. +public record ChatCommandInfo( + string Command, + string Name, + string Description, + CharacterStatus MinimumCharacterStatus, + string Usage, + IReadOnlyList Parameters); diff --git a/src/GameLogic/PlugIns/ChatCommands/ChatCommandParameterInfo.cs b/src/GameLogic/PlugIns/ChatCommands/ChatCommandParameterInfo.cs new file mode 100644 index 0000000..f832c6c --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/ChatCommandParameterInfo.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +/// +/// Describes one parameter of a chat command in a machine-readable way, so that +/// a user interface can generate an input field for it. +/// +/// The name of the parameter, as defined by the property of the arguments class. +/// The short name which is used in the shortName=value notation, if the parameter is decorated with an ; Otherwise, . +/// The name of the value type, e.g. Byte or String. +/// A value indicating whether the parameter has to be specified to execute the command. +/// The accepted values, if the parameter only accepts a limited set of them; Otherwise, empty. +public record ChatCommandParameterInfo( + string Name, + string? ShortName, + string TypeName, + bool IsRequired, + IReadOnlyList ValidValues); diff --git a/src/GameLogic/PlugIns/ChatCommands/ChatCommandTypeExtensions.cs b/src/GameLogic/PlugIns/ChatCommands/ChatCommandTypeExtensions.cs index b1b8b99..62bd338 100644 --- a/src/GameLogic/PlugIns/ChatCommands/ChatCommandTypeExtensions.cs +++ b/src/GameLogic/PlugIns/ChatCommands/ChatCommandTypeExtensions.cs @@ -4,7 +4,10 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using System.Globalization; using System.Reflection; +using System.Resources; +using MUnique.OpenMU.PlugIns; /// /// Extension methods regarding chat command types. @@ -18,11 +21,104 @@ public static class ChatCommandTypeExtensions /// The available chat commands of the player. public static IEnumerable GetAvailableChatCommands(this Player player) { - return player.GameContext?.PlugInManager - .GetKnownPlugInsOf() - .Select(CustomAttributeExtensions.GetCustomAttribute) - .Where(attribute => attribute is { }) - .Where(attribute => player.SelectedCharacter?.CharacterStatus >= attribute!.MinimumCharacterStatus) - .Select(attribute => attribute!) ?? Enumerable.Empty(); + return GetAvailableCommands(player).Select(command => command.Help); } -} \ No newline at end of file + + /// + /// Gets the description of the available chat commands of the player, + /// in the language of the player. + /// + /// The player. + /// The described chat commands which are available to the player. + public static IEnumerable GetAvailableChatCommandInfos(this Player player) + { + return GetAvailableCommands(player) + .Select(command => CreateChatCommandInfo(command.PlugInType, command.Help, player.Culture)); + } + + /// + /// Creates the description of the chat command which is implemented by the specified plugin type. + /// + /// The type of the chat command plugin. + /// The language in which name and description should be returned. If it's , the language of the current thread is used. + /// The described chat command, if the type is a chat command plugin; Otherwise, . + public static ChatCommandInfo? TryCreateChatCommandInfo(Type plugInType, CultureInfo? culture = null) + { + if (plugInType.GetCustomAttribute() is not { } help) + { + return null; + } + + return CreateChatCommandInfo(plugInType, help, culture); + } + + private static IEnumerable<(Type PlugInType, ChatCommandHelpAttribute Help)> GetAvailableCommands(Player player) + { + var plugInManager = player.GameContext?.PlugInManager; + if (plugInManager is null) + { + return []; + } + + return plugInManager + .GetKnownPlugInsOf() + .Where(plugInManager.IsPlugInActive) + .Select(plugInType => (PlugInType: plugInType, Help: plugInType.GetCustomAttribute())) + .Where(command => command.Help is { }) + .Where(command => player.SelectedCharacter?.CharacterStatus >= command.Help!.MinimumCharacterStatus) + .Select(command => (command.PlugInType, Help: command.Help!)); + } + + private static ChatCommandInfo CreateChatCommandInfo(Type plugInType, ChatCommandHelpAttribute help, CultureInfo? culture) + { + var display = plugInType.GetCustomAttribute(); + IReadOnlyList parameters = help.ArgumentsType is { } argumentsType + ? CommandExtensions.GetParameterInfos(argumentsType).ToList() + : []; + + return new ChatCommandInfo( + help.Command, + GetLocalizedValue(display, display?.Name, () => display?.GetName(), culture) ?? plugInType.Name, + GetLocalizedValue(display, display?.Description, () => display?.GetDescription(), culture) ?? help.Description ?? string.Empty, + help.MinimumCharacterStatus, + help.Usage, + parameters); + } + + /// + /// Gets the value of a in the requested language. + /// + /// The attribute. + /// The raw value of the requested property, which is a resource key when a is set. + /// The resolver of the attribute itself, which uses the language of the current thread. + /// The requested language. + /// The value in the requested language, if available. + private static string? GetLocalizedValue(DisplayAttribute? display, string? rawValue, Func defaultResolver, CultureInfo? culture) + { + if (display is null || string.IsNullOrEmpty(rawValue)) + { + return null; + } + + if (culture is { } + && display.ResourceType is { } resourceType + && resourceType.GetProperty(nameof(PlugInResources.ResourceManager), BindingFlags.Public | BindingFlags.Static)?.GetValue(null) is ResourceManager resourceManager + && resourceManager.GetString(rawValue, culture) is { } localizedValue) + { + return localizedValue; + } + + // Either no specific language was requested, or the value is a literal which + // can't be translated anyway - the attribute knows how to resolve both. + try + { + return defaultResolver(); + } + catch (InvalidOperationException) + { + // The attribute refers to a resource which doesn't exist. A single + // misconfigured plugin shouldn't break the whole command list. + return null; + } + } +} diff --git a/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs b/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs index e081649..fe6c94e 100644 --- a/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs +++ b/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs @@ -113,27 +113,46 @@ public static class CommandExtensions /// Type of the arguments. /// A list of parameters with name, type, and valid values. public static IEnumerable<(string Name, string Type, string ValidValues)> GetParameters(Type argumentsType) + { + return GetParameterInfos(argumentsType) + .Select(parameter => (Name: parameter.Name, Type: parameter.TypeName, ValidValues: string.Join('|', parameter.ValidValues))); + } + + /// + /// Gets the description of the parameters of an argument class. + /// + /// Type of the arguments. + /// The described parameters, in the order in which they are expected when they are passed without their short names. + public static IEnumerable GetParameterInfos(Type argumentsType) { var properties = argumentsType.GetProperties().Where(p => p.CanWrite); foreach (var property in properties) { - string validValues = string.Empty; + IReadOnlyList validValues = []; if (property.GetCustomAttribute() is { } validValuesAttribute) { - validValues = string.Join('|', validValuesAttribute.ValidValues); + validValues = validValuesAttribute.ValidValues.ToList(); } else if (property.PropertyType == typeof(bool)) { - validValues = "0|1"; + validValues = ["0", "1"]; } else if (property.PropertyType == typeof(byte) || property.PropertyType == typeof(ushort) || property.PropertyType == typeof(uint)) { // todo: ranges in ParameterAttribute - // validValues = ""; } - yield return (property.Name, property.PropertyType.Name, validValues); + // A parameter without an ArgumentAttribute can't be required - the parser + // only counts the required arguments of the attributed properties. + var argumentAttribute = property.GetCustomAttribute(inherit: true); + + yield return new ChatCommandParameterInfo( + property.Name, + argumentAttribute?.ShortName, + property.PropertyType.Name, + argumentAttribute?.IsRequired ?? false, + validValues); } } diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 1ce7bf1..c91308b 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -2267,6 +2267,24 @@ namespace MUnique.OpenMU.GameLogic.Properties { } } + /// + /// Looks up a localized string similar to Handles the chat command '/resetinfo'. Shows the required costs and the granted points for the next reset.. + /// + public static string ResetInfoChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("ResetInfoChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reset info chat command. + /// + public static string ResetInfoChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("ResetInfoChatCommandPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reset Feature. /// diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 541f2a2..429b327 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -195,6 +195,12 @@ Handles the chat command '/reset'. + + Reset info chat command + + + Handles the chat command '/resetinfo'. Shows the required costs and the granted points for the next reset. + Reset Feature diff --git a/src/GameLogic/Resets/ResetInfoChatCommandPlugIn.cs b/src/GameLogic/Resets/ResetInfoChatCommandPlugIn.cs index 625a6a6..9470f3f 100644 --- a/src/GameLogic/Resets/ResetInfoChatCommandPlugIn.cs +++ b/src/GameLogic/Resets/ResetInfoChatCommandPlugIn.cs @@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns; /// [Guid("79F2C2C2-2E4C-4F4B-8A74-4227D1209D27")] [PlugIn] -[Display(Name = "Reset Info Command", Description = "Shows required costs and granted points for the next reset.")] +[Display(Name = nameof(PlugInResources.ResetInfoChatCommandPlugIn_Name), Description = nameof(PlugInResources.ResetInfoChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] [ChatCommandHelp(Command, "Shows required costs and gained points for the next reset.", null)] public class ResetInfoChatCommandPlugIn : IChatCommandPlugIn {