From 53f499737ee4207ab82e38f265b989b2f78c4f88 Mon Sep 17 00:00:00 2001 From: Diego Amatte Date: Sat, 17 Oct 2026 22:47:35 +0000 Subject: [PATCH 1/2] feat(messages): add slide notices which scroll across the top of the screen Since season 1, the client scrolls 0x0D notices of the types 10 to 15 through the help and notice bands at the top of the screen, but OpenMU only sent the types 0 to 2. The new SlideNotice packet defines the fields the client reads for them (loop count, loop delay, color, speed). MessageType.SlideNotice is sent as SlideNotice packet of type 14 (queued in the notice band, shown once). Clients before season 1 get a golden notice instead. It can be sent with the new GM command '/slidenotice ' and from the admin panel, whose global message now has a message type selector. Split out of #995; the packet is now defined in the XML instead of being written by hand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uz47mrsSwm3hxZc7gCMxd9 --- docs-website/docs/admin-panel/servers.md | 13 +- docs/Packets/C1-0D-SlideNotice_by-server.md | 23 +++ docs/Packets/ServerToClient.md | 1 + .../SlideNoticeChatCommandPlugIn.cs | 41 ++++++ .../Properties/PlugInResources.Designer.cs | 18 +++ src/GameLogic/Properties/PlugInResources.resx | 6 + .../RemoteView/ShowMessagePlugIn.cs | 27 +++- src/Interfaces/IGameServer.cs | 5 + .../ServerToClient/ConnectionExtensions.cs | 38 +++++ .../ServerToClient/ServerToClientPackets.cs | 133 ++++++++++++++++++ .../ServerToClient/ServerToClientPackets.xml | 47 +++++++ .../ServerToClientPacketsRef.cs | 133 ++++++++++++++++++ src/Web/AdminPanel/Pages/Servers.razor | 16 ++- .../Properties/Resources.Designer.cs | 36 +++++ src/Web/AdminPanel/Properties/Resources.resx | 12 ++ .../ServerToClientPacketTests.cs | 16 +++ .../CastleSiegeRemoteViewTestHelper.cs | 11 +- .../ShowMessagePlugInTests.cs | 61 ++++++++ 18 files changed, 625 insertions(+), 12 deletions(-) create mode 100644 docs/Packets/C1-0D-SlideNotice_by-server.md create mode 100644 src/GameLogic/PlugIns/ChatCommands/SlideNoticeChatCommandPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/ShowMessagePlugInTests.cs diff --git a/docs-website/docs/admin-panel/servers.md b/docs-website/docs/admin-panel/servers.md index 31ddd46565..a1413937ff 100644 --- a/docs-website/docs/admin-panel/servers.md +++ b/docs-website/docs/admin-panel/servers.md @@ -128,12 +128,17 @@ forward it in your router or docker compose file. See [Ports](../reference/ports ## Global messages -The card at the top of the page sends a message to the players — the golden -message in the centre of the screen. +The card at the top of the page sends a message to the players. 1. Type the text. -2. Choose the target: *All game servers*, or one specific started game server. -3. Click **Send**. +2. Choose the message type: + - *Golden (center)*: the golden message in the centre of the screen, like `/goldnotice`. + - *Blue (system)*: a blue system message in the chat. + - *Slide (top band)*: a notice which scrolls once through the band at the top + of the screen, like `/slidenotice`. Clients older than season 1 don't have this + band and get the golden message instead. +3. Choose the target: *All game servers*, or one specific started game server. +4. Click **Send**. Only started game servers can be targeted. If the target stopped in the meantime, the panel keeps your text so you can pick another target and retry. diff --git a/docs/Packets/C1-0D-SlideNotice_by-server.md b/docs/Packets/C1-0D-SlideNotice_by-server.md new file mode 100644 index 0000000000..c928408cf7 --- /dev/null +++ b/docs/Packets/C1-0D-SlideNotice_by-server.md @@ -0,0 +1,23 @@ +# C1 0D - SlideNotice (by server) + +## Is sent when + +A game master or the server sends a notice which should scroll across the top of the screen. + +## Causes the following actions on the client side + +The client scrolls the message through the band at the top of the screen, as often as the loop count says. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | | Packet header - length of the packet | +| 2 | 1 | Byte | 0x0D | Packet header - packet type identifier | +| 3 | 1 | Byte | 14 | Type; The band and queueing of the message. 10 to 12 use the help band, 13 to 15 the notice band. Within a band, the first value drops the message when it could not be shown within a minute, the second one queues it, and the third one puts it in front of the queue, ignoring the loop delay. | +| 4 | 1 | Byte | 1 | LoopCount; How often the message is shown. The client ignores messages with more than 30 loops. | +| 6 | 2 | ShortLittleEndian | | LoopDelay; The delay between the loops, in seconds. | +| 8 | 4 | IntegerLittleEndian | | Color; The color of the text, as 0xAABBGGRR. | +| 12 | 1 | Byte | | Speed; The scroll speed, multiplied by 10. 0 means the default speed. | +| 13 | | String | | Message | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 2805673cb1..72747d44be 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -5,6 +5,7 @@ * [C1 07 - MagicEffectStatus (by server)](C1-07-MagicEffectStatus_by-server.md) * [C1 0B - MapEventState (by server)](C1-0B-MapEventState_by-server.md) * [C1 0D - ServerMessage (by server)](C1-0D-ServerMessage_by-server.md) + * [C1 0D - SlideNotice (by server)](C1-0D-SlideNotice_by-server.md) * [C1 0F - WeatherStatusUpdate (by server)](C1-0F-WeatherStatusUpdate_by-server.md) * [C1 10 - ObjectWalked075 (by server)](C1-10-ObjectWalked075_by-server.md) * [C1 11 - ObjectHit (by server)](C1-11-ObjectHit_by-server.md) diff --git a/src/GameLogic/PlugIns/ChatCommands/SlideNoticeChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SlideNoticeChatCommandPlugIn.cs new file mode 100644 index 0000000000..d6b7ede1f4 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/SlideNoticeChatCommandPlugIn.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using MUnique.OpenMU.PlugIns; + +/// +/// A chat command plugin which sends a notice to all players that scrolls across the top of their screen. +/// +[Guid("6F0B8F37-3C1D-4E5A-9B7E-2D4C8A1F5E93")] +[PlugIn] +[Display(Name = nameof(PlugInResources.SlideNoticeChatCommandPlugIn_Name), Description = nameof(PlugInResources.SlideNoticeChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] +[ChatCommandHelp(CommandKey, CharacterStatus.GameMaster)] +public class SlideNoticeChatCommandPlugIn : IChatCommandPlugIn +{ + private const string CommandKey = "/slidenotice"; + + /// + public string Key => CommandKey; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var regex = new Regex(Regex.Escape(CommandKey)); + var message = regex.Replace(command, string.Empty, 1)?.Trim(); + + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + await player.GameContext.SendGlobalMessageAsync(message, Interfaces.MessageType.SlideNotice).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 8065227541..8a9eaf60fc 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3221,6 +3221,24 @@ public static string SkipMiniGameWaitChatCommandPlugIn_Name { } } + /// + /// Looks up a localized string similar to Handles the chat command '/slidenotice message'. Sends a global notice to all players of the game which scrolls across the top of their screen.. + /// + public static string SlideNoticeChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("SlideNoticeChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Slide notice chat command. + /// + public static string SlideNoticeChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("SlideNoticeChatCommandPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Plugin which handles the small complex potion consumption.. /// diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 3db454069a..46277d16f7 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -651,6 +651,12 @@ Handles the chat command '/goldnotice message'. Sends a global golden notice message to all players of the game. + + Slide notice chat command + + + Handles the chat command '/slidenotice message'. Sends a global notice to all players of the game which scrolls across the top of their screen. + NPC open merchant chat command diff --git a/src/GameServer/RemoteView/ShowMessagePlugIn.cs b/src/GameServer/RemoteView/ShowMessagePlugIn.cs index dc9775d5c4..0d89225652 100644 --- a/src/GameServer/RemoteView/ShowMessagePlugIn.cs +++ b/src/GameServer/RemoteView/ShowMessagePlugIn.cs @@ -48,6 +48,18 @@ public async ValueTask ShowMessageAsync(string message, OpenMU.Interfaces.Messag return; } + if (messageType == Interfaces.MessageType.SlideNotice) + { + if (this._player.ClientVersion.Season > 0) + { + await this.SendSlideNoticeAsync(message).ConfigureAwait(false); + return; + } + + // Clients before season 1 don't know the slide notice. + messageType = Interfaces.MessageType.GoldenCenter; + } + const string messagePrefix = "000000000"; var content = this._player.ClientVersion.Season > 0 ? messagePrefix + message : message; await this._player.Connection.SendServerMessageAsync(ConvertMessageType(messageType), content).ConfigureAwait(false); @@ -63,4 +75,17 @@ private static ServerMessage.MessageType ConvertMessageType(OpenMU.Interfaces.Me _ => throw new NotImplementedException($"Case for {messageType} is not implemented."), }; } -} \ No newline at end of file + + /// + /// Sends a notice which the client scrolls through the notice band at the top of the screen. + /// + /// The message. + private ValueTask SendSlideNoticeAsync(string message) + { + // White text at the default speed of the client. The type and the loop count keep + // the defaults of the packet: queued in the notice band, shown once. + const uint white = 0xFFFFFFFF; + const byte defaultSpeed = 0; + return this._player.Connection.SendSlideNoticeAsync(loopDelay: 0, white, defaultSpeed, message); + } +} diff --git a/src/Interfaces/IGameServer.cs b/src/Interfaces/IGameServer.cs index 83a075cd10..f442296cd2 100644 --- a/src/Interfaces/IGameServer.cs +++ b/src/Interfaces/IGameServer.cs @@ -54,6 +54,11 @@ public enum MessageType /// The message is a guild notice (green center). /// GuildNotice = 2, + + /// + /// The message scrolls in bold across the top of the screen (slide notice). + /// + SlideNotice = 3, } /// diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 5728b45871..7b3ad73a82 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -4135,6 +4135,44 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The delay between the loops, in seconds. + /// The color of the text, as 0xAABBGGRR. + /// The scroll speed, multiplied by 10. 0 means the default speed. + /// The message. + /// How often the message is shown. The client ignores messages with more than 30 loops. + /// The band and queueing of the message. 10 to 12 use the help band, 13 to 15 the notice band. Within a band, the first value drops the message when it could not be shown within a minute, the second one queues it, and the third one puts it in front of the queue, ignoring the loop delay. + /// + /// Is sent by the server when: A game master or the server sends a notice which should scroll across the top of the screen. + /// Causes reaction on client side: The client scrolls the message through the band at the top of the screen, as often as the loop count says. + /// + public static async ValueTask SendSlideNoticeAsync(this IConnection? connection, ushort @loopDelay, uint @color, byte @speed, string @message, byte @loopCount = 1, byte @type = 14) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = SlideNoticeRef.GetRequiredSize(message); + var packet = new SlideNoticeRef(connection.Output.GetSpan(length)[..length]); + packet.Type = @type; + packet.LoopCount = @loopCount; + packet.LoopDelay = @loopDelay; + packet.Color = @color; + packet.Speed = @speed; + packet.Message = @message; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index c3e544c052..861d6223b1 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -20252,6 +20252,139 @@ public string Message } +/// +/// Is sent by the server when: A game master or the server sends a notice which should scroll across the top of the screen. +/// Causes reaction on client side: The client scrolls the message through the band at the top of the screen, as often as the loop count says. +/// +public readonly struct SlideNotice +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SlideNotice(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private SlideNotice(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)data.Length; + this.Type = 14; + this.LoopCount = 1; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x0D; + + /// + /// Gets the header of this packet. + /// + public C1Header Header => new (this._data); + + /// + /// Gets or sets the band and queueing of the message. 10 to 12 use the help band, 13 to 15 the notice band. Within a band, the first value drops the message when it could not be shown within a minute, the second one queues it, and the third one puts it in front of the queue, ignoring the loop delay. + /// + public byte Type + { + get => this._data.Span[3]; + set => this._data.Span[3] = value; + } + + /// + /// Gets or sets how often the message is shown. The client ignores messages with more than 30 loops. + /// + public byte LoopCount + { + get => this._data.Span[4]; + set => this._data.Span[4] = value; + } + + /// + /// Gets or sets the delay between the loops, in seconds. + /// + public ushort LoopDelay + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets or sets the color of the text, as 0xAABBGGRR. + /// + public uint Color + { + get => ReadUInt32LittleEndian(this._data.Span[8..]); + set => WriteUInt32LittleEndian(this._data.Span[8..], value); + } + + /// + /// Gets or sets the scroll speed, multiplied by 10. 0 means the default speed. + /// + public byte Speed + { + get => this._data.Span[12]; + set => this._data.Span[12] = value; + } + + /// + /// Gets or sets the message. + /// + public string Message + { + get => this._data.Span.ExtractString(13, this._data.Length - 13, System.Text.Encoding.UTF8); + set => this._data.Slice(13).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator SlideNotice(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(SlideNotice packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 13; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content length in bytes of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(int contentLength) => contentLength + 1 + 13; +} + + /// /// Is sent by the server when: A player requested to join a guild. This message is sent then to the guild master. /// Causes reaction on client side: The guild master gets a message box with the request popping up. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 590cb7fe3c..2bbd26b766 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -7433,6 +7433,53 @@ + + C1Header + 0D + SlideNotice + ServerToClient + A game master or the server sends a notice which should scroll across the top of the screen. + The client scrolls the message through the band at the top of the screen, as often as the loop count says. + + + 3 + Byte + Type + The band and queueing of the message. 10 to 12 use the help band, 13 to 15 the notice band. Within a band, the first value drops the message when it could not be shown within a minute, the second one queues it, and the third one puts it in front of the queue, ignoring the loop delay. + 14 + + + 4 + Byte + LoopCount + How often the message is shown. The client ignores messages with more than 30 loops. + 1 + + + 6 + ShortLittleEndian + LoopDelay + The delay between the loops, in seconds. + + + 8 + IntegerLittleEndian + Color + The color of the text, as 0xAABBGGRR. + + + 12 + Byte + Speed + The scroll speed, multiplied by 10. 0 means the default speed. + + + 13 + String + Message + + + C1Header 50 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index e5051fc3e8..a00ade05b3 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -19374,6 +19374,139 @@ public string Message } +/// +/// Is sent by the server when: A game master or the server sends a notice which should scroll across the top of the screen. +/// Causes reaction on client side: The client scrolls the message through the band at the top of the screen, as often as the loop count says. +/// +public readonly ref struct SlideNoticeRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SlideNoticeRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private SlideNoticeRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)data.Length; + this.Type = 14; + this.LoopCount = 1; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x0D; + + /// + /// Gets the header of this packet. + /// + public C1HeaderRef Header => new (this._data); + + /// + /// Gets or sets the band and queueing of the message. 10 to 12 use the help band, 13 to 15 the notice band. Within a band, the first value drops the message when it could not be shown within a minute, the second one queues it, and the third one puts it in front of the queue, ignoring the loop delay. + /// + public byte Type + { + get => this._data[3]; + set => this._data[3] = value; + } + + /// + /// Gets or sets how often the message is shown. The client ignores messages with more than 30 loops. + /// + public byte LoopCount + { + get => this._data[4]; + set => this._data[4] = value; + } + + /// + /// Gets or sets the delay between the loops, in seconds. + /// + public ushort LoopDelay + { + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); + } + + /// + /// Gets or sets the color of the text, as 0xAABBGGRR. + /// + public uint Color + { + get => ReadUInt32LittleEndian(this._data[8..]); + set => WriteUInt32LittleEndian(this._data[8..], value); + } + + /// + /// Gets or sets the scroll speed, multiplied by 10. 0 means the default speed. + /// + public byte Speed + { + get => this._data[12]; + set => this._data[12] = value; + } + + /// + /// Gets or sets the message. + /// + public string Message + { + get => this._data.ExtractString(13, this._data.Length - 13, System.Text.Encoding.UTF8); + set => this._data.Slice(13).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator SlideNoticeRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(SlideNoticeRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 13; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content length in bytes of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(int contentLength) => contentLength + 1 + 13; +} + + /// /// Is sent by the server when: A player requested to join a guild. This message is sent then to the guild master. /// Causes reaction on client side: The guild master gets a message box with the request popping up. diff --git a/src/Web/AdminPanel/Pages/Servers.razor b/src/Web/AdminPanel/Pages/Servers.razor index 595ca2dd66..4e65234a86 100644 --- a/src/Web/AdminPanel/Pages/Servers.razor +++ b/src/Web/AdminPanel/Pages/Servers.razor @@ -22,11 +22,19 @@ else
-
+
-
+
+ + +
+