From 066894c4f6c22496b54475fb90156910bc576698 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 21:15:58 +0000 Subject: [PATCH] Send the available chat commands to clients which can show them Adds the server side of the chat command list: a view plugin which sends one ChatCommandInfo per available command, and the handler for the request of a client which supports it. Both are limited to the extended protocol, so classic clients don't receive messages they can't understand. The commands come from GetAvailableChatCommandInfos, so a player only learns about the ones he may execute, and deactivated ones stay out. Executing a command needs nothing new: the client sends the composed command line as an ordinary chat message, which the chat message processor already routes to the command without broadcasting it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA (cherry picked from commit f3a9cb132c295863942bb0f06f40cde231e57907) --- .../Views/IChatCommandListViewPlugIn.cs | 20 ++++ .../ChatCommandGroupHandlerPlugIn.cs | 44 ++++++++ .../ChatCommandListRequestHandlerPlugIn.cs | 42 ++++++++ .../Properties/PlugInResources.Designer.cs | 56 +++++++++- .../Properties/PlugInResources.resx | 18 ++++ .../RemoteView/ChatCommandListViewPlugIn.cs | 100 ++++++++++++++++++ .../ChatCommandPacketTest.cs | 91 ++++++++++++++++ 7 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 src/GameLogic/Views/IChatCommandListViewPlugIn.cs create mode 100644 src/GameServer/MessageHandler/ChatCommandGroupHandlerPlugIn.cs create mode 100644 src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs create mode 100644 src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs diff --git a/src/GameLogic/Views/IChatCommandListViewPlugIn.cs b/src/GameLogic/Views/IChatCommandListViewPlugIn.cs new file mode 100644 index 0000000..cd7fb13 --- /dev/null +++ b/src/GameLogic/Views/IChatCommandListViewPlugIn.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views; + +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +/// +/// Interface of a view whose client can show the available chat commands to the player. +/// +public interface IChatCommandListViewPlugIn : IViewPlugIn +{ + /// + /// Shows the chat commands which are available to the player, so that the client + /// can offer them without requiring the player to know or type any of them. + /// + /// The available chat commands. + ValueTask ShowChatCommandListAsync(IReadOnlyCollection commands); +} diff --git a/src/GameServer/MessageHandler/ChatCommandGroupHandlerPlugIn.cs b/src/GameServer/MessageHandler/ChatCommandGroupHandlerPlugIn.cs new file mode 100644 index 0000000..7082014 --- /dev/null +++ b/src/GameServer/MessageHandler/ChatCommandGroupHandlerPlugIn.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Packet handler for the messages about chat commands (0xF5 identifier). +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.ChatCommandGroupHandlerPlugIn_Name), Description = nameof(PlugInResources.ChatCommandGroupHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("2F1B5B26-2E22-4E7D-9C8B-1C6E5B7A9D40")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +internal class ChatCommandGroupHandlerPlugIn : GroupPacketHandlerPlugIn +{ + /// + /// The group key. It's the , which can't be + /// used here directly because it's not a compile time constant. + /// + internal const byte GroupKey = 0xF5; + + /// + /// Initializes a new instance of the class. + /// + /// The client version provider. + /// The manager. + /// The logger factory. + public ChatCommandGroupHandlerPlugIn(IClientVersionProvider clientVersionProvider, PlugInManager manager, ILoggerFactory loggerFactory) + : base(clientVersionProvider, manager, loggerFactory) + { + } + + /// + public override bool IsEncryptionExpected => false; + + /// + public override byte Key => GroupKey; +} diff --git a/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs new file mode 100644 index 0000000..bbdcda8 --- /dev/null +++ b/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for the request of the list of available chat commands. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.ChatCommandListRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.ChatCommandListRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("B1E3C0F5-5D6A-4E52-9A73-8C0F1D2B4A66")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +[BelongsToGroup(ChatCommandGroupHandlerPlugIn.GroupKey)] +internal class ChatCommandListRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => ChatCommandListRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (player.SelectedCharacter is null) + { + return; + } + + var commands = player.GetAvailableChatCommandInfos().ToList(); + await player.InvokeViewPlugInAsync(p => p.ShowChatCommandListAsync(commands)).ConfigureAwait(false); + } +} diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index a924d4f..11b55b6 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -6215,5 +6215,59 @@ namespace MUnique.OpenMU.GameServer.Properties { return ResourceManager.GetString("WhisperedChatMessageHandlerPlugIn_Name", resourceCulture); } } + /// + /// Looks up a localized string similar to Chat command list view. + /// + public static string ChatCommandListViewPlugIn_Name { + get { + return ResourceManager.GetString("ChatCommandListViewPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the chat commands which are available to the player, so that the client can offer them in a user interface.. + /// + public static string ChatCommandListViewPlugIn_Description { + get { + return ResourceManager.GetString("ChatCommandListViewPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Chat command packet group handler. + /// + public static string ChatCommandGroupHandlerPlugIn_Name { + get { + return ResourceManager.GetString("ChatCommandGroupHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the packets about chat commands (0xF5).. + /// + public static string ChatCommandGroupHandlerPlugIn_Description { + get { + return ResourceManager.GetString("ChatCommandGroupHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Chat command list request handler. + /// + public static string ChatCommandListRequestHandlerPlugIn_Name { + get { + return ResourceManager.GetString("ChatCommandListRequestHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the request for the list of chat commands which are available to the player.. + /// + public static string ChatCommandListRequestHandlerPlugIn_Description { + get { + return ResourceManager.GetString("ChatCommandListRequestHandlerPlugIn_Description", resourceCulture); + } + } + } } diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index dcf7494..e420009 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2169,4 +2169,22 @@ Initializes the MU Helper settings when the player enters the world. + + Chat command list view + + + Sends the chat commands which are available to the player, so that the client can offer them in a user interface. + + + Chat command packet group handler + + + Handles the packets about chat commands (0xF5). + + + Chat command list request handler + + + Handles the request for the list of chat commands which are available to the player. + diff --git a/src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs b/src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs new file mode 100644 index 0000000..b095dd5 --- /dev/null +++ b/src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs @@ -0,0 +1,100 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameServer.RemoteView.Character; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which sends +/// one message per available chat command to the game client. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.ChatCommandListViewPlugIn_Name), Description = nameof(PlugInResources.ChatCommandListViewPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("6E9E4C1E-9C2A-4C7E-9F5B-0B0A2E2E51D7")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class ChatCommandListViewPlugIn : IChatCommandListViewPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public ChatCommandListViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowChatCommandListAsync(IReadOnlyCollection commands) + { + if (this._player.Connection is not { Connected: true } connection) + { + return; + } + + var index = 0; + foreach (var command in commands) + { + await SendCommandAsync(connection, command, (byte)index, (byte)commands.Count).ConfigureAwait(false); + index++; + } + } + + private static ValueTask SendCommandAsync(IConnection connection, ChatCommandInfo command, byte index, byte count) + { + // The client can't show more parameters than fit into one message, and a command + // with that many parameters wouldn't be usable anyway. + var parameters = command.Parameters.Take(byte.MaxValue).ToList(); + + int Write() + { + var size = ChatCommandInfoRef.GetRequiredSize(parameters.Count); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new ChatCommandInfoRef(span) + { + Index = index, + Count = count, + MinimumCharacterStatus = command.MinimumCharacterStatus.Convert(), + ParameterCount = (byte)parameters.Count, + Command = command.Command, + Name = command.Name, + Description = command.Description, + }; + + for (int i = 0; i < parameters.Count; i++) + { + var parameter = parameters[i]; + var target = packet[i]; + target.IsRequired = parameter.IsRequired; + target.Type = GetParameterType(parameter.TypeName); + target.Name = parameter.Name; + target.ShortName = parameter.ShortName ?? string.Empty; + target.ValidValues = string.Join('|', parameter.ValidValues); + } + + return size; + } + + return connection.SendAsync(Write); + } + + private static ChatCommandInfo.ChatCommandParameterType GetParameterType(string typeName) + { + return typeName switch + { + nameof(Boolean) => ChatCommandInfo.ChatCommandParameterType.Boolean, + nameof(Byte) or nameof(SByte) + or nameof(Int16) or nameof(UInt16) + or nameof(Int32) or nameof(UInt32) + or nameof(Int64) or nameof(UInt64) => ChatCommandInfo.ChatCommandParameterType.Number, + _ => ChatCommandInfo.ChatCommandParameterType.Text, + }; + } +} diff --git a/tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs b/tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs new file mode 100644 index 0000000..ae01dd3 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs @@ -0,0 +1,91 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.Network.Packets.ServerToClient; + +/// +/// Tests for the packets which describe the chat commands. +/// +[TestFixture] +public class ChatCommandPacketTest +{ + /// + /// Tests that the values of a command survive the way through the packet, so that + /// none of the fields overlaps another one. + /// + [Test] + public void ChatCommandInfoKeepsItsValues() + { + const int parameterCount = 3; + var data = new byte[ChatCommandInfoRef.GetRequiredSize(parameterCount)]; + + var written = new ChatCommandInfoRef(data) + { + Index = 7, + Count = 42, + MinimumCharacterStatus = CharacterStatus.GameMaster, + ParameterCount = parameterCount, + Command = "/item", + Name = "Item chat command", + Description = "Drops a specific item next to the character.", + }; + + var group = written[0]; + group.Name = "Group"; + group.ShortName = "group"; + group.IsRequired = true; + group.Type = ChatCommandInfo.ChatCommandParameterType.Number; + + var ancient = written[1]; + ancient.Name = "Ancient"; + ancient.ShortName = "anc"; + ancient.ValidValues = "0|1|2"; + ancient.Type = ChatCommandInfo.ChatCommandParameterType.Number; + + var skill = written[2]; + skill.Name = "Skill"; + skill.ShortName = "sk"; + skill.Type = ChatCommandInfo.ChatCommandParameterType.Boolean; + + var read = new ChatCommandInfoRef(data); + + Assert.That(read.Header.Code, Is.EqualTo(0xF5)); + Assert.That(read.Header.SubCode, Is.EqualTo(0x01)); + Assert.That(read.Index, Is.EqualTo(7)); + Assert.That(read.Count, Is.EqualTo(42)); + Assert.That(read.MinimumCharacterStatus, Is.EqualTo(CharacterStatus.GameMaster)); + Assert.That(read.ParameterCount, Is.EqualTo(parameterCount)); + Assert.That(read.Command, Is.EqualTo("/item")); + Assert.That(read.Name, Is.EqualTo("Item chat command")); + Assert.That(read.Description, Is.EqualTo("Drops a specific item next to the character.")); + + Assert.That(read[0].Name, Is.EqualTo("Group")); + Assert.That(read[0].ShortName, Is.EqualTo("group")); + Assert.That(read[0].IsRequired, Is.True); + Assert.That(read[0].ValidValues, Is.Empty); + + Assert.That(read[1].Name, Is.EqualTo("Ancient")); + Assert.That(read[1].ShortName, Is.EqualTo("anc")); + Assert.That(read[1].ValidValues, Is.EqualTo("0|1|2")); + Assert.That(read[1].IsRequired, Is.False); + + Assert.That(read[2].Name, Is.EqualTo("Skill")); + Assert.That(read[2].Type, Is.EqualTo(ChatCommandInfo.ChatCommandParameterType.Boolean)); + } + + /// + /// Tests that the size grows by exactly one parameter entry, so that the parameters + /// don't overlap the fields before them. + /// + [Test] + public void ChatCommandInfoSizeDependsOnParameterCount() + { + var withoutParameters = ChatCommandInfoRef.GetRequiredSize(0); + var withOneParameter = ChatCommandInfoRef.GetRequiredSize(1); + + Assert.That(withOneParameter - withoutParameters, Is.EqualTo(ChatCommandInfo.ChatCommandParameter.Length)); + } +}