Files
AdamuSw/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs
Claude 452f0bb139
Some checks failed
.NET Core / build (push) Has been cancelled
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
2026-07-31 15:09:32 +03:00

227 lines
8.5 KiB
C#

// <copyright file="CommandExtensions.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;
using System.Globalization;
using System.Reflection;
/// <summary>
/// Extensions to make the process of creating more commands easier.
/// </summary>
public static class CommandExtensions
{
/// <summary>
/// Parse the arguments of a command string.
/// </summary>
/// <param name="command">The command.</param>
/// <param name="player">The player which issued the command.</param>
/// <typeparam name="T">The type.</typeparam>
/// <returns>Returns the parsed object, if successful; Otherwise <see langword="null"/>.</returns>
public static async ValueTask<T?> TryParseArgumentsAsync<T>(this string command, Player? player)
where T : class, new()
{
var instance = new T();
var properties = typeof(T).GetProperties()
.Where(property => property.SetMethod is { })
.ToList();
var arguments = command.Split(' ').Where(x => !x.Contains("/")).ToList();
if (command.Contains('='))
{
// [Short argument parsing]
// If the command string contains = it means it is using the short version
if (await ReadNamedArgumentsAsync(instance, properties, arguments, player).ConfigureAwait(false))
{
return instance;
}
return null;
}
var attributedArguments = properties
.Select(p => p.GetCustomAttribute<ArgumentAttribute>(inherit: true))
.Where(a => a is { })
.Select(a => a!)
.ToList();
var requiredArgumentCount = attributedArguments.Any()
? attributedArguments.Count(a => a.IsRequired)
: arguments.Count;
if (arguments.Count < requiredArgumentCount)
{
if (player is not null)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CommandExtensionsInvalidArgumentCount), requiredArgumentCount, arguments.Count).ConfigureAwait(false);
}
return null;
}
var success = true;
for (var i = 0; i < Math.Min(arguments.Count, properties.Count); i++)
{
var property = properties[i];
var argument = arguments[i];
success = await TrySetPropertyValueAsync(instance, property, argument, player).ConfigureAwait(false) && success;
}
return instance;
}
/// <summary>
/// Create the usage string for the command using the argument class.
/// </summary>
/// <param name="argumentsType">Type of the arguments.</param>
/// <param name="commandName">The command name.</param>
/// <returns>
/// The usage string.
/// </returns>
public static string CreateUsage(Type argumentsType, string commandName)
{
var stringBuilder = new StringBuilder();
stringBuilder.Append($"{commandName} ");
foreach (var parameter in GetParameters(argumentsType).ToList())
{
if (!string.IsNullOrWhiteSpace(parameter.ValidValues))
{
stringBuilder.Append($"{{{parameter.Name}:{parameter.ValidValues}}}");
}
else if (parameter.Type == nameof(String))
{
stringBuilder.Append($"{{{parameter.Name}}}");
}
else
{
stringBuilder.Append($"{{{parameter.Name}:{parameter.Type}}}");
}
stringBuilder.Append(" ");
}
stringBuilder.ToString().TrimEnd(' ');
return stringBuilder.ToString();
}
/// <summary>
/// Gets the parameters for an argument class.
/// </summary>
/// <param name="argumentsType">Type of the arguments.</param>
/// <returns>A list of parameters with name, type, and valid values.</returns>
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);
foreach (var property in properties)
{
IReadOnlyList<string> validValues = [];
if (property.GetCustomAttribute<ValidValuesAttribute>() is { } validValuesAttribute)
{
validValues = validValuesAttribute.ValidValues.ToList();
}
else if (property.PropertyType == typeof(bool))
{
validValues = ["0", "1"];
}
else if (property.PropertyType == typeof(byte) || property.PropertyType == typeof(ushort) || property.PropertyType == typeof(uint))
{
// todo: ranges in ParameterAttribute
}
// 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);
}
}
private static async ValueTask<bool> ReadNamedArgumentsAsync(object instance, IList<PropertyInfo> properties, IList<string> arguments, Player? player)
{
var argumentProperties = properties.Where(property => property.GetCustomAttribute<ArgumentAttribute>() is { }).ToList();
var requiredProperties = argumentProperties.Where(prop => prop.GetCustomAttribute<ArgumentAttribute>() is { IsRequired: true }).ToList();
foreach (var property in argumentProperties)
{
var attribute = property.GetCustomAttributes<ArgumentAttribute>().First();
var argument = arguments.FirstOrDefault(x => x.Split('=').First().Trim() == attribute.ShortName);
if (argument is null)
{
continue;
}
// Cleans the argument from the short name
var argumentValue = argument.Replace($"{attribute.ShortName}=", string.Empty);
if (!await TrySetPropertyValueAsync(instance, property, argumentValue, player).ConfigureAwait(false))
{
return false;
}
requiredProperties.Remove(property);
}
if (!requiredProperties.Any())
{
return true;
}
if (player is null)
{
return false;
}
// One or many required properties were not used
foreach (var requiredProperty in requiredProperties)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CommandExtensions_RequiredArgumentMissing), requiredProperty.Name).ConfigureAwait(false);
}
return false;
}
private static async ValueTask<bool> TrySetPropertyValueAsync(object instance, PropertyInfo propertyInfo, string stringValue, Player? player)
{
try
{
// Special handling of booleans; we want to allow 0 and 1 as valid values.
if (propertyInfo.PropertyType == typeof(bool) && int.TryParse(stringValue, out var intBool))
{
stringValue = intBool == 1 ? bool.TrueString : bool.FalseString;
}
propertyInfo.SetValue(instance, Convert.ChangeType(stringValue, propertyInfo.PropertyType, CultureInfo.InvariantCulture));
return true;
}
catch
{
if (player is not null)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CommandExtensionsArgumentInvalidType), propertyInfo.Name, propertyInfo.PropertyType.Name).ConfigureAwait(false);
}
return false;
}
}
}