diff --git a/docs-website/docs/admin-panel/plugins.md b/docs-website/docs/admin-panel/plugins.md
index 5e762d9857..628fbb7be5 100644
--- a/docs-website/docs/admin-panel/plugins.md
+++ b/docs-website/docs/admin-panel/plugins.md
@@ -107,6 +107,22 @@ It publishes:
Each kind of event can be switched off in the plugin configuration.
+## Server announcements
+
+The *Server announcements* plugin shows a list of announcements to all players,
+one after another, so that a game master doesn't have to type recurring ones
+like rule reminders or a link to the community. It's disabled by default.
+
+Its configuration has:
+
+* the *Interval* between one announcement and the next (at least 10 seconds),
+* the *Announcements*, each with its text and its message type, e.g. the golden
+ message in the centre of the screen or a blue system message.
+
+The announcements are shown in their order; after the last one, it starts over
+with the first one. Each game server has its own rotation. The text can be translated:
+players see the translation for their language, if there is one.
+
## Writing your own plugin
Implementing a plugin is documented next to the code, in the
diff --git a/src/GameLogic/PlugIns/ServerAnnouncementsConfiguration.cs b/src/GameLogic/PlugIns/ServerAnnouncementsConfiguration.cs
new file mode 100644
index 0000000000..01224654d9
--- /dev/null
+++ b/src/GameLogic/PlugIns/ServerAnnouncementsConfiguration.cs
@@ -0,0 +1,50 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using MUnique.OpenMU.DataModel.Composition;
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Configuration for the .
+///
+public class ServerAnnouncementsConfiguration
+{
+ ///
+ /// Gets or sets the interval between one announcement and the next.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ServerAnnouncementsConfiguration_Interval_Name), Description = nameof(PlugInResources.ServerAnnouncementsConfiguration_Interval_Description))]
+ public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(15);
+
+ ///
+ /// Gets or sets the announcements, which are shown one after another in their order.
+ /// After the last one, it starts over with the first one.
+ ///
+ [MemberOfAggregate]
+ [ScaffoldColumn(true)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ServerAnnouncementsConfiguration_Messages_Name), Description = nameof(PlugInResources.ServerAnnouncementsConfiguration_Messages_Description))]
+ public ICollection Messages { get; set; } = new List();
+
+ ///
+ /// One announcement of the rotation.
+ ///
+ public class Announcement
+ {
+ ///
+ /// Gets or sets the text of the announcement.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ServerAnnouncementsConfiguration_Text_Name))]
+ public LocalizedString Text { get; set; }
+
+ ///
+ /// Gets or sets how the announcement is shown.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ServerAnnouncementsConfiguration_Type_Name), Description = nameof(PlugInResources.ServerAnnouncementsConfiguration_Type_Description))]
+ public MessageType Type { get; set; } = MessageType.GoldenCenter;
+
+ ///
+ public override string ToString() => this.Text.ToString() ?? string.Empty;
+ }
+}
diff --git a/src/GameLogic/PlugIns/ServerAnnouncementsPlugIn.cs b/src/GameLogic/PlugIns/ServerAnnouncementsPlugIn.cs
new file mode 100644
index 0000000000..65c6c378d5
--- /dev/null
+++ b/src/GameLogic/PlugIns/ServerAnnouncementsPlugIn.cs
@@ -0,0 +1,134 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Shows a configured list of announcements to all players of a game server, one after another
+/// in a fixed interval, so that they don't have to be sent by a game master.
+///
+///
+/// Only one announcement is shown per interval, so they never overlap each other.
+/// The rotation doesn't coordinate with other announcements, like the ones of the events.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.ServerAnnouncementsPlugIn_Name), Description = nameof(PlugInResources.ServerAnnouncementsPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("2D6C9E4A-7B3E-4F8C-9E1D-8A4B6F0C2E7D")]
+public class ServerAnnouncementsPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault
+{
+ ///
+ /// The rotation state per game server. The plugin instance is shared between the
+ /// game servers of a process, but each of them has its own rotation.
+ ///
+ private readonly ConditionalWeakTable _states = new();
+
+ ///
+ /// The number of calls of . A rotation which didn't see the
+ /// current number yet shows its next announcement without waiting for the interval.
+ ///
+ private int _forceStartCount;
+
+ ///
+ public ServerAnnouncementsConfiguration? Configuration { get; set; }
+
+ ///
+ public async ValueTask ExecuteTaskAsync(GameContext gameContext)
+ {
+ if (this.Configuration is not { Messages.Count: > 0 } configuration)
+ {
+ return;
+ }
+
+ var state = this._states.GetValue(gameContext, _ => new RotationState());
+ if (state.TryGetNextAnnouncement(configuration, Volatile.Read(ref this._forceStartCount)) is not { } announcement)
+ {
+ return;
+ }
+
+ await gameContext.ForEachPlayerAsync(player =>
+ {
+ var text = announcement.Text.GetTranslation(player.Culture);
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return Task.CompletedTask;
+ }
+
+ return player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, announcement.Type)).AsTask();
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ /// Shows the next announcement of each game server without waiting for the interval.
+ ///
+ public void ForceStart()
+ {
+ Interlocked.Increment(ref this._forceStartCount);
+ }
+
+ ///
+ public object CreateDefaultConfig()
+ {
+ return new ServerAnnouncementsConfiguration
+ {
+ Messages =
+ {
+ new() { Text = "Our team will never ask you for your password." },
+ },
+ };
+ }
+
+ ///
+ /// The state of the rotation of a game server.
+ ///
+ private sealed class RotationState
+ {
+ ///
+ /// The minimum interval between two announcements. It protects the players from
+ /// getting an announcement every second when the configured interval is zero.
+ ///
+ private static readonly TimeSpan MinimumInterval = TimeSpan.FromSeconds(10);
+
+ private readonly Lock _lock = new();
+
+ private int _nextIndex;
+
+ private DateTime _nextRunUtc;
+
+ private int _handledForceStartCount;
+
+ ///
+ /// Gets the announcement which is due, and advances the rotation.
+ ///
+ /// The configuration.
+ /// The current number of forced starts.
+ /// The due announcement, or null, if the interval isn't over yet.
+ public ServerAnnouncementsConfiguration.Announcement? TryGetNextAnnouncement(ServerAnnouncementsConfiguration configuration, int forceStartCount)
+ {
+ lock (this._lock)
+ {
+ var now = DateTime.UtcNow;
+ if (now < this._nextRunUtc && forceStartCount == this._handledForceStartCount)
+ {
+ return null;
+ }
+
+ this._handledForceStartCount = forceStartCount;
+
+ // The configuration may have changed since the last announcement, so the index is wrapped here.
+ var messages = configuration.Messages;
+ var announcement = messages.ElementAt(this._nextIndex % messages.Count);
+ this._nextIndex = (this._nextIndex + 1) % messages.Count;
+ this._nextRunUtc = now + (configuration.Interval > MinimumInterval ? configuration.Interval : MinimumInterval);
+ return announcement;
+ }
+ }
+ }
+}
diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs
index 8065227541..b8db4ab812 100644
--- a/src/GameLogic/Properties/PlugInResources.Designer.cs
+++ b/src/GameLogic/Properties/PlugInResources.Designer.cs
@@ -2897,6 +2897,87 @@ public static string SelfDefensePlugInConfiguration_SelfDefenseTimeOut_Name {
}
}
+ ///
+ /// Looks up a localized string similar to The time between one announcement and the next. The minimum is 10 seconds..
+ ///
+ public static string ServerAnnouncementsConfiguration_Interval_Description {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Interval_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Interval.
+ ///
+ public static string ServerAnnouncementsConfiguration_Interval_Name {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Interval_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The announcements, in the order in which they are shown. After the last one, it starts over with the first one..
+ ///
+ public static string ServerAnnouncementsConfiguration_Messages_Description {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Messages_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Announcements.
+ ///
+ public static string ServerAnnouncementsConfiguration_Messages_Name {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Messages_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Text.
+ ///
+ public static string ServerAnnouncementsConfiguration_Text_Name {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Text_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How the announcement is shown, e.g. as golden message in the center of the screen or as blue system message..
+ ///
+ public static string ServerAnnouncementsConfiguration_Type_Description {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Type_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Message Type.
+ ///
+ public static string ServerAnnouncementsConfiguration_Type_Name {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsConfiguration_Type_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Shows a configured list of announcements to all players of a game server, one after another in a fixed interval..
+ ///
+ public static string ServerAnnouncementsPlugIn_Description {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Server announcements.
+ ///
+ public static string ServerAnnouncementsPlugIn_Name {
+ get {
+ return ResourceManager.GetString("ServerAnnouncementsPlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Sets level of a player. Usage: /setlevel (level) (optional:character).
///
diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx
index 3d97f68bc2..d17feaea24 100644
--- a/src/GameLogic/Properties/PlugInResources.de.resx
+++ b/src/GameLogic/Properties/PlugInResources.de.resx
@@ -1833,4 +1833,31 @@
Legt den Preis eines Gegenstands des eigenen persönlichen Ladens in einer der aktivierten Währungen fest, z. B. '/shopprice 1 10 bless'. Ohne Argumente werden die Preise aufgelistet.
+
+ Server-Ankündigungen
+
+
+ Zeigt allen Spielern eines Spielservers eine konfigurierte Liste von Ankündigungen, eine nach der anderen in einem festen Intervall.
+
+
+ Intervall
+
+
+ Die Zeit zwischen einer Ankündigung und der nächsten. Das Minimum sind 10 Sekunden.
+
+
+ Ankündigungen
+
+
+ Die Ankündigungen in der Reihenfolge, in der sie gezeigt werden. Nach der letzten beginnt es wieder mit der ersten.
+
+
+ Text
+
+
+ Nachrichtentyp
+
+
+ Wie die Ankündigung gezeigt wird, z. B. als goldene Nachricht in der Bildschirmmitte oder als blaue Systemnachricht.
+
diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx
index 3db454069a..395b4fe6ee 100644
--- a/src/GameLogic/Properties/PlugInResources.resx
+++ b/src/GameLogic/Properties/PlugInResources.resx
@@ -1634,4 +1634,31 @@
Sets the price of an item of the own personal store in one of the enabled currencies, e.g. '/shopprice 1 10 bless'. Without arguments, it lists the prices.
+
+ Server announcements
+
+
+ Shows a configured list of announcements to all players of a game server, one after another in a fixed interval.
+
+
+ Interval
+
+
+ The time between one announcement and the next. The minimum is 10 seconds.
+
+
+ Announcements
+
+
+ The announcements, in the order in which they are shown. After the last one, it starts over with the first one.
+
+
+ Text
+
+
+ Message Type
+
+
+ How the announcement is shown, e.g. as golden message in the center of the screen or as blue system message.
+
diff --git a/tests/MUnique.OpenMU.Tests/ServerAnnouncementsPlugInTest.cs b/tests/MUnique.OpenMU.Tests/ServerAnnouncementsPlugInTest.cs
new file mode 100644
index 0000000000..73ee5d2db0
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/ServerAnnouncementsPlugInTest.cs
@@ -0,0 +1,176 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.Globalization;
+using Moq;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class ServerAnnouncementsPlugInTest
+{
+ ///
+ /// Tests that the first announcement is shown right away, and that the next one waits for the interval.
+ ///
+ [Test]
+ public async Task ShowsOneAnnouncementPerIntervalAsync()
+ {
+ var player = await CreatePlayerAsync().ConfigureAwait(false);
+ var plugIn = CreatePlugIn(TimeSpan.FromHours(1), ("First", MessageType.GoldenCenter), ("Second", MessageType.BlueNormal));
+
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+
+ var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!);
+ view.Verify(p => p.ShowMessageAsync("First", MessageType.GoldenCenter), Times.Once);
+ view.Verify(p => p.ShowMessageAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ ///
+ /// Tests that the announcements are shown in their order, and that the rotation starts over after the last one.
+ ///
+ [Test]
+ public async Task RotatesThroughTheAnnouncementsAsync()
+ {
+ var player = await CreatePlayerAsync().ConfigureAwait(false);
+ var plugIn = CreatePlugIn(TimeSpan.FromHours(1), ("First", MessageType.GoldenCenter), ("Second", MessageType.BlueNormal));
+ var shown = new List();
+ var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!);
+ view.Setup(p => p.ShowMessageAsync(It.IsAny(), It.IsAny()))
+ .Callback((string message, MessageType _) => shown.Add(message))
+ .Returns(ValueTask.CompletedTask);
+
+ for (var i = 0; i < 3; i++)
+ {
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+ plugIn.ForceStart();
+ }
+
+ Assert.That(shown, Is.EqualTo(new[] { "First", "Second", "First" }));
+ }
+
+ ///
+ /// Tests that an interval of zero doesn't show an announcement on every run.
+ ///
+ [Test]
+ public async Task ZeroIntervalDoesNotShowAnAnnouncementOnEveryRunAsync()
+ {
+ var player = await CreatePlayerAsync().ConfigureAwait(false);
+ var plugIn = CreatePlugIn(TimeSpan.Zero, ("First", MessageType.GoldenCenter), ("Second", MessageType.BlueNormal));
+
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+
+ Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(p => p.ShowMessageAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ ///
+ /// Tests that the text is shown in the language of the player, if it has a translation.
+ ///
+ [Test]
+ public async Task ShowsTheTranslationOfThePlayerAsync()
+ {
+ var player = await CreatePlayerAsync().ConfigureAwait(false);
+ player.Culture = CultureInfo.GetCultureInfo("de");
+ var text = new LocalizedString("Hello").WithTranslation(CultureInfo.GetCultureInfo("de"), "Hallo");
+ var plugIn = new ServerAnnouncementsPlugIn
+ {
+ Configuration = new ServerAnnouncementsConfiguration
+ {
+ Messages = { new() { Text = text, Type = MessageType.BlueNormal } },
+ },
+ };
+
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+
+ Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(p => p.ShowMessageAsync("Hallo", MessageType.BlueNormal), Times.Once);
+ }
+
+ ///
+ /// Tests that nothing is shown without announcements.
+ ///
+ [Test]
+ public async Task ShowsNothingWithoutAnnouncementsAsync()
+ {
+ var player = await CreatePlayerAsync().ConfigureAwait(false);
+ var plugIn = new ServerAnnouncementsPlugIn { Configuration = new ServerAnnouncementsConfiguration() };
+
+ await plugIn.ExecuteTaskAsync((GameContext)player.GameContext).ConfigureAwait(false);
+
+ Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(p => p.ShowMessageAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Tests that the configuration, including the translations of the texts, survives a plugin configuration round trip
+ /// with the JSON converters which the server registers at startup.
+ ///
+ [Test]
+ public void ConfigurationRoundTripsThroughPlugInConfiguration()
+ {
+ var registerConverter = !JsonConverterRegistry.Converters.OfType().Any();
+ if (registerConverter)
+ {
+ JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter());
+ }
+
+ try
+ {
+ AssertConfigurationRoundTrip();
+ }
+ finally
+ {
+ if (registerConverter)
+ {
+ JsonConverterRegistry.ClearConverters();
+ }
+ }
+ }
+
+ private static void AssertConfigurationRoundTrip()
+ {
+ var plugInConfiguration = new PlugInConfiguration();
+ var configuration = new ServerAnnouncementsConfiguration { Interval = TimeSpan.FromMinutes(5) };
+ configuration.Messages.Add(new() { Text = new LocalizedString("Hello").WithTranslation(CultureInfo.GetCultureInfo("de"), "Hallo"), Type = MessageType.BlueNormal });
+
+ plugInConfiguration.SetConfiguration(configuration, null);
+ var restored = plugInConfiguration.GetConfiguration(null);
+
+ Assert.That(restored, Is.Not.Null);
+ var announcement = restored!.Messages.Single();
+ Assert.Multiple(() =>
+ {
+ Assert.That(restored.Interval, Is.EqualTo(TimeSpan.FromMinutes(5)));
+ Assert.That(announcement.Type, Is.EqualTo(MessageType.BlueNormal));
+ Assert.That(announcement.Text.GetTranslation(CultureInfo.InvariantCulture), Is.EqualTo("Hello"));
+ Assert.That(announcement.Text.GetTranslation(CultureInfo.GetCultureInfo("de")), Is.EqualTo("Hallo"));
+ });
+ }
+
+ private static ServerAnnouncementsPlugIn CreatePlugIn(TimeSpan interval, params (string Text, MessageType Type)[] announcements)
+ {
+ var configuration = new ServerAnnouncementsConfiguration { Interval = interval };
+ foreach (var (text, type) in announcements)
+ {
+ configuration.Messages.Add(new() { Text = text, Type = type });
+ }
+
+ return new ServerAnnouncementsPlugIn { Configuration = configuration };
+ }
+
+ private static async ValueTask CreatePlayerAsync()
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
+ await player.GameContext.AddPlayerAsync(player).ConfigureAwait(false);
+ return player;
+ }
+}