diff --git a/docs-website/docs/admin-panel/servers.md b/docs-website/docs/admin-panel/servers.md
index 31ddd46565..a4a05ce79a 100644
--- a/docs-website/docs/admin-panel/servers.md
+++ b/docs-website/docs/admin-panel/servers.md
@@ -128,12 +128,18 @@ 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
+
+
+ C1Header50
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