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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA (cherry picked from commit f3a9cb132c295863942bb0f06f40cde231e57907)
This commit is contained in:
20
src/GameLogic/Views/IChatCommandListViewPlugIn.cs
Normal file
20
src/GameLogic/Views/IChatCommandListViewPlugIn.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// <copyright file="IChatCommandListViewPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic.Views;
|
||||||
|
|
||||||
|
using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface of a view whose client can show the available chat commands to the player.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChatCommandListViewPlugIn : IViewPlugIn
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="commands">The available chat commands.</param>
|
||||||
|
ValueTask ShowChatCommandListAsync(IReadOnlyCollection<ChatCommandInfo> commands);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// <copyright file="ChatCommandGroupHandlerPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packet handler for the messages about chat commands (0xF5 identifier).
|
||||||
|
/// </summary>
|
||||||
|
[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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The group key. It's the <see cref="ChatCommandListRequest.Code"/>, which can't be
|
||||||
|
/// used here directly because it's not a compile time constant.
|
||||||
|
/// </summary>
|
||||||
|
internal const byte GroupKey = 0xF5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChatCommandGroupHandlerPlugIn" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientVersionProvider">The client version provider.</param>
|
||||||
|
/// <param name="manager">The manager.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
public ChatCommandGroupHandlerPlugIn(IClientVersionProvider clientVersionProvider, PlugInManager manager, ILoggerFactory loggerFactory)
|
||||||
|
: base(clientVersionProvider, manager, loggerFactory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool IsEncryptionExpected => false;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override byte Key => GroupKey;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// <copyright file="ChatCommandListRequestHandlerPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for the request of the list of available chat commands.
|
||||||
|
/// </summary>
|
||||||
|
[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
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsEncryptionExpected => false;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public byte Key => ChatCommandListRequest.SubCode;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask HandlePacketAsync(Player player, Memory<byte> packet)
|
||||||
|
{
|
||||||
|
if (player.SelectedCharacter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = player.GetAvailableChatCommandInfos().ToList();
|
||||||
|
await player.InvokeViewPlugInAsync<IChatCommandListViewPlugIn>(p => p.ShowChatCommandListAsync(commands)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -6215,5 +6215,59 @@ namespace MUnique.OpenMU.GameServer.Properties {
|
|||||||
return ResourceManager.GetString("WhisperedChatMessageHandlerPlugIn_Name", resourceCulture);
|
return ResourceManager.GetString("WhisperedChatMessageHandlerPlugIn_Name", resourceCulture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Chat command list view.
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandListViewPlugIn_Name {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandListViewPlugIn_Name", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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..
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandListViewPlugIn_Description {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandListViewPlugIn_Description", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Chat command packet group handler.
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandGroupHandlerPlugIn_Name {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandGroupHandlerPlugIn_Name", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Handles the packets about chat commands (0xF5)..
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandGroupHandlerPlugIn_Description {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandGroupHandlerPlugIn_Description", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Chat command list request handler.
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandListRequestHandlerPlugIn_Name {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandListRequestHandlerPlugIn_Name", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Handles the request for the list of chat commands which are available to the player..
|
||||||
|
/// </summary>
|
||||||
|
public static string ChatCommandListRequestHandlerPlugIn_Description {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatCommandListRequestHandlerPlugIn_Description", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2169,4 +2169,22 @@
|
|||||||
<data name="MuHelperSettingsInitializationPlugIn_Description" xml:space="preserve">
|
<data name="MuHelperSettingsInitializationPlugIn_Description" xml:space="preserve">
|
||||||
<value>Initializes the MU Helper settings when the player enters the world.</value>
|
<value>Initializes the MU Helper settings when the player enters the world.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ChatCommandListViewPlugIn_Name" xml:space="preserve">
|
||||||
|
<value>Chat command list view</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommandListViewPlugIn_Description" xml:space="preserve">
|
||||||
|
<value>Sends the chat commands which are available to the player, so that the client can offer them in a user interface.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommandGroupHandlerPlugIn_Name" xml:space="preserve">
|
||||||
|
<value>Chat command packet group handler</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommandGroupHandlerPlugIn_Description" xml:space="preserve">
|
||||||
|
<value>Handles the packets about chat commands (0xF5).</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommandListRequestHandlerPlugIn_Name" xml:space="preserve">
|
||||||
|
<value>Chat command list request handler</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommandListRequestHandlerPlugIn_Description" xml:space="preserve">
|
||||||
|
<value>Handles the request for the list of chat commands which are available to the player.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
100
src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs
Normal file
100
src/GameServer/RemoteView/ChatCommandListViewPlugIn.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// <copyright file="ChatCommandListViewPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default implementation of the <see cref="IChatCommandListViewPlugIn"/> which sends
|
||||||
|
/// one message per available chat command to the game client.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChatCommandListViewPlugIn"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="player">The player.</param>
|
||||||
|
public ChatCommandListViewPlugIn(RemotePlayer player) => this._player = player;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask ShowChatCommandListAsync(IReadOnlyCollection<ChatCommandInfo> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs
Normal file
91
tests/MUnique.OpenMU.Tests/ChatCommandPacketTest.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// <copyright file="ChatCommandPacketTest.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Tests;
|
||||||
|
|
||||||
|
using MUnique.OpenMU.Network.Packets.ServerToClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the packets which describe the chat commands.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class ChatCommandPacketTest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that the values of a command survive the way through the packet, so that
|
||||||
|
/// none of the fields overlaps another one.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that the size grows by exactly one parameter entry, so that the parameters
|
||||||
|
/// don't overlap the fields before them.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void ChatCommandInfoSizeDependsOnParameterCount()
|
||||||
|
{
|
||||||
|
var withoutParameters = ChatCommandInfoRef.GetRequiredSize(0);
|
||||||
|
var withOneParameter = ChatCommandInfoRef.GetRequiredSize(1);
|
||||||
|
|
||||||
|
Assert.That(withOneParameter - withoutParameters, Is.EqualTo(ChatCommandInfo.ChatCommandParameter.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user