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; + } +}