Describe chat commands in a machine readable way
Some checks failed
.NET Core / build (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
This commit is contained in:
Claude
2026-07-25 16:09:46 +00:00
committed by Acentech Dev
parent 38fd435ff6
commit 452f0bb139
8 changed files with 212 additions and 14 deletions

View File

@@ -39,6 +39,7 @@ public class ChatCommandHelpAttribute : Attribute
public ChatCommandHelpAttribute(string command, string description, Type? argumentsType) public ChatCommandHelpAttribute(string command, string description, Type? argumentsType)
: this(command, argumentsType, CharacterStatus.Normal) : this(command, argumentsType, CharacterStatus.Normal)
{ {
this.Description = description;
} }
/// <summary> /// <summary>
@@ -64,6 +65,15 @@ public class ChatCommandHelpAttribute : Attribute
/// </summary> /// </summary>
public CharacterStatus MinimumCharacterStatus { get; } public CharacterStatus MinimumCharacterStatus { get; }
/// <summary>
/// Gets the description of the command, if one was specified.
/// </summary>
/// <remarks>
/// This is only a fallback for commands without a <see cref="DisplayAttribute"/>.
/// A description defined there can be translated, so it's preferred.
/// </remarks>
public string? Description { get; }
/// <summary> /// <summary>
/// Gets the type of the arguments of the chat command. /// Gets the type of the arguments of the chat command.
/// </summary> /// </summary>

View File

@@ -0,0 +1,28 @@
// <copyright file="ChatCommandInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// <summary>
/// Describes a chat command in a machine-readable way.
/// </summary>
/// <remarks>
/// In contrast to <see cref="ChatCommandHelpAttribute.Usage"/>, 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.
/// </remarks>
/// <param name="Command">The command, including the slash, e.g. <c>/item</c>.</param>
/// <param name="Name">The name of the command, in the requested language.</param>
/// <param name="Description">The description of the command, in the requested language.</param>
/// <param name="MinimumCharacterStatus">The character status which is required to execute the command.</param>
/// <param name="Usage">The usage text, as it's shown by the help command.</param>
/// <param name="Parameters">The parameters of the command, in the order in which they are expected when they are passed without their short names.</param>
public record ChatCommandInfo(
string Command,
string Name,
string Description,
CharacterStatus MinimumCharacterStatus,
string Usage,
IReadOnlyList<ChatCommandParameterInfo> Parameters);

View File

@@ -0,0 +1,21 @@
// <copyright file="ChatCommandParameterInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// <summary>
/// Describes one parameter of a chat command in a machine-readable way, so that
/// a user interface can generate an input field for it.
/// </summary>
/// <param name="Name">The name of the parameter, as defined by the property of the arguments class.</param>
/// <param name="ShortName">The short name which is used in the <c>shortName=value</c> notation, if the parameter is decorated with an <see cref="ArgumentAttribute"/>; Otherwise, <see langword="null"/>.</param>
/// <param name="TypeName">The name of the value type, e.g. <c>Byte</c> or <c>String</c>.</param>
/// <param name="IsRequired">A value indicating whether the parameter has to be specified to execute the command.</param>
/// <param name="ValidValues">The accepted values, if the parameter only accepts a limited set of them; Otherwise, empty.</param>
public record ChatCommandParameterInfo(
string Name,
string? ShortName,
string TypeName,
bool IsRequired,
IReadOnlyList<string> ValidValues);

View File

@@ -4,7 +4,10 @@
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Globalization;
using System.Reflection; using System.Reflection;
using System.Resources;
using MUnique.OpenMU.PlugIns;
/// <summary> /// <summary>
/// Extension methods regarding chat command types. /// Extension methods regarding chat command types.
@@ -18,11 +21,104 @@ public static class ChatCommandTypeExtensions
/// <returns>The available chat commands of the player.</returns> /// <returns>The available chat commands of the player.</returns>
public static IEnumerable<ChatCommandHelpAttribute> GetAvailableChatCommands(this Player player) public static IEnumerable<ChatCommandHelpAttribute> GetAvailableChatCommands(this Player player)
{ {
return player.GameContext?.PlugInManager return GetAvailableCommands(player).Select(command => command.Help);
}
/// <summary>
/// Gets the description of the available chat commands of the player,
/// in the language of the player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The described chat commands which are available to the player.</returns>
public static IEnumerable<ChatCommandInfo> GetAvailableChatCommandInfos(this Player player)
{
return GetAvailableCommands(player)
.Select(command => CreateChatCommandInfo(command.PlugInType, command.Help, player.Culture));
}
/// <summary>
/// Creates the description of the chat command which is implemented by the specified plugin type.
/// </summary>
/// <param name="plugInType">The type of the chat command plugin.</param>
/// <param name="culture">The language in which name and description should be returned. If it's <see langword="null"/>, the language of the current thread is used.</param>
/// <returns>The described chat command, if the type is a chat command plugin; Otherwise, <see langword="null"/>.</returns>
public static ChatCommandInfo? TryCreateChatCommandInfo(Type plugInType, CultureInfo? culture = null)
{
if (plugInType.GetCustomAttribute<ChatCommandHelpAttribute>() 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<IChatCommandPlugIn>() .GetKnownPlugInsOf<IChatCommandPlugIn>()
.Select(CustomAttributeExtensions.GetCustomAttribute<ChatCommandHelpAttribute>) .Where(plugInManager.IsPlugInActive)
.Where(attribute => attribute is { }) .Select(plugInType => (PlugInType: plugInType, Help: plugInType.GetCustomAttribute<ChatCommandHelpAttribute>()))
.Where(attribute => player.SelectedCharacter?.CharacterStatus >= attribute!.MinimumCharacterStatus) .Where(command => command.Help is { })
.Select(attribute => attribute!) ?? Enumerable.Empty<ChatCommandHelpAttribute>(); .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<DisplayAttribute>();
IReadOnlyList<ChatCommandParameterInfo> 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);
}
/// <summary>
/// Gets the value of a <see cref="DisplayAttribute"/> in the requested language.
/// </summary>
/// <param name="display">The attribute.</param>
/// <param name="rawValue">The raw value of the requested property, which is a resource key when a <see cref="DisplayAttribute.ResourceType"/> is set.</param>
/// <param name="defaultResolver">The resolver of the attribute itself, which uses the language of the current thread.</param>
/// <param name="culture">The requested language.</param>
/// <returns>The value in the requested language, if available.</returns>
private static string? GetLocalizedValue(DisplayAttribute? display, string? rawValue, Func<string?> 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;
}
} }
} }

