From a88fa73fd7bdb15d4f162c468cac98dd3741dbf6 Mon Sep 17 00:00:00 2001 From: Marcelo Oviedo Date: Sat, 17 Oct 2026 23:00:39 +0000 Subject: [PATCH] feat(plugins): add a plugin which rotates server announcements Shows a configured list of announcements to all players of a game server, one after another in a fixed interval, so that a game master doesn't have to type recurring ones. Disabled by default. Split out of #995 and reworked: the texts are LocalizedStrings which are shown in the language of each player, the plugin and its configuration use PlugInResources (with German translations), the rotation state lives in the plugin instance per game context instead of a static dictionary, the interval has a lower bound, and ForceStart shows the next announcement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uz47mrsSwm3hxZc7gCMxd9 --- docs-website/docs/admin-panel/plugins.md | 16 ++ .../ServerAnnouncementsConfiguration.cs | 50 +++++ .../PlugIns/ServerAnnouncementsPlugIn.cs | 134 +++++++++++++ .../Properties/PlugInResources.Designer.cs | 81 ++++++++ .../Properties/PlugInResources.de.resx | 27 +++ src/GameLogic/Properties/PlugInResources.resx | 27 +++ .../ServerAnnouncementsPlugInTest.cs | 176 ++++++++++++++++++ 7 files changed, 511 insertions(+) create mode 100644 src/GameLogic/PlugIns/ServerAnnouncementsConfiguration.cs create mode 100644 src/GameLogic/PlugIns/ServerAnnouncementsPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/ServerAnnouncementsPlugInTest.cs 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; + } +}