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));
+ }
+}