View File

@@ -113,27 +113,46 @@ public static class CommandExtensions
/// <param name="argumentsType">Type of the arguments.</param> /// <param name="argumentsType">Type of the arguments.</param>
/// <returns>A list of parameters with name, type, and valid values.</returns> /// <returns>A list of parameters with name, type, and valid values.</returns>
public static IEnumerable<(string Name, string Type, string ValidValues)> GetParameters(Type argumentsType) 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)));
}
/// <summary>
/// Gets the description of the parameters of an argument class.
/// </summary>
/// <param name="argumentsType">Type of the arguments.</param>
/// <returns>The described parameters, in the order in which they are expected when they are passed without their short names.</returns>
public static IEnumerable<ChatCommandParameterInfo> GetParameterInfos(Type argumentsType)
{ {
var properties = argumentsType.GetProperties().Where(p => p.CanWrite); var properties = argumentsType.GetProperties().Where(p => p.CanWrite);
foreach (var property in properties) foreach (var property in properties)
{ {
string validValues = string.Empty; IReadOnlyList<string> validValues = [];
if (property.GetCustomAttribute<ValidValuesAttribute>() is { } validValuesAttribute) if (property.GetCustomAttribute<ValidValuesAttribute>() is { } validValuesAttribute)
{ {
validValues = string.Join('|', validValuesAttribute.ValidValues); validValues = validValuesAttribute.ValidValues.ToList();
} }
else if (property.PropertyType == typeof(bool)) 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)) else if (property.PropertyType == typeof(byte) || property.PropertyType == typeof(ushort) || property.PropertyType == typeof(uint))
{ {
// todo: ranges in ParameterAttribute // 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<ArgumentAttribute>(inherit: true);
yield return new ChatCommandParameterInfo(
property.Name,
argumentAttribute?.ShortName,
property.PropertyType.Name,
argumentAttribute?.IsRequired ?? false,
validValues);
} }
} }

View File

@@ -1,4 +1,4 @@
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// <auto-generated> // <auto-generated>
// This code was generated by a tool. // This code was generated by a tool.
// Runtime Version:4.0.30319.42000 // Runtime Version:4.0.30319.42000
@@ -2267,6 +2267,24 @@ namespace MUnique.OpenMU.GameLogic.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Handles the chat command &apos;/resetinfo&apos;. Shows the required costs and the granted points for the next reset..
/// </summary>
public static string ResetInfoChatCommandPlugIn_Description {
get {
return ResourceManager.GetString("ResetInfoChatCommandPlugIn_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reset info chat command.
/// </summary>
public static string ResetInfoChatCommandPlugIn_Name {
get {
return ResourceManager.GetString("ResetInfoChatCommandPlugIn_Name", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Reset Feature. /// Looks up a localized string similar to Reset Feature.
/// </summary> /// </summary>

View File

@@ -195,6 +195,12 @@
<data name="ResetChatCommandPlugIn_Description" xml:space="preserve"> <data name="ResetChatCommandPlugIn_Description" xml:space="preserve">
<value>Handles the chat command '/reset'.</value> <value>Handles the chat command '/reset'.</value>
</data> </data>
<data name="ResetInfoChatCommandPlugIn_Name" xml:space="preserve">
<value>Reset info chat command</value>
</data>
<data name="ResetInfoChatCommandPlugIn_Description" xml:space="preserve">
<value>Handles the chat command '/resetinfo'. Shows the required costs and the granted points for the next reset.</value>
</data>
<data name="ResetFeaturePlugIn_Name" xml:space="preserve"> <data name="ResetFeaturePlugIn_Name" xml:space="preserve">
<value>Reset Feature</value> <value>Reset Feature</value>
</data> </data>

View File

@@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns;
/// </summary> /// </summary>
[Guid("79F2C2C2-2E4C-4F4B-8A74-4227D1209D27")] [Guid("79F2C2C2-2E4C-4F4B-8A74-4227D1209D27")]
[PlugIn] [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)] [ChatCommandHelp(Command, "Shows required costs and gained points for the next reset.", null)]
public class ResetInfoChatCommandPlugIn : IChatCommandPlugIn public class ResetInfoChatCommandPlugIn : IChatCommandPlugIn
{ {