From 66cb48548c0de80cc2f434329a59e89b6c674e6f Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 13:37:05 +0100 Subject: [PATCH 01/44] feat(bots): let bots talk with configurable lines Adds the "Bot chat" plugin (disabled by default). Bots announce opened Blood Castle, Devil Square and Chaos Castle entrances and started invasions with /post, comment on level milestones in the normal chat and answer whispers of players - with lines from the plugin configuration, no language model. A bot only posts about what it could do: an event only when it meets the entry requirements (BotMiniGameHandler.IsEligible), an invasion only when it may warp to the invaded map. A server-wide gap between posts, one announcement per event opening and per-bot cooldowns keep a large population from flooding the chat. Both post chances default to 0, and bots post only while the /post command is enabled. Lines are localized strings rendered per receiver, like the server announcements. The /post format is shared with PostChatCommandPlugIn. --- docs-website/docs/server-features/bots.md | 26 ++ src/GameLogic/Bots/BotChatComposer.cs | 62 ++++ src/GameLogic/Bots/BotChatConfiguration.cs | 136 ++++++++ src/GameLogic/Bots/BotChatLine.cs | 34 ++ src/GameLogic/Bots/BotChatPlugIn.cs | 303 ++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 39 +++ src/GameLogic/Bots/BotChatSpeaker.cs | 91 ++++++ src/GameLogic/Bots/BotChatThrottle.cs | 123 +++++++ .../ChatCommands/PostChatCommandPlugIn.cs | 16 +- .../Properties/PlugInResources.Designer.cs | 78 +++++ src/GameLogic/Properties/PlugInResources.resx | 26 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 144 +++++++++ 12 files changed, 1076 insertions(+), 2 deletions(-) create mode 100644 src/GameLogic/Bots/BotChatComposer.cs create mode 100644 src/GameLogic/Bots/BotChatConfiguration.cs create mode 100644 src/GameLogic/Bots/BotChatLine.cs create mode 100644 src/GameLogic/Bots/BotChatPlugIn.cs create mode 100644 src/GameLogic/Bots/BotChatSituation.cs create mode 100644 src/GameLogic/Bots/BotChatSpeaker.cs create mode 100644 src/GameLogic/Bots/BotChatThrottle.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotChatTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 3556b7418a..e982325e2d 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -331,6 +331,32 @@ Over the day, the presence rotation logs bots in and out: fewest in the early morning, most in the evening, and never more than one at a time, so the population ebbs and flows instead of appearing and vanishing in blocks. +### Talking + +With the separate *Bot chat* plugin (off by default), bots say a line now and +then. The lines come from the plugin's configuration; there is no language +model. A bot only talks about what it could actually do: + +| Situation | Where | Who says it | +|---|---|---| +| A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post` | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | +| An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | +| A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | +| A player whispered to the bot | whisper back, after a few seconds | the bot | + +Placeholders in a line are filled in when it is said: `{event}`, `{map}`, +`{level}`, and `{player}` (whisper replies). Each line is a localized text, so +every player sees the translation of their own language, or the English text +when it has none. + +The limits keep a large population from flooding the chat. All bots of a game +server share one gap between two posts (`Minimum time between posts`, 3 minutes), +each opened event type or started invasion is announced at most once, and each +bot has its own cooldown for the normal chat and for whisper replies. The two +post chances are 30 % by default, so not every opened event or invasion gets a +post. Bots post only while the `/post` command is enabled on the server, and +their messages look exactly like a player's post. + ### Keeping itself alive The engine's attribute system is not thread-safe, and a lost race can corrupt a diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs new file mode 100644 index 0000000000..516b67b124 --- /dev/null +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -0,0 +1,62 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +using System.Text; + +/// +/// Chooses the line a bot says and renders it for one receiver: the translation of the receiver's +/// language, with the placeholders filled in. Pure logic - exposed for unit tests. +/// +internal static class BotChatComposer +{ + /// + /// Picks a random line of the situation. + /// + /// All configured lines. + /// The situation. + /// Returns a random index below the given count. + /// The picked line, or null when the situation has no usable line. + internal static BotChatLine? PickLine(IEnumerable lines, BotChatSituation situation, Func pickIndex) + { + var candidates = lines + .Where(line => line.Situation == situation && !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) + .ToList(); + return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; + } + + /// + /// Renders the line for a receiver of the given culture. + /// + /// The line. + /// The culture of the receiving player. + /// The placeholder values by name (without braces), each resolved for the culture. + /// The text to send. + /// + /// Unknown placeholders are left as they are, so a typo in a configured line is visible in the + /// game instead of silently swallowing text. + /// + internal static string Render(BotChatLine line, CultureInfo culture, IReadOnlyDictionary> values) + { + var template = line.Text.GetTranslation(culture) ?? string.Empty; + if (!template.Contains('{', StringComparison.Ordinal)) + { + return template; + } + + var result = new StringBuilder(template); + foreach (var (name, value) in values) + { + var placeholder = "{" + name + "}"; + if (template.Contains(placeholder, StringComparison.Ordinal)) + { + result.Replace(placeholder, value(culture)); + } + } + + return result.ToString(); + } +} diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs new file mode 100644 index 0000000000..30971c2e4f --- /dev/null +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -0,0 +1,136 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Composition; + +/// +/// Configuration of the : what the server-side bots say, and how often. +/// +/// +/// The limits are what keeps a few hundred bots from flooding the chat. The /post messages +/// reach the whole server, so they are the rarest: one server-wide gap +/// () applies to all bots together, and each opened event or +/// started invasion is announced at most once. Both post chances are 30 % by default, so not every +/// opened event or invasion gets a post. +/// +public class BotChatConfiguration +{ + /// + /// Gets or sets the chance, in percent, that a bot announces with /post that the entrance + /// of a Blood Castle, Devil Square or Chaos Castle opened. Rolled once per event type and + /// opening, not per event level. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventPostChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventPostChancePercent_Description))] + public int EventPostChancePercent { get; set; } = 30; + + /// + /// Gets or sets the chance, in percent, that a bot announces a started invasion with /post. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_InvasionPostChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_InvasionPostChancePercent_Description))] + public int InvasionPostChancePercent { get; set; } = 30; + + /// + /// Gets or sets the minimum time between two /post messages of bots on one game server. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MinTimeBetweenPosts_Name), Description = nameof(PlugInResources.BotChatConfiguration_MinTimeBetweenPosts_Description))] + public TimeSpan MinTimeBetweenPosts { get; set; } = TimeSpan.FromMinutes(3); + + /// + /// Gets or sets the longest time a bot waits before it posts about an event. The actual delay is + /// random up to this value, so the post doesn't arrive in the same second as the event's own + /// announcement. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MaxPostDelay_Name), Description = nameof(PlugInResources.BotChatConfiguration_MaxPostDelay_Description))] + public TimeSpan MaxPostDelay { get; set; } = TimeSpan.FromSeconds(90); + + /// + /// Gets or sets every how many levels a bot may comment on its level-up; 0 switches it off. + /// + [Range(0, 1000)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneStep_Name), Description = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneStep_Description))] + public int LevelMilestoneStep { get; set; } = 50; + + /// + /// Gets or sets the chance, in percent, that a bot comments on reaching a level milestone. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneChancePercent_Description))] + public int LevelMilestoneChancePercent { get; set; } = 25; + + /// + /// Gets or sets the minimum time between two normal chat lines of the same bot. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_BotChatCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_BotChatCooldown_Description))] + public TimeSpan BotChatCooldown { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Gets or sets the chance, in percent, that a bot answers a whisper of a player. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplyChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplyChancePercent_Description))] + public int WhisperReplyChancePercent { get; set; } = 100; + + /// + /// Gets or sets the minimum time between two whisper replies of the same bot. A bot which + /// answered recently stays silent, so a player can't keep it talking. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplyCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplyCooldown_Description))] + public TimeSpan WhisperReplyCooldown { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the lines the bots choose from. A situation without lines stays silent. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_Lines_Name), Description = nameof(PlugInResources.BotChatConfiguration_Lines_Description))] + public ICollection Lines { get; set; } = new List(); + + /// + /// Creates the configuration a server starts with, including a small set of English lines. + /// + /// The default configuration. + /// + /// The lines are configuration data like the names of the seeded items, not texts of the game + /// logic: a server owner is expected to rewrite them and to add translations. + /// + public static BotChatConfiguration CreateDefault() + { + var configuration = new BotChatConfiguration(); + Add( + BotChatSituation.EventEntranceOpened, + "{event} open, anyone going?", + "lf party {event}", + "{event} now, who wants to join?", + "going {event}, party anyone?"); + Add( + BotChatSituation.InvasionStarted, + "{event} in {map}!", + "{event} on {map}, go go", + "{map} - {event} started"); + Add( + BotChatSituation.LevelMilestone, + "{level} finally :)", + "lvl {level}, nice", + "{level} done"); + Add( + BotChatSituation.WhisperReply, + "sry busy hunting", + "hi, cant talk now", + "?", + "afk a bit"); + return configuration; + + void Add(BotChatSituation situation, params string[] texts) + { + foreach (var text in texts) + { + configuration.Lines.Add(new BotChatLine { Situation = situation, Text = text }); + } + } + } +} diff --git a/src/GameLogic/Bots/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs new file mode 100644 index 0000000000..ad6031ffdd --- /dev/null +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Interfaces; + +/// +/// One line a server-side bot may say in a . +/// +/// +/// The text is a : every receiving player sees the translation of +/// their own language, falling back to the English text. Placeholders in curly braces +/// (e.g. {event}) are filled in when the line is said; which ones a situation offers is +/// documented on . +/// +public class BotChatLine +{ + /// + /// Gets or sets the situation in which the line may be said. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Situation_Name), Description = nameof(PlugInResources.BotChatLine_Situation_Description))] + public BotChatSituation Situation { get; set; } + + /// + /// Gets or sets the text of the line. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] + public LocalizedString Text { get; set; } + + /// + public override string ToString() => $"{this.Situation}: {this.Text.ValueInNeutralLanguage}"; +} diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs new file mode 100644 index 0000000000..5be2248829 --- /dev/null +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -0,0 +1,303 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.ComponentModel; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.PlugIns; + +/// +/// Lets the server-side bots talk: they announce opened events and started invasions with +/// /post, comment on their level milestones and answer whispers - with lines from the +/// configuration, no language model. +/// +/// +/// A bot only talks about what it could actually do: it posts about a Blood Castle only when it +/// meets that event's entry requirements (the same check that decides whether a bot may follow its +/// leader in, see ), and about an invasion only when it may +/// warp to the invaded map. The limits of the keep a large +/// population from flooding the chat; they are tracked per game server. +/// It's a feature plugin of the bots, listed in the admin panel next to them. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.BotChatPlugIn_Name), Description = nameof(PlugInResources.BotChatPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("B3234CC4-1365-463A-B0DE-603F9D7D86A2")] +public class BotChatPlugIn + : IFeaturePlugIn, + IMiniGameEntranceOpenedPlugIn, + IInvasionStartedPlugIn, + ICharacterLevelUpPlugIn, + IWhisperMessageReceivedPlugIn, + ISupportCustomConfiguration, + ISupportDefaultCustomConfiguration, + IDisabledByDefault +{ + /// + /// How long an invasion counts as announced. Long enough that a second invasion of the same kind + /// started by the same schedule run doesn't get its own post. + /// + private static readonly TimeSpan InvasionAnnouncementWindow = TimeSpan.FromMinutes(10); + + /// + /// The delay range of a level milestone comment: a moment after the level-up effect. + /// + private static readonly (int Min, int Max) LevelMilestoneDelaySeconds = (1, 4); + + /// + /// The delay range of a whisper reply - the time a player needs to read and type. + /// + private static readonly (int Min, int Max) WhisperReplyDelaySeconds = (2, 7); + + private readonly ConditionalWeakTable _throttles = new(); + + /// + public BotChatConfiguration? Configuration { get; set; } + + /// + public object CreateDefaultConfig() => BotChatConfiguration.CreateDefault(); + + /// + public ValueTask MiniGameEntranceOpenedAsync(MiniGameContext miniGame) + { + if (this.Configuration is not { EventPostChancePercent: > 0 } configuration + || miniGame.Definition.Type is not (MiniGameType.BloodCastle or MiniGameType.DevilSquare or MiniGameType.ChaosCastle) + || !IsPostCommandActive(miniGame.GameContext)) + { + return ValueTask.CompletedTask; + } + + // Every level of an event (Blood Castle 1..8) reports its own opening. Each one waits a random + // time; the first one which finds a qualified bot takes the announcement of the whole event type. + RunLater( + miniGame.GameContext, + RandomDelay(TimeSpan.Zero, configuration.MaxPostDelay), + () => this.PostAboutEventAsync(miniGame, configuration)); + return ValueTask.CompletedTask; + } + + /// + public ValueTask InvasionStartedAsync(IGameContext gameContext, IPeriodicTaskPlugIn invasion, IReadOnlyCollection maps) + { + if (this.Configuration is not { InvasionPostChancePercent: > 0 } configuration + || maps.Count == 0 + || !IsPostCommandActive(gameContext)) + { + return ValueTask.CompletedTask; + } + + RunLater( + gameContext, + RandomDelay(TimeSpan.Zero, configuration.MaxPostDelay), + () => this.PostAboutInvasionAsync(gameContext, invasion, maps, configuration)); + return ValueTask.CompletedTask; + } + + /// + public void CharacterLeveledUp(Player player) + { + if (this.Configuration is not { LevelMilestoneStep: > 0, LevelMilestoneChancePercent: > 0 } configuration + || !IsBot(player)) + { + return; + } + + var level = GetLevel(player); + if (level < configuration.LevelMilestoneStep + || level % configuration.LevelMilestoneStep != 0 + || !Rand.NextRandomBool(configuration.LevelMilestoneChancePercent) + || PickLine(configuration, BotChatSituation.LevelMilestone) is not { } line + || !this.GetThrottle(player.GameContext).TryReserveChat(player, DateTime.UtcNow, configuration.BotChatCooldown)) + { + return; + } + + RunLater( + player.GameContext, + RandomDelay(LevelMilestoneDelaySeconds), + () => IsAvailable(player) + ? BotChatSpeaker.SayAsync(player, line, CreateBotValues(player)) + : ValueTask.CompletedTask); + } + + /// + public void WhisperMessageReceived(Player sender, Player receiver, string message, CancelEventArgs cancelEventArgs) + { + if (this.Configuration is not { WhisperReplyChancePercent: > 0 } configuration + || !IsBot(receiver) + || sender is OfflinePlayer + || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) + || PickLine(configuration, BotChatSituation.WhisperReply) is not { } line + || !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + return; + } + + var values = CreateBotValues(receiver); + var senderName = sender.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => senderName; + RunLater( + receiver.GameContext, + RandomDelay(WhisperReplyDelaySeconds), + () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld + ? BotChatSpeaker.WhisperAsync(receiver, sender, line, values) + : ValueTask.CompletedTask); + } + + /// + /// Determines whether the bot may warp to the map according to the warp list - the same rule the + /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the + /// map isn't forbidden for bots. + /// + /// The bot. + /// The map. + /// true, if the bot may warp to the map. + internal static bool MayWarpTo(Player bot, GameMapDefinition map) + { + if (bot.SelectedCharacter is not { } character) + { + return false; + } + + if (BotFeaturePlugIn.GetConfiguration(bot.GameContext)?.IsMapForbidden(map) == true) + { + return false; + } + + var level = GetLevel(bot); + return bot.GameContext.Configuration.WarpList.Any(warp => + warp.Gate?.Map?.Number == map.Number + && character.GetEffectiveMoveLevelRequirement(warp.LevelRequirement) <= level); + } + + private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; + + /// + /// Determines whether the bot is in the world and free to talk: alive and not inside an event. + /// + private static bool IsAvailable(Player bot) + => bot.PlayerState.CurrentState == PlayerState.EnteredWorld + && bot.IsAlive + && bot.CurrentMiniGame is null; + + private static int GetLevel(Player player) => (int)(player.Attributes?[Stats.Level] ?? 0); + + private static bool IsPostCommandActive(IGameContext gameContext) + => gameContext.PlugInManager.IsPlugInActive(typeof(PostChatCommandPlugIn)); + + private static BotChatLine? PickLine(BotChatConfiguration configuration, BotChatSituation situation) + => BotChatComposer.PickLine(configuration.Lines, situation, count => Rand.NextInt(0, count)); + + private static TimeSpan RandomDelay(TimeSpan min, TimeSpan max) + => max <= min ? min : min + TimeSpan.FromMilliseconds(Rand.NextDouble() * (max - min).TotalMilliseconds); + + private static TimeSpan RandomDelay((int Min, int Max) seconds) + => RandomDelay(TimeSpan.FromSeconds(seconds.Min), TimeSpan.FromSeconds(seconds.Max)); + + private static Dictionary> CreateBotValues(Player bot) + { + var level = GetLevel(bot).ToString(CultureInfo.InvariantCulture); + var map = bot.CurrentMap?.Definition.Name; + return new Dictionary> + { + ["level"] = _ => level, + ["map"] = culture => map?.GetTranslation(culture) ?? string.Empty, + }; + } + + /// + /// Runs the action after the delay, without holding up the caller - the plugin points are called + /// from the game logic, which must not wait for a bot's "typing time". + /// + private static void RunLater(IGameContext gameContext, TimeSpan delay, Func action) + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(delay).ConfigureAwait(false); + await action().ConfigureAwait(false); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger().LogError(ex, "Bot chat failed."); + } + }); + } + + /// + /// Gets the display name of an invasion in the receiver's language, from the name of its plugin. + /// + private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo culture) + { + var displayName = (Attribute.GetCustomAttribute(invasion.GetType(), typeof(DisplayAttribute)) as DisplayAttribute)?.Name; + return displayName is null + ? invasion.GetType().Name + : PlugInResources.ResourceManager.GetString(displayName, culture) ?? displayName; + } + + private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + + private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) + { + var now = DateTime.UtcNow; + if (now >= miniGame.EnterEndsAtUtc) + { + return; + } + + var definition = miniGame.Definition; + var players = await miniGame.GameContext.GetPlayersAsync().ConfigureAwait(false); + var candidates = players + .Where(p => IsBot(p) && IsAvailable(p) && BotMiniGameHandler.IsEligible(p, definition, out _)) + .ToList(); + var throttle = this.GetThrottle(miniGame.GameContext); + if (candidates.Count == 0 + || !throttle.TryReserveOccasion($"mini game {definition.Type}", now, miniGame.EnterEndsAtUtc - now) + || !Rand.NextRandomBool(configuration.EventPostChancePercent) + || PickLine(configuration, BotChatSituation.EventEntranceOpened) is not { } line + || !throttle.TryReservePost(now, configuration.MinTimeBetweenPosts)) + { + return; + } + + var bot = candidates[Rand.NextInt(0, candidates.Count)]; + var values = CreateBotValues(bot); + values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; + await BotChatSpeaker.PostAsync(bot, line, values).ConfigureAwait(false); + } + + private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriodicTaskPlugIn invasion, IReadOnlyCollection maps, BotChatConfiguration configuration) + { + var now = DateTime.UtcNow; + var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); + var candidates = players + .Where(p => IsBot(p) && IsAvailable(p)) + .SelectMany(p => maps.Where(map => MayWarpTo(p, map)).Select(map => (Bot: p, Map: map))) + .ToList(); + var throttle = this.GetThrottle(gameContext); + if (candidates.Count == 0 + || !throttle.TryReserveOccasion($"invasion {invasion.GetType().Name}", now, InvasionAnnouncementWindow) + || !Rand.NextRandomBool(configuration.InvasionPostChancePercent) + || PickLine(configuration, BotChatSituation.InvasionStarted) is not { } line + || !throttle.TryReservePost(now, configuration.MinTimeBetweenPosts)) + { + return; + } + + var (bot, map) = candidates[Rand.NextInt(0, candidates.Count)]; + var values = CreateBotValues(bot); + values["event"] = culture => GetInvasionName(invasion, culture); + values["map"] = culture => map.Name.GetTranslation(culture) ?? string.Empty; + await BotChatSpeaker.PostAsync(bot, line, values).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs new file mode 100644 index 0000000000..cacf1d9999 --- /dev/null +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// The situations in which a server-side bot says something. Each line of the +/// belongs to one of them. +/// +public enum BotChatSituation +{ + /// + /// The entrance of a Blood Castle, Devil Square or Chaos Castle opened. A bot which meets the + /// event's entry requirements posts about it with /post. + /// Placeholders: {event}, {level}. + /// + EventEntranceOpened, + + /// + /// An invasion (White Wizard, Golden, Red Dragon) started. A bot which may warp to the invaded + /// map posts about it with /post. + /// Placeholders: {event}, {map}, {level}. + /// + InvasionStarted, + + /// + /// The bot reached a level milestone (see ). + /// Said in the normal chat, so only players nearby see it. + /// Placeholders: {level}, {map}. + /// + LevelMilestone, + + /// + /// A human player whispered to the bot. The bot whispers back. + /// Placeholders: {player}, {level}, {map}. + /// + WhisperReply, +} diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs new file mode 100644 index 0000000000..b41300a6cd --- /dev/null +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -0,0 +1,91 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; + +/// +/// Delivers what a bot says to the human players, each in their own language. +/// +/// +/// A player's chat goes through the ChatMessageAction as one string for all receivers. A +/// bot's line is a instead, so it is rendered and sent per receiver - +/// like the ServerAnnouncementsPlugIn does. The receivers see exactly what a player's +/// message would look like. Off-level players and other bots are skipped: they have no connection +/// to show it on. +/// +internal static class BotChatSpeaker +{ + /// + /// Says the line in the normal chat, to the players who see the bot. + /// + /// The speaking bot. + /// The line. + /// The placeholder values. + internal static ValueTask SayAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + { + if (bot.SelectedCharacter?.Name is not { } name) + { + return ValueTask.CompletedTask; + } + + return bot.ForEachObservingAsync( + receiver => SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Normal), + false); + } + + /// + /// Whispers the line to a player. + /// + /// The whispering bot. + /// The receiving player. + /// The line. + /// The placeholder values. + internal static ValueTask WhisperAsync(Player bot, Player receiver, BotChatLine line, IReadOnlyDictionary> values) + { + if (bot.SelectedCharacter?.Name is not { } name) + { + return ValueTask.CompletedTask; + } + + return SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Whisper); + } + + /// + /// Posts the line to all players of the game server, in the format of the /post command. + /// + /// The posting bot. + /// The line. + /// The placeholder values. + internal static async ValueTask PostAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + { + if (bot.SelectedCharacter?.Name is not { } name) + { + return; + } + + await bot.GameContext.ForEachPlayerAsync(receiver => + SendAsync( + receiver, + PostChatCommandPlugIn.FormatPost(name, BotChatComposer.Render(line, receiver.Culture, values)), + PostChatCommandPlugIn.PostSender, + ChatMessageType.Gens).AsTask()) + .ConfigureAwait(false); + } + + private static ValueTask SendAsync(Player receiver, string text, string sender, ChatMessageType type) + { + if (receiver is OfflinePlayer || string.IsNullOrWhiteSpace(text)) + { + return ValueTask.CompletedTask; + } + + return receiver.InvokeViewPlugInAsync(p => p.ChatMessageAsync(text, sender, type)); + } +} diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs new file mode 100644 index 0000000000..04d7dfe6e6 --- /dev/null +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -0,0 +1,123 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; + +/// +/// The chat limits of the bots of one game server: the server-wide gap between two /post +/// messages, the once-per-occasion announcement of an event, and the per-bot cooldowns of the +/// normal chat and of whisper replies. +/// +/// +/// Every check reserves its slot atomically, so two bots reacting to the same event at the same +/// moment can't both get through. The time is passed in, which keeps the class testable. +/// +internal sealed class BotChatThrottle +{ + private readonly ConcurrentDictionary _occasions = new(); + + private readonly ConditionalWeakTable _bots = new(); + + private long _nextPostTicks; + + /// + /// Reserves the right to announce an occasion (e.g. "Blood Castle opened") for the given time + /// window. Only the first caller within the window gets it. + /// + /// The key of the occasion, e.g. the mini game type. + /// The current time. + /// How long the occasion stays reserved. + /// true, if the caller may announce the occasion. + public bool TryReserveOccasion(string occasionKey, DateTime now, TimeSpan window) + { + while (true) + { + if (this._occasions.TryAdd(occasionKey, now + window)) + { + return true; + } + + if (!this._occasions.TryGetValue(occasionKey, out var reservedUntil) || reservedUntil > now) + { + // Either still reserved, or another caller just replaced the expired reservation. + return false; + } + + if (this._occasions.TryUpdate(occasionKey, now + window, reservedUntil)) + { + return true; + } + } + } + + /// + /// Reserves the next server-wide /post of a bot. + /// + /// The current time. + /// The minimum time until the next post. + /// true, if the caller may post now. + public bool TryReservePost(DateTime now, TimeSpan gap) + => TryReserve(ref this._nextPostTicks, now, gap); + + /// + /// Reserves the next normal chat line of the bot. + /// + /// The bot. + /// The current time. + /// The minimum time until the bot's next line. + /// true, if the bot may say something now. + public bool TryReserveChat(Player bot, DateTime now, TimeSpan cooldown) + => TryReserve(ref this._bots.GetOrCreateValue(bot).NextChatTicks, now, cooldown); + + /// + /// Reserves the next whisper reply of the bot. + /// + /// The bot. + /// The current time. + /// The minimum time until the bot's next reply. + /// true, if the bot may reply now. + public bool TryReserveWhisperReply(Player bot, DateTime now, TimeSpan cooldown) + => TryReserve(ref this._bots.GetOrCreateValue(bot).NextWhisperReplyTicks, now, cooldown); + + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) + { + while (true) + { + var current = Interlocked.Read(ref nextAllowedTicks); + if (now.Ticks < current) + { + return false; + } + + if (Interlocked.CompareExchange(ref nextAllowedTicks, (now + gap).Ticks, current) == current) + { + return true; + } + } + } + + /// + /// The cooldowns of one bot, as raw ticks for the interlocked updates. + /// + private sealed class BotCooldowns + { + private long _nextChatTicks; + + private long _nextWhisperReplyTicks; + + /// + /// Gets a reference to the time (ticks) from which the bot may say the next normal chat line. + /// + public ref long NextChatTicks => ref this._nextChatTicks; + + /// + /// Gets a reference to the time (ticks) from which the bot may answer the next whisper. + /// + public ref long NextWhisperReplyTicks => ref this._nextWhisperReplyTicks; + } +} diff --git a/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs index 43b01cc6aa..605e53e1da 100644 --- a/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs @@ -18,6 +18,11 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; [ChatCommandHelp(CommandKey, CharacterStatus.Normal)] public class PostChatCommandPlugIn : IChatCommandPlugIn { + /// + /// The sender name under which a post appears in the chat. + /// + internal const string PostSender = "[POST]"; + private const string CommandKey = "/post"; /// @@ -37,7 +42,14 @@ public async ValueTask HandleCommandAsync(Player player, string command) return; } - message = $"{player.SelectedCharacter?.Name}: {message}"; - await player.GameContext.SendGlobalChatMessageAsync("[POST]", message, ChatMessageType.Gens).ConfigureAwait(false); + await player.GameContext.SendGlobalChatMessageAsync(PostSender, FormatPost(player.SelectedCharacter?.Name, message), ChatMessageType.Gens).ConfigureAwait(false); } + + /// + /// Formats the text of a post as it appears in the chat: the author's name, then the message. + /// + /// The name of the posting character. + /// The posted message. + /// The text shown in the chat. + internal static string FormatPost(string? characterName, string message) => $"{characterName}: {message}"; } \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 3da4665754..5cfdca3d81 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4615,6 +4615,84 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotRevengePlugIn_Description text. public static string BotRevengePlugIn_Description => ResourceManager.GetString("BotRevengePlugIn_Description", resourceCulture)!; + /// Gets the localized BotChatPlugIn_Name text. + public static string BotChatPlugIn_Name => ResourceManager.GetString("BotChatPlugIn_Name", resourceCulture)!; + + /// Gets the localized BotChatPlugIn_Description text. + public static string BotChatPlugIn_Description => ResourceManager.GetString("BotChatPlugIn_Description", resourceCulture)!; + + /// Gets the localized BotChatLine_Situation_Name text. + public static string BotChatLine_Situation_Name => ResourceManager.GetString("BotChatLine_Situation_Name", resourceCulture)!; + + /// Gets the localized BotChatLine_Situation_Description text. + public static string BotChatLine_Situation_Description => ResourceManager.GetString("BotChatLine_Situation_Description", resourceCulture)!; + + /// Gets the localized BotChatLine_Text_Name text. + public static string BotChatLine_Text_Name => ResourceManager.GetString("BotChatLine_Text_Name", resourceCulture)!; + + /// Gets the localized BotChatLine_Text_Description text. + public static string BotChatLine_Text_Description => ResourceManager.GetString("BotChatLine_Text_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventPostChancePercent_Name text. + public static string BotChatConfiguration_EventPostChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_EventPostChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventPostChancePercent_Description text. + public static string BotChatConfiguration_EventPostChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_EventPostChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_InvasionPostChancePercent_Name text. + public static string BotChatConfiguration_InvasionPostChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_InvasionPostChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_InvasionPostChancePercent_Description text. + public static string BotChatConfiguration_InvasionPostChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_InvasionPostChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MinTimeBetweenPosts_Name text. + public static string BotChatConfiguration_MinTimeBetweenPosts_Name => ResourceManager.GetString("BotChatConfiguration_MinTimeBetweenPosts_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MinTimeBetweenPosts_Description text. + public static string BotChatConfiguration_MinTimeBetweenPosts_Description => ResourceManager.GetString("BotChatConfiguration_MinTimeBetweenPosts_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MaxPostDelay_Name text. + public static string BotChatConfiguration_MaxPostDelay_Name => ResourceManager.GetString("BotChatConfiguration_MaxPostDelay_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MaxPostDelay_Description text. + public static string BotChatConfiguration_MaxPostDelay_Description => ResourceManager.GetString("BotChatConfiguration_MaxPostDelay_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneStep_Name text. + public static string BotChatConfiguration_LevelMilestoneStep_Name => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneStep_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneStep_Description text. + public static string BotChatConfiguration_LevelMilestoneStep_Description => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneStep_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneChancePercent_Name text. + public static string BotChatConfiguration_LevelMilestoneChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneChancePercent_Description text. + public static string BotChatConfiguration_LevelMilestoneChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_BotChatCooldown_Name text. + public static string BotChatConfiguration_BotChatCooldown_Name => ResourceManager.GetString("BotChatConfiguration_BotChatCooldown_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_BotChatCooldown_Description text. + public static string BotChatConfiguration_BotChatCooldown_Description => ResourceManager.GetString("BotChatConfiguration_BotChatCooldown_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplyChancePercent_Name text. + public static string BotChatConfiguration_WhisperReplyChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_WhisperReplyChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplyChancePercent_Description text. + public static string BotChatConfiguration_WhisperReplyChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_WhisperReplyChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplyCooldown_Name text. + public static string BotChatConfiguration_WhisperReplyCooldown_Name => ResourceManager.GetString("BotChatConfiguration_WhisperReplyCooldown_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplyCooldown_Description text. + public static string BotChatConfiguration_WhisperReplyCooldown_Description => ResourceManager.GetString("BotChatConfiguration_WhisperReplyCooldown_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_Lines_Name text. + public static string BotChatConfiguration_Lines_Name => ResourceManager.GetString("BotChatConfiguration_Lines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_Lines_Description text. + public static string BotChatConfiguration_Lines_Description => ResourceManager.GetString("BotChatConfiguration_Lines_Description", resourceCulture)!; + /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 72a1208212..08ab7380bc 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1897,4 +1897,30 @@ Maps bots never warp to: no hunting, following or events there, and a bot which logs in on one leaves it. Players are not affected. + Bot chat + Lets server-side bots talk: they announce events and invasions with /post, comment on level milestones and answer whispers, using configurable lines. + Situation + When the line may be said. + Text + What the bot says. Placeholders: {event}, {map}, {level}, {player} - the documentation lists which situation offers which. + Event post chance (%) + Chance that a bot which meets the entry requirements announces an opened Blood Castle, Devil Square or Chaos Castle with /post. Rolled once per event type and opening. 0 = never. + Invasion post chance (%) + Chance that a bot which may warp to the invaded map announces a started invasion with /post. 0 = never. + Minimum time between posts + Minimum time between two /post messages of bots on one game server. + Maximum post delay + A bot waits a random time up to this value before it posts about an event. + Level milestone step + Every how many levels a bot may comment on its level-up. 0 = never. + Level milestone chance (%) + Chance that a bot comments on reaching a level milestone, in the normal chat. + Chat cooldown per bot + Minimum time between two normal chat lines of the same bot. + Whisper reply chance (%) + Chance that a bot answers a whisper of a player. 0 = never. + Whisper reply cooldown + Minimum time between two whisper replies of the same bot. + Lines + The lines the bots choose from, per situation. A situation without lines stays silent. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs new file mode 100644 index 0000000000..6468ceb1db --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -0,0 +1,144 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Globalization; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.Interfaces; + +/// +/// Tests the bot chat (): the limits which keep a large bot population +/// from flooding the chat, and how a configured line becomes the text a player sees. +/// +[TestFixture] +public class BotChatTest +{ + private static readonly DateTime Start = new(2026, 11, 4, 20, 0, 0, DateTimeKind.Utc); + + private static readonly Dictionary> NoValues = new(); + + /// + /// A /post reaches the whole server, so all bots of a server share one gap between posts. + /// + [Test] + public void PostsKeepTheServerWideGap() + { + var throttle = new BotChatThrottle(); + var gap = TimeSpan.FromMinutes(3); + + Assert.That(throttle.TryReservePost(Start, gap), Is.True); + Assert.That(throttle.TryReservePost(Start.AddMinutes(1), gap), Is.False); + Assert.That(throttle.TryReservePost(Start.AddMinutes(3), gap), Is.True); + } + + /// + /// Every level of Blood Castle reports its own opening; only the first gets the announcement of + /// the event type, the others stay silent until the reservation expires. Other occasions are + /// independent of it. + /// + [Test] + public void AnOccasionIsAnnouncedOncePerWindow() + { + var throttle = new BotChatThrottle(); + var window = TimeSpan.FromMinutes(5); + + Assert.That(throttle.TryReserveOccasion("mini game BloodCastle", Start, window), Is.True); + Assert.That(throttle.TryReserveOccasion("mini game BloodCastle", Start.AddSeconds(20), window), Is.False); + Assert.That(throttle.TryReserveOccasion("mini game DevilSquare", Start.AddSeconds(20), window), Is.True); + Assert.That(throttle.TryReserveOccasion("mini game BloodCastle", Start.AddMinutes(5), window), Is.True); + } + + /// + /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the + /// two cooldowns don't block each other. + /// + /// The task. + [Test] + public async ValueTask CooldownsArePerBotAndPerKindAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var first = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var second = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var throttle = new BotChatThrottle(); + var cooldown = TimeSpan.FromMinutes(2); + + Assert.That(throttle.TryReserveChat(first, Start, cooldown), Is.True); + Assert.That(throttle.TryReserveChat(first, Start.AddMinutes(1), cooldown), Is.False); + Assert.That(throttle.TryReserveChat(second, Start.AddMinutes(1), cooldown), Is.True); + Assert.That(throttle.TryReserveWhisperReply(first, Start.AddMinutes(1), cooldown), Is.True); + Assert.That(throttle.TryReserveChat(first, Start.AddMinutes(2), cooldown), Is.True); + } + + /// + /// Only lines of the requested situation are picked, and blank lines are ignored - a situation + /// without a usable line stays silent. + /// + [Test] + public void PicksOnlyUsableLinesOfTheSituation() + { + var lines = new List + { + new() { Situation = BotChatSituation.WhisperReply, Text = "busy" }, + new() { Situation = BotChatSituation.LevelMilestone, Text = " " }, + new() { Situation = BotChatSituation.LevelMilestone, Text = "{level} finally" }, + }; + + var picked = BotChatComposer.PickLine(lines, BotChatSituation.LevelMilestone, count => count - 1); + var silent = BotChatComposer.PickLine(lines, BotChatSituation.InvasionStarted, _ => 0); + + Assert.That(picked?.Text.ValueInNeutralLanguage, Is.EqualTo("{level} finally")); + Assert.That(silent, Is.Null); + } + + /// + /// Every receiver sees the translation of their own language, with the placeholders filled in + /// for that language; without a translation, the English text is used. + /// + [Test] + public void RendersTheLineInTheReceiversLanguage() + { + var polish = CultureInfo.GetCultureInfo("pl"); + var line = new BotChatLine + { + Situation = BotChatSituation.InvasionStarted, + Text = new LocalizedString("{event} in {map}!").WithTranslation(polish, "{event} na {map}!"), + }; + var values = new Dictionary> + { + ["event"] = culture => culture.Name == "pl" ? "Inwazja" : "Invasion", + ["map"] = _ => "Devias", + }; + + Assert.That(BotChatComposer.Render(line, polish, values), Is.EqualTo("Inwazja na Devias!")); + Assert.That(BotChatComposer.Render(line, CultureInfo.GetCultureInfo("de"), values), Is.EqualTo("Invasion in Devias!")); + } + + /// + /// A placeholder without a value (e.g. a typo in a configured line) stays visible instead of + /// silently swallowing text. + /// + [Test] + public void KeepsUnknownPlaceholders() + { + var line = new BotChatLine { Situation = BotChatSituation.LevelMilestone, Text = "{lvl} done" }; + + Assert.That(BotChatComposer.Render(line, CultureInfo.InvariantCulture, NoValues), Is.EqualTo("{lvl} done")); + } + + /// + /// A server which switches the plugin on has something to say in every situation. + /// + [Test] + public void DefaultConfigurationHasLinesForEverySituation() + { + var configuration = BotChatConfiguration.CreateDefault(); + + foreach (var situation in Enum.GetValues()) + { + Assert.That(configuration.Lines.Any(line => line.Situation == situation), Is.True, situation.ToString()); + } + } +} From 82ae6620b42d07749acf3895c10f1cf760042b67 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 13:57:54 +0100 Subject: [PATCH 02/44] feat(bots): wait for players after announcing an event A bot which posts "Blood Castle open, anyone going?" and keeps hunting in another corner of the world can't be found. After its post it now goes to a meeting point and waits there until the entrance closes: next to the Messenger of Archangel (Blood Castle) or Charon (Devil Square), found in the game data, or - since Chaos Castle is entered with the ticket alone - in the safezone of its home town, which the post names ({map}). Waiting does not make the bot decline party invitations; a player who invites it and enters takes it along like any party bot. The meeting ends when the entrance closes, when a player takes the bot into a party or when there is no route to the NPC. A bot does not announce an event whose entrance closes in less than two minutes, and a bot on an errand, on a revenge or in a party with a player does not look for company. --- docs-website/docs/server-features/bots.md | 13 ++- src/GameLogic/Bots/BotChatConfiguration.cs | 8 +- src/GameLogic/Bots/BotChatPlugIn.cs | 42 +++++++- src/GameLogic/Bots/BotChatSituation.cs | 5 +- src/GameLogic/Bots/BotEventMeeting.cs | 22 +++++ src/GameLogic/Bots/BotEventMeetingPlanner.cs | 96 +++++++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 83 ++++++++++++++++ src/GameLogic/Offline/OfflinePlayer.cs | 8 ++ .../BotEventMeetingTest.cs | 95 ++++++++++++++++++ 9 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 src/GameLogic/Bots/BotEventMeeting.cs create mode 100644 src/GameLogic/Bots/BotEventMeetingPlanner.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotEventMeetingTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index e982325e2d..940e1b1a9b 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -339,11 +339,22 @@ model. A bot only talks about what it could actually do: | Situation | Where | Who says it | |---|---|---| -| A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post` | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | +| A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post`, then it waits for players | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | | A player whispered to the bot | whisper back, after a few seconds | the bot | +A bot which posts about an event does not stay out hunting where nobody +could find it. It goes to the meeting point and waits there until the entrance +closes: next to the Messenger of Archangel for Blood Castle, next to Charon for +Devil Square, and — since Chaos Castle is entered with the ticket alone — in the +safezone of its home town, which the post names (`{map}`). Waiting does not +make it decline party invitations; being invited is the point. A player who +invites it and enters the event takes it along like any party bot. If nobody +invites it, it goes back to hunting when the entrance closes. A bot does not +announce an event whose entrance closes in less than two minutes, and a bot +already in a party with a player does not look for company. + Placeholders in a line are filled in when it is said: `{event}`, `{map}`, `{level}`, and `{player}` (whisper replies). Each line is a localized text, so every player sees the translation of their own language, or the English text diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 30971c2e4f..059cbe4ddb 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -103,10 +103,10 @@ public static BotChatConfiguration CreateDefault() var configuration = new BotChatConfiguration(); Add( BotChatSituation.EventEntranceOpened, - "{event} open, anyone going?", - "lf party {event}", - "{event} now, who wants to join?", - "going {event}, party anyone?"); + "{event} open, anyone going? im in {map}", + "lf party {event}, waiting in {map}", + "{event} now, who wants to join? {map}", + "going {event}, party anyone? at {map}"); Add( BotChatSituation.InvasionStarted, "{event} in {map}!", diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 5be2248829..fed056fb34 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -25,7 +25,9 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// A bot only talks about what it could actually do: it posts about a Blood Castle only when it /// meets that event's entry requirements (the same check that decides whether a bot may follow its /// leader in, see ), and about an invasion only when it may -/// warp to the invaded map. The limits of the keep a large +/// warp to the invaded map. After announcing an event, the bot waits at the event's entry NPC (or in +/// its home town for Chaos Castle) until the entrance closes, so the players can find and invite it +/// (see ). The limits of the keep a large /// population from flooding the chat; they are tracked per game server. /// It's a feature plugin of the bots, listed in the admin panel next to them. /// @@ -48,6 +50,13 @@ public class BotChatPlugIn /// private static readonly TimeSpan InvasionAnnouncementWindow = TimeSpan.FromMinutes(10); + /// + /// The least time an event's entrance must still stay open for a bot to announce it: the bot + /// waits at the meeting point until the entrance closes (see ), and a + /// player who reads the post needs the time to get there and invite it. + /// + private static readonly TimeSpan MinimumMeetingTime = TimeSpan.FromMinutes(2); + /// /// The delay range of a level milestone comment: a moment after the level-up effect. /// @@ -181,6 +190,18 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; + /// + /// Determines whether the bot is free to wait for players at an event: not already waiting, not on + /// an errand or a revenge, and not in a party with a player - it would look for company it has. + /// + private static bool IsFreeToMeet(OfflinePlayer bot) + => bot.EventMeeting is null + && !bot.IsOnShoppingTrip + && !bot.IsOnBuffTrip + && !bot.IsOnChaosMachineTrip + && !bot.HasRevengeIntent + && !BotPartyHandler.HasHumanCompanion(bot); + /// /// Determines whether the bot is in the world and free to talk: alive and not inside an event. /// @@ -255,10 +276,19 @@ private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatCon return; } + // A player reading the post needs the time to get to the meeting point. + if (miniGame.EnterEndsAtUtc - now < MinimumMeetingTime) + { + return; + } + var definition = miniGame.Definition; var players = await miniGame.GameContext.GetPlayersAsync().ConfigureAwait(false); var candidates = players - .Where(p => IsBot(p) && IsAvailable(p) && BotMiniGameHandler.IsEligible(p, definition, out _)) + .OfType() + .Where(bot => IsBot(bot) && IsAvailable(bot) && IsFreeToMeet(bot) && BotMiniGameHandler.IsEligible(bot, definition, out _)) + .Select(bot => (Bot: bot, Meeting: BotEventMeetingPlanner.TryPlan(bot, definition.Type, miniGame.EnterEndsAtUtc))) + .Where(candidate => candidate.Meeting is not null) .ToList(); var throttle = this.GetThrottle(miniGame.GameContext); if (candidates.Count == 0 @@ -270,10 +300,12 @@ private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatCon return; } - var bot = candidates[Rand.NextInt(0, candidates.Count)]; - var values = CreateBotValues(bot); + var (chosen, meeting) = candidates[Rand.NextInt(0, candidates.Count)]; + chosen.EventMeeting = meeting; + var values = CreateBotValues(chosen); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; - await BotChatSpeaker.PostAsync(bot, line, values).ConfigureAwait(false); + values["map"] = culture => meeting!.Map.Name.GetTranslation(culture) ?? string.Empty; + await BotChatSpeaker.PostAsync(chosen, line, values).ConfigureAwait(false); } private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriodicTaskPlugIn invasion, IReadOnlyCollection maps, BotChatConfiguration configuration) diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index cacf1d9999..d9ffb075e7 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -12,8 +12,9 @@ public enum BotChatSituation { /// /// The entrance of a Blood Castle, Devil Square or Chaos Castle opened. A bot which meets the - /// event's entry requirements posts about it with /post. - /// Placeholders: {event}, {level}. + /// event's entry requirements posts about it with /post and then waits for players at the + /// meeting point (see ) until the entrance closes. + /// Placeholders: {event}, {map} (the map of the meeting point), {level}. /// EventEntranceOpened, diff --git a/src/GameLogic/Bots/BotEventMeeting.cs b/src/GameLogic/Bots/BotEventMeeting.cs new file mode 100644 index 0000000000..a7d87626af --- /dev/null +++ b/src/GameLogic/Bots/BotEventMeeting.cs @@ -0,0 +1,22 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Pathfinding; + +/// +/// Where and until when a bot waits for players after it announced an opened event (see +/// ). A bot which posts "Blood Castle open, anyone going?" and keeps +/// hunting in another corner of the world could never be found - so it goes to the meeting point and +/// stays there until the entrance closes, ready to be invited into a party. +/// +/// The map of the meeting point. +/// +/// The position of the event's entry NPC (Messenger of Archangel, Charon), next to which the bot waits; +/// null to wait anywhere in the map's safezone - Chaos Castle is entered with the ticket alone, +/// it has no entry NPC. +/// +/// When the event's entrance closes. +internal sealed record BotEventMeeting(GameMapDefinition Map, Point? NpcPosition, DateTime UntilUtc); diff --git a/src/GameLogic/Bots/BotEventMeetingPlanner.cs b/src/GameLogic/Bots/BotEventMeetingPlanner.cs new file mode 100644 index 0000000000..3770c4383f --- /dev/null +++ b/src/GameLogic/Bots/BotEventMeetingPlanner.cs @@ -0,0 +1,96 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Pathfinding; + +/// +/// Plans where a bot waits for players after it announced an opened event (see +/// ). Pure decision logic - exposed for unit tests. +/// +internal static class BotEventMeetingPlanner +{ + /// + /// Plans the meeting point of the bot for the event type. + /// + /// The bot which announces the event. + /// The type of the opened event. + /// When the event's entrance closes. + /// The meeting, or null when the bot has nowhere to wait. + /// + /// Blood Castle and Devil Square are entered through an NPC (Messenger of Archangel, Charon), so + /// the bot waits next to it - on a map it may warp to, preferring the one it is on. Chaos Castle + /// is entered with the ticket alone and has no such place; the bot waits in the safezone of its + /// class's home town instead, and the post names the town. + /// + internal static BotEventMeeting? TryPlan(Player bot, MiniGameType type, DateTime untilUtc) + { + var entryNpcWindow = type switch + { + MiniGameType.BloodCastle => NpcWindow.BloodCastle, + MiniGameType.DevilSquare => NpcWindow.DevilSquare, + _ => (NpcWindow?)null, + }; + + if (entryNpcWindow is not { } window) + { + return type == MiniGameType.ChaosCastle + && bot.SelectedCharacter?.CharacterClass?.HomeMap is { } homeMap + ? new BotEventMeeting(homeMap, null, untilUtc) + : null; + } + + var currentMapNumber = bot.CurrentMap?.Definition.Number; + var entryNpc = bot.GameContext.Configuration.Maps + .SelectMany(map => map.MonsterSpawns + .Where(spawn => spawn.MonsterDefinition?.NpcWindow == window) + .Select(spawn => (Map: map, Position: new Point(spawn.X1, spawn.Y1)))) + .Where(npc => npc.Map.Number == currentMapNumber || BotChatPlugIn.MayWarpTo(bot, npc.Map)) + .OrderByDescending(npc => npc.Map.Number == currentMapNumber) + .FirstOrDefault(); + + return entryNpc.Map is null ? null : new BotEventMeeting(entryNpc.Map, entryNpc.Position, untilUtc); + } + + /// + /// Finds the tile the bot stands on next to an NPC: the nearest walkable tile within the range, + /// preferring the safezone. The NPC's own tile is usually blocked, and a route to a blocked tile + /// does not exist. + /// + /// The terrain of the map. + /// The position of the NPC. + /// The maximum distance from the NPC. + /// The tile, or null when none is walkable. + internal static Point? FindStandingPoint(GameMapTerrain terrain, Point npcPosition, int range) + { + Point? best = null; + var bestScore = double.MaxValue; + for (var x = npcPosition.X - range; x <= npcPosition.X + range; x++) + { + for (var y = npcPosition.Y - range; y <= npcPosition.Y + range; y++) + { + if (x is < 0 or > byte.MaxValue + || y is < 0 or > byte.MaxValue + || (x == npcPosition.X && y == npcPosition.Y) + || !terrain.WalkMap[x, y]) + { + continue; + } + + var point = new Point((byte)x, (byte)y); + + // A tile outside the safezone only wins when the safezone offers none in range. + var score = point.EuclideanDistanceTo(npcPosition) + (terrain.SafezoneMap[x, y] ? 0 : range * 2); + if (score < bestScore) + { + best = point; + bestScore = score; + } + } + } + + return best; + } +} diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index d8f2ce8962..df6e39bbf5 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -1066,6 +1066,14 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // A bot which announced an opened event waits at the meeting point until the entrance closes + // (see BotChatPlugIn): it said where it would be, so it must be found there. This outranks the + // errands and the party follow below - and a due reset, which would change its level. + if (await this.TryEventMeetingAsync(map, inSafezone, cancellationToken).ConfigureAwait(false)) + { + return; + } + // On servers with the reset feature, a due character reset outranks the whole routine below: // the bot has reached the required level, so hunting on only gains it nothing until it resets. // It keeps fighting normally through the random grace delay, then resets and starts its next @@ -1422,6 +1430,81 @@ private async ValueTask ClaimBuffAsync(GameMap map, Point buffNpcPosition) await BotBuffHandler.TryRequestBuffAsync(this._player, map, buffNpcPosition).ConfigureAwait(false); } + /// + /// Drives an event meeting (see ): a bot which announced an + /// opened event warps to the meeting map, walks up to the event's entry NPC (or just into the + /// safezone, for Chaos Castle) and waits there until the entrance closes. The meeting ends early + /// when a player takes the bot into a party - the follow logic then takes it into the event behind + /// its new leader. + /// + /// The current map. + /// Indicates whether the bot currently is in the safezone of the map. + /// The token which aborts the travel wait on shutdown. + /// True, if the meeting consumed this tick. + private async ValueTask TryEventMeetingAsync(GameMap map, bool inSafezone, CancellationToken cancellationToken) + { + if (this._player.EventMeeting is not { } meeting) + { + return false; + } + + if (DateTime.UtcNow >= meeting.UntilUtc + || this._player.CurrentMiniGame is not null + || BotPartyHandler.HasHumanCompanion(this._player)) + { + this.EndEventMeeting(meeting); + return false; + } + + // Leaving the hunting ground for a while: don't keep other bots off it meanwhile. + this._player.HuntingSpot = null; + + if (map.Definition.Number != meeting.Map.Number || (meeting.NpcPosition is null && !inSafezone)) + { + if (meeting.Map.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } gate) + { + this.EndEventMeeting(meeting); + return false; + } + + this._travelPath = null; + this._hasDestination = false; + this._lastWarpUtc = this._timeProvider.GetUtcNow(); + this._player.Logger.LogDebug("Bot '{Name}' warps to {Map} to wait for players of the event it announced.", this._player.Name, meeting.Map.Name); + await this._player.WarpToAsync(gate).ConfigureAwait(false); + return true; + } + + if (meeting.NpcPosition is { } npcPosition && this._player.GetDistanceTo(npcPosition) > MerchantTalkRange) + { + if (BotEventMeetingPlanner.FindStandingPoint(map.Terrain, npcPosition, MerchantTalkRange) is not { } standingPoint + || !await this.TravelTowardAsync(map, standingPoint, cancellationToken).ConfigureAwait(false)) + { + this._player.Logger.LogDebug("Bot '{Name}' gives up waiting for the event: no route to the entry NPC at {Target}.", this._player.Name, npcPosition); + this.EndEventMeeting(meeting); + return false; + } + + return true; + } + + // Waiting at the meeting point is not "stuck". + this._lastMoveUtc = DateTime.UtcNow; + return true; + } + + /// + /// Ends the event meeting, unless a newer one replaced it in the meantime. + /// + /// The meeting which is over. + private void EndEventMeeting(BotEventMeeting meeting) + { + if (ReferenceEquals(this._player.EventMeeting, meeting)) + { + this._player.EventMeeting = null; + } + } + /// /// Drives a chaos machine trip: when the bot carries what an upgrade of a worn lucky piece needs (see /// ), it heads to the goblin - warping to a map with diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 688976c24b..65bd03ae17 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -166,6 +166,14 @@ internal Player? RecentAggressor /// internal bool IsOnChaosMachineTrip { get; set; } + /// + /// Gets or sets the meeting point at which the bot waits for players after announcing an opened + /// event, set by and carried out by . + /// Unlike an errand, it does not make the bot decline party invitations - being invited is what + /// it waits for. + /// + internal Bots.BotEventMeeting? EventMeeting { get; set; } + /// /// Gets a value indicating whether a revenge against a player killer is pending or armed - the /// bot has unfinished business and is in no mood to group up. diff --git a/tests/MUnique.OpenMU.Tests/BotEventMeetingTest.cs b/tests/MUnique.OpenMU.Tests/BotEventMeetingTest.cs new file mode 100644 index 0000000000..60d945fffe --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotEventMeetingTest.cs @@ -0,0 +1,95 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests where a bot waits for players after it announced an opened event +/// (). +/// +[TestFixture] +public class BotEventMeetingTest +{ + private static readonly Point Npc = new(100, 100); + + /// + /// The NPC's own tile is usually blocked, and a route to a blocked tile does not exist - the bot + /// stands on the nearest walkable tile next to it. + /// + [Test] + public void StandsOnTheNearestWalkableTileNextToTheNpc() + { + var terrain = CreateBlockedTerrain(); + terrain.WalkMap[Npc.X, Npc.Y] = true; + terrain.WalkMap[Npc.X + 2, Npc.Y] = true; + terrain.WalkMap[Npc.X + 1, Npc.Y + 1] = true; + + var point = BotEventMeetingPlanner.FindStandingPoint(terrain, Npc, 3); + + Assert.That(point, Is.EqualTo(new Point((byte)(Npc.X + 1), (byte)(Npc.Y + 1)))); + } + + /// + /// The meeting happens in town: a walkable tile in the safezone wins over a nearer one outside. + /// + [Test] + public void PrefersTheSafezone() + { + var terrain = CreateBlockedTerrain(); + terrain.WalkMap[Npc.X + 1, Npc.Y] = true; + terrain.WalkMap[Npc.X + 3, Npc.Y] = true; + terrain.SafezoneMap[Npc.X + 3, Npc.Y] = true; + + var point = BotEventMeetingPlanner.FindStandingPoint(terrain, Npc, 3); + + Assert.That(point, Is.EqualTo(new Point((byte)(Npc.X + 3), Npc.Y))); + } + + /// + /// Without any walkable tile in range there is no standing point - the bot gives up the meeting. + /// + [Test] + public void NoStandingPointWithoutWalkableTiles() + { + var terrain = CreateBlockedTerrain(); + + Assert.That(BotEventMeetingPlanner.FindStandingPoint(terrain, Npc, 3), Is.Null); + } + + /// + /// Chaos Castle has no entry NPC: the bot waits in the safezone of its home town. + /// + /// The task. + [Test] + public async ValueTask ChaosCastleMeetsInTheHomeTownAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var homeTown = new GameMapDefinition { Number = 3 }; + Mock.Get(bot.SelectedCharacter!.CharacterClass!).Setup(c => c.HomeMap).Returns(homeTown); + var untilUtc = new DateTime(2026, 11, 4, 20, 5, 0, DateTimeKind.Utc); + + var meeting = BotEventMeetingPlanner.TryPlan(bot, MiniGameType.ChaosCastle, untilUtc); + + Assert.That(meeting, Is.EqualTo(new BotEventMeeting(homeTown, null, untilUtc))); + } + + /// + /// Creates a terrain on which no tile is walkable and none is in the safezone - without data a + /// terrain is the walkable default map, which would hide what the test sets up. + /// + private static GameMapTerrain CreateBlockedTerrain() + { + var terrain = new GameMapTerrain((byte[]?)null); + Array.Clear(terrain.WalkMap); + Array.Clear(terrain.SafezoneMap); + return terrain; + } +} From 3e2e94fa3cce7cc85c94bb0e2fa95275582e29cc Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 14:38:42 +0100 Subject: [PATCH 03/44] feat(bots): answer whispers by their keywords A whisper reply line can now carry keywords - comma-separated words or phrases it answers, e.g. "how are you, how r u". The bot answers with a line whose keyword matches the whisper (whole words, ignoring case and punctuation, the longest match winning), and falls back to the replies without keywords when nothing matches. The default lines cover greetings, "how are you", the bot's level and map, party, thanks and goodbye. The bare "?" reply is replaced by "hi, what's up?". --- docs-website/docs/server-features/bots.md | 10 ++- src/GameLogic/Bots/BotChatComposer.cs | 63 +++++++++++++++++++ src/GameLogic/Bots/BotChatConfiguration.cs | 17 ++++- src/GameLogic/Bots/BotChatLine.cs | 13 ++++ src/GameLogic/Bots/BotChatPlugIn.cs | 2 +- .../Properties/PlugInResources.Designer.cs | 6 ++ src/GameLogic/Properties/PlugInResources.resx | 2 + tests/MUnique.OpenMU.Tests/BotChatTest.cs | 40 ++++++++++++ 8 files changed, 150 insertions(+), 3 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 940e1b1a9b..ae23c588fb 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -342,7 +342,7 @@ model. A bot only talks about what it could actually do: | A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post`, then it waits for players | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | -| A player whispered to the bot | whisper back, after a few seconds | the bot | +| A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody could find it. It goes to the meeting point and waits there until the entrance @@ -355,6 +355,14 @@ invites it, it goes back to hunting when the entrance closes. A bot does not announce an event whose entrance closes in less than two minutes, and a bot already in a party with a player does not look for company. +A whisper reply can carry `Keywords`, a comma-separated list of words or +phrases it answers (`how are you, how r u`). The bot picks among the replies +whose keyword matches the whisper — whole words, ignoring case and punctuation, +the longest match winning, so "hi, how are you?" gets an answer to the question +rather than a greeting. When nothing matches, it uses a reply without keywords. +The default lines cover greetings, "how are you", the level, where the bot is, +party, thanks and goodbye. + Placeholders in a line are filled in when it is said: `{event}`, `{map}`, `{level}`, and `{player}` (whisper replies). Each line is a localized text, so every player sees the translation of their own language, or the English text diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 516b67b124..da5b617f83 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -28,6 +28,34 @@ internal static class BotChatComposer return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } + /// + /// Picks the reply to a player's whisper: a random one of the lines whose keywords match the + /// message best, or - when no keyword matches - one of the replies without keywords. + /// + /// All configured lines. + /// The whispered message. + /// Returns a random index below the given count. + /// The picked line, or null when there is no usable reply. + /// + /// The best match is the longest matching keyword, so "hi, how are you?" is answered by the + /// "how are you" lines rather than by the greetings matching "hi". + /// + internal static BotChatLine? PickWhisperReply(IEnumerable lines, string message, Func pickIndex) + { + var replies = lines + .Where(line => line.Situation == BotChatSituation.WhisperReply && !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) + .ToList(); + var normalizedMessage = $" {Normalize(message)} "; + var scored = replies + .Select(line => (Line: line, Score: GetKeywords(line).Where(k => normalizedMessage.Contains($" {k} ", StringComparison.Ordinal)).Select(k => k.Length).DefaultIfEmpty(0).Max())) + .ToList(); + var bestScore = scored.Count == 0 ? 0 : scored.Max(s => s.Score); + var candidates = bestScore > 0 + ? scored.Where(s => s.Score == bestScore).Select(s => s.Line).ToList() + : replies.Where(line => !GetKeywords(line).Any()).ToList(); + return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; + } + /// /// Renders the line for a receiver of the given culture. /// @@ -59,4 +87,39 @@ internal static string Render(BotChatLine line, CultureInfo culture, IReadOnlyDi return result.ToString(); } + + private static IEnumerable GetKeywords(BotChatLine line) + => (line.Keywords ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Normalize) + .Where(keyword => keyword.Length > 0); + + /// + /// Lower-cases the text and turns everything except letters and digits into single spaces, so + /// "How are you?!" and "how are you" compare equal and keywords match as whole words. + /// + private static string Normalize(string text) + { + var builder = new StringBuilder(text.Length); + var pendingSpace = false; + foreach (var character in text) + { + if (char.IsLetterOrDigit(character)) + { + if (pendingSpace && builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(char.ToLowerInvariant(character)); + pendingSpace = false; + } + else + { + pendingSpace = true; + } + } + + return builder.ToString(); + } } diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 059cbe4ddb..d0bbdebeba 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -121,8 +121,15 @@ public static BotChatConfiguration CreateDefault() BotChatSituation.WhisperReply, "sry busy hunting", "hi, cant talk now", - "?", + "hi, what's up?", "afk a bit"); + AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); + AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); + AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); + AddReplies("where are you, where r u, where", "{map}", "in {map}"); + AddReplies("party, pt", "sure, invite me", "ok, send invite"); + AddReplies("thanks, thank you, thx, ty", "np", "no problem"); + AddReplies("bye, cya, see you, gn", "bye", "cya"); return configuration; void Add(BotChatSituation situation, params string[] texts) @@ -132,5 +139,13 @@ void Add(BotChatSituation situation, params string[] texts) configuration.Lines.Add(new BotChatLine { Situation = situation, Text = text }); } } + + void AddReplies(string keywords, params string[] texts) + { + foreach (var text in texts) + { + configuration.Lines.Add(new BotChatLine { Situation = BotChatSituation.WhisperReply, Text = text, Keywords = keywords }); + } + } } } diff --git a/src/GameLogic/Bots/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs index ad6031ffdd..cb3866a830 100644 --- a/src/GameLogic/Bots/BotChatLine.cs +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -29,6 +29,19 @@ public class BotChatLine [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] public LocalizedString Text { get; set; } + /// + /// Gets or sets the comma-separated words or phrases of a player's whisper this line answers, + /// e.g. how are you, how r u. Only used for ; + /// a reply without keywords is the fallback when no keyword matches. + /// + /// + /// The keywords are compared as whole words, ignoring case and punctuation, so hi doesn't + /// match "this". They may be written in several languages at once - the whisper's language is + /// not known. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Keywords_Name), Description = nameof(PlugInResources.BotChatLine_Keywords_Description))] + public string Keywords { get; set; } = string.Empty; + /// public override string ToString() => $"{this.Situation}: {this.Text.ValueInNeutralLanguage}"; } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index fed056fb34..0e2c354542 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -145,7 +145,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag || !IsBot(receiver) || sender is OfflinePlayer || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) - || PickLine(configuration, BotChatSituation.WhisperReply) is not { } line + || BotChatComposer.PickWhisperReply(configuration.Lines, message, count => Rand.NextInt(0, count)) is not { } line || !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 5cfdca3d81..e6c7e46952 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4633,6 +4633,12 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatLine_Text_Description text. public static string BotChatLine_Text_Description => ResourceManager.GetString("BotChatLine_Text_Description", resourceCulture)!; + /// Gets the localized BotChatLine_Keywords_Name text. + public static string BotChatLine_Keywords_Name => ResourceManager.GetString("BotChatLine_Keywords_Name", resourceCulture)!; + + /// Gets the localized BotChatLine_Keywords_Description text. + public static string BotChatLine_Keywords_Description => ResourceManager.GetString("BotChatLine_Keywords_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_EventPostChancePercent_Name text. public static string BotChatConfiguration_EventPostChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_EventPostChancePercent_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 08ab7380bc..c51c3d076c 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1923,4 +1923,6 @@ Minimum time between two whisper replies of the same bot. Lines The lines the bots choose from, per situation. A situation without lines stays silent. + Keywords + Whisper replies only: comma-separated words or phrases of a player's whisper which this line answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 6468ceb1db..2f3bc16a74 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -128,6 +128,46 @@ public void KeepsUnknownPlaceholders() Assert.That(BotChatComposer.Render(line, CultureInfo.InvariantCulture, NoValues), Is.EqualTo("{lvl} done")); } + /// + /// A whisper is answered by the lines whose keywords match it - ignoring case and punctuation, + /// preferring the longest match, and matching whole words only. Without a match, a reply + /// without keywords is used. + /// + /// The whispered message. + /// The expected reply. + [TestCase("How are you?!", "fine, thanks for asking")] + [TestCase("hi, how are you", "fine, thanks for asking")] + [TestCase("HI", "hi, what's up?")] + [TestCase("this is cool", "busy")] + [TestCase("asdf", "busy")] + public void AnswersWhispersByKeywords(string message, string expected) + { + var lines = new List + { + new() { Situation = BotChatSituation.WhisperReply, Text = "busy" }, + new() { Situation = BotChatSituation.WhisperReply, Text = "hi, what's up?", Keywords = "hi, hello" }, + new() { Situation = BotChatSituation.WhisperReply, Text = "fine, thanks for asking", Keywords = "how are you, how r u" }, + new() { Situation = BotChatSituation.LevelMilestone, Text = "not a reply", Keywords = "how are you" }, + }; + + var reply = BotChatComposer.PickWhisperReply(lines, message, _ => 0); + + Assert.That(reply?.Text.ValueInNeutralLanguage, Is.EqualTo(expected)); + } + + /// + /// The default configuration knows the basic small talk: a "how are you" gets a fitting answer. + /// + [Test] + public void DefaultRepliesAnswerHowAreYou() + { + var configuration = BotChatConfiguration.CreateDefault(); + + var reply = BotChatComposer.PickWhisperReply(configuration.Lines, "hey, how are you?", _ => 0); + + Assert.That(reply?.Keywords, Does.Contain("how are you")); + } + /// /// A server which switches the plugin on has something to say in every situation. /// From 4dfdbd99dc9a2a243db77798c4a28ca1476460bd Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:07:41 +0100 Subject: [PATCH 04/44] feat(bots): greet players entering the game When a player enters the game from the character selection (not after a warp), the nearest bot which sees the player greets them in the normal chat, e.g. "hi {player}, have a nice game". Without a bot in sight nobody greets. A player is greeted at most once per GreetingCooldown (30 minutes), so logging out and in again doesn't start a round of greetings; the chance is GreetingChancePercent (50 %), and the bot's own chat cooldown applies. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 20 +++++ src/GameLogic/Bots/BotChatPlugIn.cs | 72 ++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 8 ++ .../Properties/PlugInResources.Designer.cs | 12 +++ src/GameLogic/Properties/PlugInResources.resx | 4 + .../BotChatGreetingTest.cs | 76 +++++++++++++++++++ 7 files changed, 193 insertions(+) create mode 100644 tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index ae23c588fb..6e59b3141f 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -342,6 +342,7 @@ model. A bot only talks about what it could actually do: | A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post`, then it waits for players | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | +| A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index d0bbdebeba..0e1e9edb8b 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -82,6 +82,20 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplyCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplyCooldown_Description))] public TimeSpan WhisperReplyCooldown { get; set; } = TimeSpan.FromMinutes(1); + /// + /// Gets or sets the chance, in percent, that the nearest bot greets a player who enters the game. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingChancePercent_Description))] + public int GreetingChancePercent { get; set; } = 50; + + /// + /// Gets or sets the minimum time between two greetings of the same player, so logging out and in + /// again doesn't start a round of greetings. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingCooldown_Description))] + public TimeSpan GreetingCooldown { get; set; } = TimeSpan.FromMinutes(30); + /// /// Gets or sets the lines the bots choose from. A situation without lines stays silent. /// @@ -123,6 +137,12 @@ public static BotChatConfiguration CreateDefault() "hi, cant talk now", "hi, what's up?", "afk a bit"); + Add( + BotChatSituation.PlayerLogin, + "hi {player}, have a nice game", + "welcome back {player}", + "yo {player} :)", + "hey {player}"); AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 0e2c354542..d6a1c7a236 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -40,10 +40,17 @@ public class BotChatPlugIn IInvasionStartedPlugIn, ICharacterLevelUpPlugIn, IWhisperMessageReceivedPlugIn, + IPlayerStateChangedPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault { + /// + /// How close a bot must stand to greet an entering player - roughly the range in which the + /// player sees it, so the greeting comes from a character on the screen. + /// + private const double GreetingRange = 12; + /// /// How long an invasion counts as announced. Long enough that a second invasion of the same kind /// started by the same schedule run doesn't get its own post. @@ -67,6 +74,11 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) WhisperReplyDelaySeconds = (2, 7); + /// + /// The delay range of a greeting: the entering player's client needs a moment to load the world. + /// + private static readonly (int Min, int Max) GreetingDelaySeconds = (3, 8); + private readonly ConditionalWeakTable _throttles = new(); /// @@ -162,6 +174,23 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag : ValueTask.CompletedTask); } + /// + public ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + // Only entering the game counts - a warp or a map change keeps the player in the world. + if (this.Configuration is not { GreetingChancePercent: > 0 } configuration + || player is OfflinePlayer + || currentState != PlayerState.EnteredWorld + || previousState != PlayerState.CharacterSelection + || !Rand.NextRandomBool(configuration.GreetingChancePercent)) + { + return ValueTask.CompletedTask; + } + + RunLater(player.GameContext, RandomDelay(GreetingDelaySeconds), () => this.GreetAsync(player, configuration)); + return ValueTask.CompletedTask; + } + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the @@ -188,6 +217,23 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) && character.GetEffectiveMoveLevelRequirement(warp.LevelRequirement) <= level); } + /// + /// Finds the bot which greets an entering player: the nearest available bot on the player's map, + /// within the range in which the player sees it. + /// + /// The players of the game server. + /// The entering player. + /// The player's map. + /// The greeting bot, or null when none is close enough. + internal static Player? FindGreeter(IEnumerable players, Player player, GameMap map) + => players + .Where(p => IsBot(p) && IsAvailable(p) && ReferenceEquals(p.CurrentMap, map)) + .Select(p => (Bot: p, Distance: p.GetDistanceTo(player))) + .Where(candidate => candidate.Distance <= GreetingRange) + .OrderBy(candidate => candidate.Distance) + .Select(candidate => candidate.Bot) + .FirstOrDefault(); + private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; /// @@ -268,6 +314,32 @@ private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + private async ValueTask GreetAsync(Player player, BotChatConfiguration configuration) + { + if (player.PlayerState.CurrentState != PlayerState.EnteredWorld + || player.CurrentMap is not { } map + || player.SelectedCharacter?.Name is not { } playerName) + { + return; + } + + var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); + var bot = FindGreeter(players, player, map); + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(player.GameContext); + if (bot is null + || PickLine(configuration, BotChatSituation.PlayerLogin) is not { } line + || !throttle.TryReserveOccasion($"greeting {playerName}", now, configuration.GreetingCooldown) + || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => playerName; + await BotChatSpeaker.SayAsync(bot, line, values).ConfigureAwait(false); + } + private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) { var now = DateTime.UtcNow; diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index d9ffb075e7..c0cb4d7c82 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -37,4 +37,12 @@ public enum BotChatSituation /// Placeholders: {player}, {level}, {map}. /// WhisperReply, + + /// + /// A player entered the game (from the character selection, not after a warp). The nearest bot + /// which sees the player greets them in the normal chat. + /// Placeholders: {player}, {level}, {map}. + /// + /// New situations are appended: the configuration stores the situation as a number. + PlayerLogin, } diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index e6c7e46952..7ae07b893f 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4699,6 +4699,18 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_Lines_Description text. public static string BotChatConfiguration_Lines_Description => ResourceManager.GetString("BotChatConfiguration_Lines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_GreetingChancePercent_Name text. + public static string BotChatConfiguration_GreetingChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_GreetingChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_GreetingChancePercent_Description text. + public static string BotChatConfiguration_GreetingChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_GreetingChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_GreetingCooldown_Name text. + public static string BotChatConfiguration_GreetingCooldown_Name => ResourceManager.GetString("BotChatConfiguration_GreetingCooldown_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_GreetingCooldown_Description text. + public static string BotChatConfiguration_GreetingCooldown_Description => ResourceManager.GetString("BotChatConfiguration_GreetingCooldown_Description", resourceCulture)!; + /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index c51c3d076c..9f1d1a54d4 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1925,4 +1925,8 @@ The lines the bots choose from, per situation. A situation without lines stays silent. Keywords Whisper replies only: comma-separated words or phrases of a player's whisper which this line answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. + Greeting chance (%) + Chance that the nearest bot greets a player who enters the game, in the normal chat. 0 = never. + Greeting cooldown per player + Minimum time between two greetings of the same player, so logging out and in again doesn't start a round of greetings. diff --git a/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs new file mode 100644 index 0000000000..56f8f8d98a --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs @@ -0,0 +1,76 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests which bot greets a player entering the game (): the +/// greeting must come from a character the player sees on the screen. +/// +[TestFixture] +public class BotChatGreetingTest +{ + private static readonly Point PlayerPosition = new(100, 100); + + /// + /// The nearest bot on the player's map greets; a closer bot on another map doesn't count. + /// + /// The task. + [Test] + public async ValueTask NearestBotOnTheMapGreetsAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var map = new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8); + var otherMap = new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8); + var player = await CreateHumanAsync(gameContext, map).ConfigureAwait(false); + var near = await CreateBotAsync(gameContext, map, new Point(103, 100)).ConfigureAwait(false); + var farther = await CreateBotAsync(gameContext, map, new Point(106, 100)).ConfigureAwait(false); + var elsewhere = await CreateBotAsync(gameContext, otherMap, new Point(101, 100)).ConfigureAwait(false); + + var greeter = BotChatPlugIn.FindGreeter([player, farther, elsewhere, near], player, map); + + Assert.That(greeter, Is.SameAs(near)); + } + + /// + /// Without a bot in sight, nobody greets - a greeting from off the screen would give the bots away. + /// + /// The task. + [Test] + public async ValueTask NobodyGreetsFromOutOfSightAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var map = new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8); + var player = await CreateHumanAsync(gameContext, map).ConfigureAwait(false); + var far = await CreateBotAsync(gameContext, map, new Point(130, 100)).ConfigureAwait(false); + + Assert.That(BotChatPlugIn.FindGreeter([player, far], player, map), Is.Null); + } + + private static async ValueTask CreateHumanAsync(IGameContext gameContext, GameMap map) + { + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + player.SetCurrentMapSilently(map); + player.Position = PlayerPosition; + return player; + } + + private static async ValueTask CreateBotAsync(IGameContext gameContext, GameMap map, Point position) + { + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + bot.IsAlive = true; + bot.Account!.IsBot = true; + bot.SetCurrentMapSilently(map); + bot.Position = position; + return bot; + } +} From 3ffb6a3ec969d3f8c8dc4fc03e747de4039eefa8 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:10:51 +0100 Subject: [PATCH 05/44] feat(bots): talk in the party chat of a player A bot which accepts a player's party invitation thanks for it in the party chat ("thx for invite"), and after an event the bot went through with a player's party, one bot of that party says "gg". The chance is PartyChatChancePercent (70 %); the bot's chat cooldown applies. The accepted invitation is reported through the new IBotPartyJoinedPlugIn plugin point, called by BotPartyHandler. --- docs-website/docs/server-features/bots.md | 2 + src/GameLogic/Bots/BotChatConfiguration.cs | 20 +++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 56 +++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 13 +++++ src/GameLogic/Bots/BotChatSpeaker.cs | 19 +++++++ src/GameLogic/Bots/BotPartyHandler.cs | 1 + src/GameLogic/Bots/IBotPartyJoinedPlugIn.cs | 24 ++++++++ .../Properties/PlugInResources.Designer.cs | 6 ++ src/GameLogic/Properties/PlugInResources.resx | 2 + 9 files changed, 143 insertions(+) create mode 100644 src/GameLogic/Bots/IBotPartyJoinedPlugIn.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 6e59b3141f..488f969940 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -343,6 +343,8 @@ model. A bot only talks about what it could actually do: | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | +| The bot accepted a player's party invitation | party chat, `{player}` | the bot | +| An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 0e1e9edb8b..c61a53b02f 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -96,6 +96,14 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingCooldown_Description))] public TimeSpan GreetingCooldown { get; set; } = TimeSpan.FromMinutes(30); + /// + /// Gets or sets the chance, in percent, that a bot says something in the party chat of a player: + /// thanks for the invitation, "gg" after an event. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Description))] + public int PartyChatChancePercent { get; set; } = 70; + /// /// Gets or sets the lines the bots choose from. A situation without lines stays silent. /// @@ -143,6 +151,18 @@ public static BotChatConfiguration CreateDefault() "welcome back {player}", "yo {player} :)", "hey {player}"); + Add( + BotChatSituation.PartyJoined, + "thx for invite", + "hi all", + "ty {player}", + "hey, lets go"); + Add( + BotChatSituation.EventFinished, + "gg", + "gg wp", + "gg, thx for party", + "nice run"); AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index d6a1c7a236..7135895719 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -41,6 +41,8 @@ public class BotChatPlugIn ICharacterLevelUpPlugIn, IWhisperMessageReceivedPlugIn, IPlayerStateChangedPlugIn, + IBotPartyJoinedPlugIn, + IMiniGameEndedPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -79,6 +81,11 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) GreetingDelaySeconds = (3, 8); + /// + /// The delay range of a party chat line - after the party window or the event's score popped up. + /// + private static readonly (int Min, int Max) PartyChatDelaySeconds = (2, 6); + private readonly ConditionalWeakTable _throttles = new(); /// @@ -191,6 +198,55 @@ public ValueTask PlayerStateChangedAsync(Player player, State previousState, Sta return ValueTask.CompletedTask; } + /// + public void BotJoinedParty(Player bot, Player inviter) + { + if (this.Configuration is not { PartyChatChancePercent: > 0 } configuration + || !Rand.NextRandomBool(configuration.PartyChatChancePercent) + || PickLine(configuration, BotChatSituation.PartyJoined) is not { } line + || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => inviterName; + RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line, values)); + } + + /// + public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IReadOnlyCollection finishers) + { + if (this.Configuration is not { PartyChatChancePercent: > 0 } configuration) + { + return ValueTask.CompletedTask; + } + + // One bot per party with a player: a whole chorus of "gg" from the bots would give them away. + var speakers = finishers + .Where(p => IsBot(p) && p.Party is not null && BotPartyHandler.HasHumanCompanion(p)) + .GroupBy(p => p.Party) + .Select(party => party.First()) + .ToList(); + var definition = miniGame.Definition; + foreach (var bot in speakers) + { + if (!Rand.NextRandomBool(configuration.PartyChatChancePercent) + || PickLine(configuration, BotChatSituation.EventFinished) is not { } line + || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) + { + continue; + } + + var values = CreateBotValues(bot); + values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; + RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line, values)); + } + + return ValueTask.CompletedTask; + } + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index c0cb4d7c82..64057a7b82 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -45,4 +45,17 @@ public enum BotChatSituation /// /// New situations are appended: the configuration stores the situation as a number. PlayerLogin, + + /// + /// The bot accepted a player's party invitation. Said in the party chat. + /// Placeholders: {player} (the inviter), {level}, {map}. + /// + PartyJoined, + + /// + /// An event (Blood Castle, Devil Square, Chaos Castle) the bot went through with a player's party + /// ended. One bot per party says it in the party chat. + /// Placeholders: {event}, {level}. + /// + EventFinished, } diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs index b41300a6cd..9b2e4ff9c9 100644 --- a/src/GameLogic/Bots/BotChatSpeaker.cs +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -57,6 +57,25 @@ internal static ValueTask WhisperAsync(Player bot, Player receiver, BotChatLine return SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Whisper); } + /// + /// Says the line in the party chat, to the players of the bot's party. + /// + /// The speaking bot. + /// The line. + /// The placeholder values. + internal static async ValueTask PartySayAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + { + if (bot.SelectedCharacter?.Name is not { } name || bot.Party is not { } party) + { + return; + } + + foreach (var receiver in party.PartyList.OfType()) + { + await SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); + } + } + /// /// Posts the line to all players of the game server, in the format of the /post command. /// diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index 11cba2102f..fea60b9454 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -187,6 +187,7 @@ private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player r if (success) { bot.Logger.LogDebug("Bot '{Name}' joined the party of '{Requester}'.", bot.Name, requester.Name); + bot.GameContext.PlugInManager.GetPlugInPoint()?.BotJoinedParty(bot, requester); } } diff --git a/src/GameLogic/Bots/IBotPartyJoinedPlugIn.cs b/src/GameLogic/Bots/IBotPartyJoinedPlugIn.cs new file mode 100644 index 0000000000..5a25de18d5 --- /dev/null +++ b/src/GameLogic/Bots/IBotPartyJoinedPlugIn.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// Plugins which are called when a server-side bot accepted the party invitation of a player +/// (see ). +/// +[Guid("224C6C87-3028-4A8B-8785-ECB0E2D3A0D7")] +[PlugInPoint("Bot joined a player's party", "Plugins which are called when a server-side bot accepted the party invitation of a player.")] +public interface IBotPartyJoinedPlugIn +{ + /// + /// Is called when the bot joined the party of the inviting player. + /// + /// The bot. + /// The player whose invitation the bot accepted. + void BotJoinedParty(Player bot, Player inviter); +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 7ae07b893f..f9705ce59e 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4711,6 +4711,12 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_GreetingCooldown_Description text. public static string BotChatConfiguration_GreetingCooldown_Description => ResourceManager.GetString("BotChatConfiguration_GreetingCooldown_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_PartyChatChancePercent_Name text. + public static string BotChatConfiguration_PartyChatChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_PartyChatChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyChatChancePercent_Description text. + public static string BotChatConfiguration_PartyChatChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_PartyChatChancePercent_Description", resourceCulture)!; + /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 9f1d1a54d4..065936694a 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1929,4 +1929,6 @@ Chance that the nearest bot greets a player who enters the game, in the normal chat. 0 = never. Greeting cooldown per player Minimum time between two greetings of the same player, so logging out and in again doesn't start a round of greetings. + Party chat chance (%) + Chance that a bot says something in the party chat of a player: thanks for the invitation, "gg" after an event. 0 = never. From d88039b569487765fe3d9912c1d85b6411f6ade9 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:14:17 +0100 Subject: [PATCH 06/44] feat(bots): comment on being killed by a player A bot killed by a player outside of an event says something about it in the normal chat while it still lies there ("why??", "wtf {player}"). When the same player kills it again within the revenge memory - the point at which the bot gives up its revenge - it says a resigned line instead ("you again..."). The chance is KilledByPlayerChancePercent (70 %); the bot's chat cooldown applies. OfflinePlayer exposes the recent death count per killer for this. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 19 +++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 41 +++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 12 ++++++ src/GameLogic/Offline/OfflinePlayer.cs | 12 +++++- .../Properties/PlugInResources.Designer.cs | 6 +++ src/GameLogic/Properties/PlugInResources.resx | 2 + .../BotRecentDeathCountTest.cs | 41 +++++++++++++++++++ 8 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/MUnique.OpenMU.Tests/BotRecentDeathCountTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 488f969940..2a612f4fa2 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -345,6 +345,7 @@ model. A bot only talks about what it could actually do: | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | | The bot accepted a player's party invitation | party chat, `{player}` | the bot | | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | +| A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index c61a53b02f..42dabab0d2 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -104,6 +104,13 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Description))] public int PartyChatChancePercent { get; set; } = 70; + /// + /// Gets or sets the chance, in percent, that a bot killed by a player says something about it. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledByPlayerChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledByPlayerChancePercent_Description))] + public int KilledByPlayerChancePercent { get; set; } = 70; + /// /// Gets or sets the lines the bots choose from. A situation without lines stays silent. /// @@ -163,6 +170,18 @@ public static BotChatConfiguration CreateDefault() "gg wp", "gg, thx for party", "nice run"); + Add( + BotChatSituation.KilledByPlayer, + "why??", + "wtf {player}", + "really?", + "ok {player}, remember that"); + Add( + BotChatSituation.KilledAgainByPlayer, + "you again...", + "ok ok, i leave", + "{player} pls stop", + "enough, im out"); AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 7135895719..5b21be2b75 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -43,6 +43,7 @@ public class BotChatPlugIn IPlayerStateChangedPlugIn, IBotPartyJoinedPlugIn, IMiniGameEndedPlugIn, + IAttackableGotKilledPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -86,6 +87,12 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) PartyChatDelaySeconds = (2, 6); + /// + /// The delay range of a comment on being killed: said while the bot still lies there, before it + /// respawns, so the killer sees it. + /// + private static readonly (int Min, int Max) KilledDelaySeconds = (1, 2); + private readonly ConditionalWeakTable _throttles = new(); /// @@ -247,6 +254,40 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR return ValueTask.CompletedTask; } + /// + public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) + { + // Same cases as the revenge (BotRevengePlugIn): a human player's kill outside of an event, + // where dying is part of the game. + if (this.Configuration is not { KilledByPlayerChancePercent: > 0 } configuration + || killed is not OfflinePlayer bot + || !IsBot(bot) + || bot.CurrentMiniGame is not null + || killer is not Player killerPlayer + || killerPlayer is OfflinePlayer + || !Rand.NextRandomBool(configuration.KilledByPlayerChancePercent) + || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) + { + return ValueTask.CompletedTask; + } + + RunLater(bot.GameContext, RandomDelay(KilledDelaySeconds), () => + { + // Read after the delay: the revenge plugin registers the death in the same notification. + var situation = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? BotChatSituation.KilledAgainByPlayer : BotChatSituation.KilledByPlayer; + if (PickLine(configuration, situation) is not { } line) + { + return ValueTask.CompletedTask; + } + + var values = CreateBotValues(bot); + var killerName = killerPlayer.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => killerName; + return BotChatSpeaker.SayAsync(bot, line, values); + }); + return ValueTask.CompletedTask; + } + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index 64057a7b82..b14f28d2e5 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -58,4 +58,16 @@ public enum BotChatSituation /// Placeholders: {event}, {level}. /// EventFinished, + + /// + /// A player killed the bot (outside of an event). Said in the normal chat, right before the bot + /// respawns. Placeholders: {player} (the killer), {level}, {map}. + /// + KilledByPlayer, + + /// + /// The same player killed the bot again within a short time - the bot gives up its revenge + /// (see ). Placeholders as for . + /// + KilledAgainByPlayer, } diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 65bd03ae17..d95580210e 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -19,7 +19,7 @@ public class OfflinePlayer : Player /// A player who killed this bot this many times gets no (further) revenge: walking back a third /// time into the same lost fight would just be a death loop feeding the killer free kills. /// - private const int RepeatedKillThreshold = 2; + internal const int RepeatedKillThreshold = 2; /// /// How long an attack by a player stays "hot" as a self-defense target, counted from the LAST hit @@ -259,6 +259,16 @@ internal void RegisterAggressor(Player aggressor) this._aggression = new Aggression(aggressor, DateTime.UtcNow); } + /// + /// Gets how often the player killed this bot recently (within the memory of the revenge logic). + /// + /// The killing player. + /// The number of recent deaths by this player; 0 when none is remembered. + internal int GetRecentDeathCount(Player killer) + => this._deathsByKiller.TryGetValue(killer.Name, out var record) && DateTime.UtcNow - record.LastDeathUtc <= DeathCountMemory + ? record.Count + : 0; + /// /// Registers that a (human) player killed this bot. The first kill makes a revenge pending: after /// respawning on the same map, the bot marches back to the place of its death (driven by the diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index f9705ce59e..6d2df3e7c2 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4717,6 +4717,12 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_PartyChatChancePercent_Description text. public static string BotChatConfiguration_PartyChatChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_PartyChatChancePercent_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_KilledByPlayerChancePercent_Name text. + public static string BotChatConfiguration_KilledByPlayerChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_KilledByPlayerChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_KilledByPlayerChancePercent_Description text. + public static string BotChatConfiguration_KilledByPlayerChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_KilledByPlayerChancePercent_Description", resourceCulture)!; + /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 065936694a..84d7ee7100 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1931,4 +1931,6 @@ Minimum time between two greetings of the same player, so logging out and in again doesn't start a round of greetings. Party chat chance (%) Chance that a bot says something in the party chat of a player: thanks for the invitation, "gg" after an event. 0 = never. + Killed by a player chance (%) + Chance that a bot killed by a player (outside of an event) says something about it in the normal chat, before it respawns. 0 = never. diff --git a/tests/MUnique.OpenMU.Tests/BotRecentDeathCountTest.cs b/tests/MUnique.OpenMU.Tests/BotRecentDeathCountTest.cs new file mode 100644 index 0000000000..bde890fbcc --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotRecentDeathCountTest.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// Tests how a bot remembers who killed it () - the bot +/// chat answers a repeated kill differently ("you again..."). +/// +[TestFixture] +public class BotRecentDeathCountTest +{ + /// + /// The count grows with every kill by the same player and stays separate per killer. + /// + /// The task. + [Test] + public async ValueTask CountsKillsPerPlayerAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + bot.SetCurrentMapSilently(new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8)); + var killer = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + killer.SelectedCharacter!.Name = "Killer"; + var other = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + other.SelectedCharacter!.Name = "Other"; + + Assert.That(bot.GetRecentDeathCount(killer), Is.Zero); + + bot.RegisterDeathByPlayer(killer); + bot.RegisterDeathByPlayer(killer); + + Assert.That(bot.GetRecentDeathCount(killer), Is.EqualTo(OfflinePlayer.RepeatedKillThreshold)); + Assert.That(bot.GetRecentDeathCount(other), Is.Zero); + } +} From 60f4982a867af7f14444fbbdd57e05d649793693 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:17:06 +0100 Subject: [PATCH 07/44] feat(bots): answer when mentioned in the normal chat When a player writes the name of a bot which sees them in the normal chat ("Serael, how are you?"), the bot answers in the normal chat with the same lines and keywords as a whisper reply. The name must be a whole word; the whisper reply chance and cooldown apply. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatComposer.cs | 11 ++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 43 +++++++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 4 ++- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 16 +++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 2a612f4fa2..696eaaa4a1 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -346,6 +346,7 @@ model. A bot only talks about what it could actually do: | The bot accepted a player's party invitation | party chat, `{player}` | the bot | | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | +| A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index da5b617f83..94c52a000d 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -88,6 +88,17 @@ internal static string Render(BotChatLine line, CultureInfo culture, IReadOnlyDi return result.ToString(); } + /// + /// Determines whether the message mentions the name as a whole word, ignoring case and + /// punctuation - "hi Serael!" mentions Serael, "Seraelion" does not. + /// + /// The chat message. + /// The character name. + /// true, if the message mentions the name. + internal static bool MentionsName(string message, string name) + => Normalize(name) is { Length: > 0 } normalizedName + && $" {Normalize(message)} ".Contains($" {normalizedName} ", StringComparison.Ordinal); + private static IEnumerable GetKeywords(BotChatLine line) => (line.Keywords ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 5b21be2b75..55090364d9 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -14,6 +14,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.PlugIns; /// @@ -44,6 +45,7 @@ public class BotChatPlugIn IBotPartyJoinedPlugIn, IMiniGameEndedPlugIn, IAttackableGotKilledPlugIn, + IChatMessageSentPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -288,6 +290,47 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) return ValueTask.CompletedTask; } + /// + public async ValueTask ChatMessageSentAsync(Player sender, string message, ChatMessageType messageType, Player? receiver) + { + if (this.Configuration is not { WhisperReplyChancePercent: > 0 } configuration + || messageType != ChatMessageType.Normal + || sender is OfflinePlayer + || sender.SelectedCharacter?.Name is not { } senderName) + { + return; + } + + // Only a bot which sees the speaker (and so got the message) may feel addressed. + var mentionedBots = new List(); + await sender.ForEachObservingAsync( + observer => + { + if (IsBot(observer) && observer.SelectedCharacter?.Name is { } botName && BotChatComposer.MentionsName(message, botName)) + { + mentionedBots.Add(observer); + } + + return ValueTask.CompletedTask; + }, + false).ConfigureAwait(false); + + if (mentionedBots.FirstOrDefault(IsAvailable) is not { } bot + || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) + || BotChatComposer.PickWhisperReply(configuration.Lines, message, count => Rand.NextInt(0, count)) is not { } line + || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => senderName; + RunLater( + bot.GameContext, + RandomDelay(WhisperReplyDelaySeconds), + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line, values) : ValueTask.CompletedTask); + } + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index b14f28d2e5..a9bcd69b5e 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -33,7 +33,9 @@ public enum BotChatSituation LevelMilestone, /// - /// A human player whispered to the bot. The bot whispers back. + /// A human player whispered to the bot, or mentioned its name in the normal chat next to it. The + /// bot answers the same way: by whisper, or in the normal chat. A line may carry + /// . /// Placeholders: {player}, {level}, {map}. /// WhisperReply, diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 2f3bc16a74..d6f478d31c 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -155,6 +155,22 @@ public void AnswersWhispersByKeywords(string message, string expected) Assert.That(reply?.Text.ValueInNeutralLanguage, Is.EqualTo(expected)); } + /// + /// A bot feels addressed when its name is in a chat message as a whole word - not when it is only + /// a part of a longer word. + /// + /// The chat message. + /// Whether the message mentions the bot. + [TestCase("hi Serael!", true)] + [TestCase("SERAEL how are you", true)] + [TestCase("serael,where r u", true)] + [TestCase("Seraelion is strong", false)] + [TestCase("hello all", false)] + public void RecognizesTheBotsName(string message, bool expected) + { + Assert.That(BotChatComposer.MentionsName(message, "Serael"), Is.EqualTo(expected)); + } + /// /// The default configuration knows the basic small talk: a "how are you" gets a fitting answer. /// From bf614d513580c048bc65c31625f0cf89cbb89937 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:21:53 +0100 Subject: [PATCH 08/44] feat(bots): make small talk in town Every SmallTalkInterval (10 minutes) per game server, with SmallTalkChancePercent (50 %), a bot standing in a safezone says something in the normal chat ("lf party {map}", "lag?"). Only a bot which a player sees talks - talking to nobody would be wasted. The lines are kept neutral on purpose: bots don't trade with players, so they don't offer anything for sale. A forced start of the plugin from the admin panel makes the small talk due at once. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 21 +++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 61 +++++++++++++++++++ src/GameLogic/Bots/BotChatSituation.cs | 7 +++ src/GameLogic/Bots/BotChatThrottle.cs | 41 +++++++++++++ .../Properties/PlugInResources.Designer.cs | 12 ++++ src/GameLogic/Properties/PlugInResources.resx | 4 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 17 ++++++ 8 files changed, 164 insertions(+) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 696eaaa4a1..5179e710ac 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -347,6 +347,7 @@ model. A bot only talks about what it could actually do: | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | | A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | +| Now and then (every 10 minutes per game server, 50 % chance) | normal chat | a bot standing in a safezone which a player sees | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 42dabab0d2..ffac5b3f77 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -111,6 +111,19 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledByPlayerChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledByPlayerChancePercent_Description))] public int KilledByPlayerChancePercent { get; set; } = 70; + /// + /// Gets or sets how often a bot in a safezone may make small talk, per game server. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkInterval_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkInterval_Description))] + public TimeSpan SmallTalkInterval { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Gets or sets the chance, in percent, that a bot makes small talk when the interval is over. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Description))] + public int SmallTalkChancePercent { get; set; } = 50; + /// /// Gets or sets the lines the bots choose from. A situation without lines stays silent. /// @@ -182,6 +195,14 @@ public static BotChatConfiguration CreateDefault() "ok ok, i leave", "{player} pls stop", "enough, im out"); + Add( + BotChatSituation.SmallTalk, + "lf party {map}", + "lag?", + "need a party for exp", + "anyone up for bc later?", + "so many people today", + "gm online?"); AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 55090364d9..e6ba9f9dc2 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MiniGames; using MUnique.OpenMU.GameLogic.Offline; @@ -46,6 +47,7 @@ public class BotChatPlugIn IMiniGameEndedPlugIn, IAttackableGotKilledPlugIn, IChatMessageSentPlugIn, + IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -97,6 +99,12 @@ public class BotChatPlugIn private readonly ConditionalWeakTable _throttles = new(); + /// + /// The number of calls of ; a server which didn't see the current number + /// yet makes its small talk without waiting for the interval. + /// + private int _forceStartCount; + /// public BotChatConfiguration? Configuration { get; set; } @@ -331,6 +339,43 @@ await sender.ForEachObservingAsync( () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line, values) : ValueTask.CompletedTask); } + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + if (this.Configuration is not { SmallTalkChancePercent: > 0 } configuration + || !this.GetThrottle(gameContext).TryStartSmallTalk(DateTime.UtcNow, configuration.SmallTalkInterval, Volatile.Read(ref this._forceStartCount)) + || !Rand.NextRandomBool(configuration.SmallTalkChancePercent)) + { + return; + } + + var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); + var candidates = new List(); + foreach (var bot in players.Where(p => IsBot(p) && IsAvailable(p) && IsInSafezone(p))) + { + // Talking to nobody would be wasted: only a bot which a player sees may talk. + if (await HasHumanObserverAsync(bot).ConfigureAwait(false)) + { + candidates.Add(bot); + } + } + + if (candidates.Count == 0 + || PickLine(configuration, BotChatSituation.SmallTalk) is not { } line) + { + return; + } + + var speaker = candidates[Rand.NextInt(0, candidates.Count)]; + if (this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) + { + await BotChatSpeaker.SayAsync(speaker, line, CreateBotValues(speaker)).ConfigureAwait(false); + } + } + + /// + public void ForceStart() => Interlocked.Increment(ref this._forceStartCount); + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the @@ -396,6 +441,22 @@ private static bool IsAvailable(Player bot) && bot.IsAlive && bot.CurrentMiniGame is null; + private static bool IsInSafezone(Player bot) + => bot.CurrentMap is { } map && map.Terrain.SafezoneMap[bot.Position.X, bot.Position.Y]; + + private static async ValueTask HasHumanObserverAsync(Player bot) + { + var seen = false; + await bot.ForEachObservingAsync( + observer => + { + seen |= observer is not OfflinePlayer; + return ValueTask.CompletedTask; + }, + false).ConfigureAwait(false); + return seen; + } + private static int GetLevel(Player player) => (int)(player.Attributes?[Stats.Level] ?? 0); private static bool IsPostCommandActive(IGameContext gameContext) diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs index a9bcd69b5e..5eed90b738 100644 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ b/src/GameLogic/Bots/BotChatSituation.cs @@ -72,4 +72,11 @@ public enum BotChatSituation /// (see ). Placeholders as for . /// KilledAgainByPlayer, + + /// + /// Now and then (see ) a bot standing in a + /// safezone says something in the normal chat - only when a player sees it. + /// Placeholders: {level}, {map}. + /// + SmallTalk, } diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 04d7dfe6e6..157fc4837b 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -19,12 +19,24 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// internal sealed class BotChatThrottle { + /// + /// The shortest gap between two small talks, so an interval of zero doesn't make the bots talk + /// every second. + /// + private static readonly TimeSpan MinimumSmallTalkInterval = TimeSpan.FromSeconds(10); + private readonly ConcurrentDictionary _occasions = new(); private readonly ConditionalWeakTable _bots = new(); + private readonly Lock _smallTalkLock = new(); + private long _nextPostTicks; + private DateTime _nextSmallTalkUtc; + + private int _handledForceStartCount; + /// /// Reserves the right to announce an occasion (e.g. "Blood Castle opened") for the given time /// window. Only the first caller within the window gets it. @@ -84,6 +96,35 @@ public bool TryReserveChat(Player bot, DateTime now, TimeSpan cooldown) public bool TryReserveWhisperReply(Player bot, DateTime now, TimeSpan cooldown) => TryReserve(ref this._bots.GetOrCreateValue(bot).NextWhisperReplyTicks, now, cooldown); + /// + /// Determines whether the next small talk of this server is due, and if so, schedules the one + /// after it. A forced start (from the admin panel) makes it due at once. + /// + /// The current time. + /// The configured interval between two small talks. + /// The current number of forced starts of the plugin. + /// true, if a bot may make small talk now. + public bool TryStartSmallTalk(DateTime now, TimeSpan interval, int forceStartCount) + { + lock (this._smallTalkLock) + { + if (this._nextSmallTalkUtc == default) + { + // A freshly started server waits one interval first - the bots are just logging in. + this._nextSmallTalkUtc = now + interval; + } + + if (now < this._nextSmallTalkUtc && forceStartCount == this._handledForceStartCount) + { + return false; + } + + this._handledForceStartCount = forceStartCount; + this._nextSmallTalkUtc = now + (interval > MinimumSmallTalkInterval ? interval : MinimumSmallTalkInterval); + return true; + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 6d2df3e7c2..ec45bfc047 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4723,6 +4723,18 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_KilledByPlayerChancePercent_Description text. public static string BotChatConfiguration_KilledByPlayerChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_KilledByPlayerChancePercent_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_SmallTalkInterval_Name text. + public static string BotChatConfiguration_SmallTalkInterval_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkInterval_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_SmallTalkInterval_Description text. + public static string BotChatConfiguration_SmallTalkInterval_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkInterval_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_SmallTalkChancePercent_Name text. + public static string BotChatConfiguration_SmallTalkChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_SmallTalkChancePercent_Description text. + public static string BotChatConfiguration_SmallTalkChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkChancePercent_Description", resourceCulture)!; + /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 84d7ee7100..9788f2294d 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1933,4 +1933,8 @@ Chance that a bot says something in the party chat of a player: thanks for the invitation, "gg" after an event. 0 = never. Killed by a player chance (%) Chance that a bot killed by a player (outside of an event) says something about it in the normal chat, before it respawns. 0 = never. + Small talk interval + How often a bot standing in a safezone may say something in the normal chat, per game server. Only a bot which a player sees talks. + Small talk chance (%) + Chance that a bot makes small talk when the interval is over. 0 = never. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index d6f478d31c..71aad74e5a 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -51,6 +51,23 @@ public void AnOccasionIsAnnouncedOncePerWindow() Assert.That(throttle.TryReserveOccasion("mini game BloodCastle", Start.AddMinutes(5), window), Is.True); } + /// + /// Small talk happens once per interval - a freshly started server waits one interval first, + /// while the bots log in - and a forced start from the admin panel makes it due at once. + /// + [Test] + public void SmallTalkFollowsTheIntervalOrAForcedStart() + { + var throttle = new BotChatThrottle(); + var interval = TimeSpan.FromMinutes(10); + + Assert.That(throttle.TryStartSmallTalk(Start, interval, 0), Is.False); + Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(10), interval, 0), Is.True); + Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(11), interval, 0), Is.False); + Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(11), interval, 1), Is.True); + Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(12), interval, 1), Is.False); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From ae328ff76bc927d6b2ce3441388cee44b3f8e6aa Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 15:25:29 +0100 Subject: [PATCH 09/44] feat(bots): ship the default chat lines in Polish too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every default bot chat line now carries a Polish translation, so Polish players see the bots talk in Polish without the server owner writing the translations first. Event, map and item names stay English, as the game data names them. The default reply keywords also understand Polish whispers, with and without diacritics ("jak się masz", "jak sie masz"). --- docs-website/docs/server-features/bots.md | 3 +- src/GameLogic/Bots/BotChatConfiguration.cs | 137 +++++++++++++-------- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 17 +++ 3 files changed, 103 insertions(+), 54 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 5179e710ac..a536cbb463 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -372,7 +372,8 @@ party, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, `{level}`, and `{player}` (whisper replies). Each line is a localized text, so every player sees the translation of their own language, or the English text -when it has none. +when it has none. The default lines come with a Polish translation, and the default +keywords understand Polish whispers too. The limits keep a large population from flooding the chat. All bots of a game server share one gap between two posts (`Minimum time between posts`, 3 minutes), diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index ffac5b3f77..49aefef0d2 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -4,7 +4,9 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using System.Globalization; using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.Interfaces; /// /// Configuration of the : what the server-side bots say, and how often. @@ -133,99 +135,128 @@ public class BotChatConfiguration public ICollection Lines { get; set; } = new List(); /// - /// Creates the configuration a server starts with, including a small set of English lines. + /// Creates the configuration a server starts with: a set of English lines, each with a Polish + /// translation. /// /// The default configuration. /// /// The lines are configuration data like the names of the seeded items, not texts of the game - /// logic: a server owner is expected to rewrite them and to add translations. + /// logic: a server owner is expected to rewrite them and to add translations. Event, map and item + /// names stay English in the Polish lines - the game data names them that way. The keywords hold + /// the Polish phrases with and without diacritics, since players often type without them. /// public static BotChatConfiguration CreateDefault() { var configuration = new BotChatConfiguration(); Add( BotChatSituation.EventEntranceOpened, - "{event} open, anyone going? im in {map}", - "lf party {event}, waiting in {map}", - "{event} now, who wants to join? {map}", - "going {event}, party anyone? at {map}"); + ("{event} open, anyone going? im in {map}", "{event} otwarte, ktoś idzie? jestem w {map}"), + ("lf party {event}, waiting in {map}", "szukam grupy na {event}, czekam w {map}"), + ("{event} now, who wants to join? {map}", "{event} teraz, kto chętny? {map}"), + ("going {event}, party anyone? at {map}", "idę na {event}, ktoś do grupy? {map}")); Add( BotChatSituation.InvasionStarted, - "{event} in {map}!", - "{event} on {map}, go go", - "{map} - {event} started"); + ("{event} in {map}!", "{event} w {map}!"), + ("{event} on {map}, go go", "{event} na {map}, lecimy"), + ("{map} - {event} started", "{map} - start: {event}")); Add( BotChatSituation.LevelMilestone, - "{level} finally :)", - "lvl {level}, nice", - "{level} done"); + ("{level} finally :)", "{level} w końcu :)"), + ("lvl {level}, nice", "lvl {level}, nieźle"), + ("{level} done", "{level} wbite")); Add( BotChatSituation.WhisperReply, - "sry busy hunting", - "hi, cant talk now", - "hi, what's up?", - "afk a bit"); + ("sry busy hunting", "sry, poluję"), + ("hi, cant talk now", "hej, nie mogę teraz gadać"), + ("hi, what's up?", "hej, co tam?"), + ("afk a bit", "chwilę afk")); Add( BotChatSituation.PlayerLogin, - "hi {player}, have a nice game", - "welcome back {player}", - "yo {player} :)", - "hey {player}"); + ("hi {player}, have a nice game", "siema {player}, miłej gry"), + ("welcome back {player}", "witaj z powrotem {player}"), + ("yo {player} :)", "yo {player} :)"), + ("hey {player}", "hej {player}")); Add( BotChatSituation.PartyJoined, - "thx for invite", - "hi all", - "ty {player}", - "hey, lets go"); + ("thx for invite", "dzięki za zaproszenie"), + ("hi all", "hej wszystkim"), + ("ty {player}", "dzięki {player}"), + ("hey, lets go", "hej, lecimy")); Add( BotChatSituation.EventFinished, - "gg", - "gg wp", - "gg, thx for party", - "nice run"); + ("gg", "gg"), + ("gg wp", "gg wp"), + ("gg, thx for party", "gg, dzięki za grupę"), + ("nice run", "niezły run")); Add( BotChatSituation.KilledByPlayer, - "why??", - "wtf {player}", - "really?", - "ok {player}, remember that"); + ("why??", "czemu??"), + ("wtf {player}", "wtf {player}"), + ("really?", "serio?"), + ("ok {player}, remember that", "ok {player}, zapamiętam")); Add( BotChatSituation.KilledAgainByPlayer, - "you again...", - "ok ok, i leave", - "{player} pls stop", - "enough, im out"); + ("you again...", "znowu ty..."), + ("ok ok, i leave", "dobra dobra, idę stąd"), + ("{player} pls stop", "{player} weź przestań"), + ("enough, im out", "dość, spadam")); Add( BotChatSituation.SmallTalk, - "lf party {map}", - "lag?", - "need a party for exp", - "anyone up for bc later?", - "so many people today", - "gm online?"); - AddReplies("how are you, how r u, how are u, hru, how's it going, how is it going", "fine, thanks for asking", "good, you?", "all good, just grinding"); - AddReplies("hi, hello, hey, yo, hiho, sup", "hi, what's up?", "hey :)", "hello"); - AddReplies("level, lvl, what level", "{level} now", "lvl {level}"); - AddReplies("where are you, where r u, where", "{map}", "in {map}"); - AddReplies("party, pt", "sure, invite me", "ok, send invite"); - AddReplies("thanks, thank you, thx, ty", "np", "no problem"); - AddReplies("bye, cya, see you, gn", "bye", "cya"); + ("lf party {map}", "szukam grupy {map}"), + ("lag?", "lag?"), + ("need a party for exp", "potrzebna grupa na expa"), + ("anyone up for bc later?", "ktoś na bc później?"), + ("so many people today", "dużo ludzi dzisiaj"), + ("gm online?", "jest gm?")); + AddReplies( + "how are you, how r u, how are u, hru, how's it going, how is it going, jak sie masz, jak się masz, co slychac, co słychać, co tam", + ("fine, thanks for asking", "dobrze, dzięki że pytasz"), + ("good, you?", "spoko, a ty?"), + ("all good, just grinding", "wszystko ok, expię")); + AddReplies( + "hi, hello, hey, yo, hiho, sup, siema, czesc, cześć, hej, elo, witam", + ("hi, what's up?", "hej, co tam?"), + ("hey :)", "hej :)"), + ("hello", "siema")); + AddReplies( + "level, lvl, what level, poziom, jaki lvl, jaki poziom", + ("{level} now", "{level} teraz"), + ("lvl {level}", "lvl {level}")); + AddReplies( + "where are you, where r u, where, gdzie jestes, gdzie jesteś, gdzie", + ("{map}", "{map}"), + ("in {map}", "w {map}")); + AddReplies( + "party, pt, grupa, grupe, grupę, do grupy", + ("sure, invite me", "jasne, zaproś mnie"), + ("ok, send invite", "ok, wysyłaj zaproszenie")); + AddReplies( + "thanks, thank you, thx, ty, dzieki, dzięki, dziekuje, dziękuję", + ("np", "np"), + ("no problem", "nie ma sprawy")); + AddReplies( + "bye, cya, see you, gn, nara, pa, do zobaczenia, dobranoc", + ("bye", "nara"), + ("cya", "na razie")); return configuration; - void Add(BotChatSituation situation, params string[] texts) + void Add(BotChatSituation situation, params (string English, string Polish)[] texts) { foreach (var text in texts) { - configuration.Lines.Add(new BotChatLine { Situation = situation, Text = text }); + configuration.Lines.Add(new BotChatLine { Situation = situation, Text = Translated(text) }); } } - void AddReplies(string keywords, params string[] texts) + void AddReplies(string keywords, params (string English, string Polish)[] texts) { foreach (var text in texts) { - configuration.Lines.Add(new BotChatLine { Situation = BotChatSituation.WhisperReply, Text = text, Keywords = keywords }); + configuration.Lines.Add(new BotChatLine { Situation = BotChatSituation.WhisperReply, Text = Translated(text), Keywords = keywords }); } } + + static LocalizedString Translated((string English, string Polish) text) + => new LocalizedString(text.English).WithTranslation(CultureInfo.GetCultureInfo("pl"), text.Polish); } } diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 71aad74e5a..cea697756a 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -201,6 +201,23 @@ public void DefaultRepliesAnswerHowAreYou() Assert.That(reply?.Keywords, Does.Contain("how are you")); } + /// + /// The default lines come with a Polish translation: a Polish player who whispers in Polish gets + /// a fitting answer in Polish, also when typing without diacritics. + /// + /// The whispered message. + [TestCase("siema, jak się masz?")] + [TestCase("jak sie masz")] + public void DefaultRepliesSpeakPolish(string message) + { + var configuration = BotChatConfiguration.CreateDefault(); + + var reply = BotChatComposer.PickWhisperReply(configuration.Lines, message, _ => 0); + var text = reply is null ? null : BotChatComposer.Render(reply, CultureInfo.GetCultureInfo("pl-PL"), NoValues); + + Assert.That(text, Is.EqualTo("dobrze, dzięki że pytasz")); + } + /// /// A server which switches the plugin on has something to say in every situation. /// From a93b94bd33fa0bdc3912fec22a4350f5cd3d1889 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 17:56:29 +0100 Subject: [PATCH 10/44] refactor(bots): give every chat situation its own list The bot chat configuration had one long "Lines" list in which every line named its situation. With some fifty lines, the admin panel showed one endless page, and a line could end up in the wrong situation or carry keywords which only whisper replies use. Every situation now has its own list (event lines, greetings, whisper replies, ...), each collapsible on its own in the plugin's configuration dialog. The lines only hold their text; the whisper replies keep their keywords. The situation enum is gone - the list a line is in says when it is said. --- docs-website/docs/server-features/bots.md | 5 +- src/GameLogic/Bots/BotChatComposer.cs | 48 +++---- src/GameLogic/Bots/BotChatConfiguration.cs | 119 +++++++++++++++--- src/GameLogic/Bots/BotChatLine.cs | 28 +---- src/GameLogic/Bots/BotChatPlugIn.cs | 46 +++---- src/GameLogic/Bots/BotChatReply.cs | 40 ++++++ src/GameLogic/Bots/BotChatSituation.cs | 82 ------------ src/GameLogic/Bots/BotChatSpeaker.cs | 24 ++-- .../Properties/PlugInResources.Designer.cs | 84 ++++++++++--- src/GameLogic/Properties/PlugInResources.resx | 30 +++-- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 52 ++++---- 11 files changed, 320 insertions(+), 238 deletions(-) create mode 100644 src/GameLogic/Bots/BotChatReply.cs delete mode 100644 src/GameLogic/Bots/BotChatSituation.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index a536cbb463..485adb934d 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -334,8 +334,9 @@ population ebbs and flows instead of appearing and vanishing in blocks. ### Talking With the separate *Bot chat* plugin (off by default), bots say a line now and -then. The lines come from the plugin's configuration; there is no language -model. A bot only talks about what it could actually do: +then. The lines come from the plugin's configuration, one list per situation +(event lines, greetings, whisper replies, …); there is no language model. A bot +only talks about what it could actually do: | Situation | Where | Who says it | |---|---|---| diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 94c52a000d..f3086b87f4 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Globalization; using System.Text; +using MUnique.OpenMU.Interfaces; /// /// Chooses the line a bot says and renders it for one receiver: the translation of the receiver's @@ -14,52 +15,51 @@ namespace MUnique.OpenMU.GameLogic.Bots; internal static class BotChatComposer { /// - /// Picks a random line of the situation. + /// Picks a random usable line of the list. /// - /// All configured lines. - /// The situation. + /// The lines of one list, e.g. the greetings. /// Returns a random index below the given count. - /// The picked line, or null when the situation has no usable line. - internal static BotChatLine? PickLine(IEnumerable lines, BotChatSituation situation, Func pickIndex) + /// The picked line, or null when the list has no usable line. + internal static BotChatLine? PickLine(IEnumerable lines, Func pickIndex) { var candidates = lines - .Where(line => line.Situation == situation && !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) + .Where(line => !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) .ToList(); return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } /// - /// Picks the reply to a player's whisper: a random one of the lines whose keywords match the + /// Picks the reply to a player's message: a random one of the replies whose keywords match the /// message best, or - when no keyword matches - one of the replies without keywords. /// - /// All configured lines. - /// The whispered message. + /// The configured replies. + /// The player's message. /// Returns a random index below the given count. - /// The picked line, or null when there is no usable reply. + /// The picked reply, or null when there is no usable reply. /// /// The best match is the longest matching keyword, so "hi, how are you?" is answered by the - /// "how are you" lines rather than by the greetings matching "hi". + /// "how are you" replies rather than by the greetings matching "hi". /// - internal static BotChatLine? PickWhisperReply(IEnumerable lines, string message, Func pickIndex) + internal static BotChatReply? PickWhisperReply(IEnumerable replies, string message, Func pickIndex) { - var replies = lines - .Where(line => line.Situation == BotChatSituation.WhisperReply && !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) + var usable = replies + .Where(reply => !string.IsNullOrWhiteSpace(reply.Text.ValueInNeutralLanguage)) .ToList(); var normalizedMessage = $" {Normalize(message)} "; - var scored = replies - .Select(line => (Line: line, Score: GetKeywords(line).Where(k => normalizedMessage.Contains($" {k} ", StringComparison.Ordinal)).Select(k => k.Length).DefaultIfEmpty(0).Max())) + var scored = usable + .Select(reply => (Reply: reply, Score: GetKeywords(reply.Keywords).Where(k => normalizedMessage.Contains($" {k} ", StringComparison.Ordinal)).Select(k => k.Length).DefaultIfEmpty(0).Max())) .ToList(); var bestScore = scored.Count == 0 ? 0 : scored.Max(s => s.Score); var candidates = bestScore > 0 - ? scored.Where(s => s.Score == bestScore).Select(s => s.Line).ToList() - : replies.Where(line => !GetKeywords(line).Any()).ToList(); + ? scored.Where(s => s.Score == bestScore).Select(s => s.Reply).ToList() + : usable.Where(reply => !GetKeywords(reply.Keywords).Any()).ToList(); return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } /// - /// Renders the line for a receiver of the given culture. + /// Renders a text for a receiver of the given culture. /// - /// The line. + /// The text, e.g. of a . /// The culture of the receiving player. /// The placeholder values by name (without braces), each resolved for the culture. /// The text to send. @@ -67,9 +67,9 @@ internal static class BotChatComposer /// Unknown placeholders are left as they are, so a typo in a configured line is visible in the /// game instead of silently swallowing text. /// - internal static string Render(BotChatLine line, CultureInfo culture, IReadOnlyDictionary> values) + internal static string Render(LocalizedString text, CultureInfo culture, IReadOnlyDictionary> values) { - var template = line.Text.GetTranslation(culture) ?? string.Empty; + var template = text.GetTranslation(culture) ?? string.Empty; if (!template.Contains('{', StringComparison.Ordinal)) { return template; @@ -99,8 +99,8 @@ internal static bool MentionsName(string message, string name) => Normalize(name) is { Length: > 0 } normalizedName && $" {Normalize(message)} ".Contains($" {normalizedName} ", StringComparison.Ordinal); - private static IEnumerable GetKeywords(BotChatLine line) - => (line.Keywords ?? string.Empty) + private static IEnumerable GetKeywords(string? keywords) + => (keywords ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(Normalize) .Where(keyword => keyword.Length > 0); diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 49aefef0d2..19c1523ecd 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -127,12 +127,97 @@ public class BotChatConfiguration public int SmallTalkChancePercent { get; set; } = 50; /// - /// Gets or sets the lines the bots choose from. A situation without lines stays silent. + /// Gets or sets the lines a bot posts with /post when the entrance of a Blood Castle, Devil Square + /// or Chaos Castle opened, before it waits for players at the meeting point (see ). + /// Placeholders: {event}, {map} (the map of the meeting point), {level}. /// [MemberOfAggregate] [ScaffoldColumn(true)] - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_Lines_Name), Description = nameof(PlugInResources.BotChatConfiguration_Lines_Description))] - public ICollection Lines { get; set; } = new List(); + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventLines_Description))] + public ICollection EventLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot posts with /post when an invasion (White Wizard, Golden, Red Dragon) + /// started. Placeholders: {event}, {map}, {level}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_InvasionLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_InvasionLines_Description))] + public ICollection InvasionLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot says in the normal chat when it reached a level milestone + /// (see ). Placeholders: {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneLines_Description))] + public ICollection LevelMilestoneLines { get; set; } = new List(); + + /// + /// Gets or sets the answers of a bot to a player who whispered to it, or mentioned its name in the normal + /// chat next to it. The bot answers the same way: by whisper, or in the normal chat. + /// Placeholders: {player}, {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Description))] + public ICollection WhisperReplies { get; set; } = new List(); + + /// + /// Gets or sets the lines the nearest bot greets a player with who entered the game. + /// Placeholders: {player}, {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Description))] + public ICollection GreetingLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot says in the party chat after it accepted a player's party invitation. + /// Placeholders: {player} (the inviter), {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyJoinedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyJoinedLines_Description))] + public ICollection PartyJoinedLines { get; set; } = new List(); + + /// + /// Gets or sets the lines one bot per party says in the party chat when an event it went through with a + /// player's party ended. Placeholders: {event}, {level}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Description))] + public ICollection EventFinishedLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot says in the normal chat when a player killed it outside of an event, right + /// before it respawns. Placeholders: {player} (the killer), {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledLines_Description))] + public ICollection KilledLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot says instead of the when the same player killed + /// it again within a short time - the bot gives up its revenge (see ). + /// Placeholders as for the . + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledAgainLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledAgainLines_Description))] + public ICollection KilledAgainLines { get; set; } = new List(); + + /// + /// Gets or sets the lines a bot standing in a safezone says now and then in the normal chat (see + /// ) - only when a player sees it. Placeholders: {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Description))] + public ICollection SmallTalkLines { get; set; } = new List(); /// /// Creates the configuration a server starts with: a set of English lines, each with a Polish @@ -149,59 +234,59 @@ public static BotChatConfiguration CreateDefault() { var configuration = new BotChatConfiguration(); Add( - BotChatSituation.EventEntranceOpened, + configuration.EventLines, ("{event} open, anyone going? im in {map}", "{event} otwarte, ktoś idzie? jestem w {map}"), ("lf party {event}, waiting in {map}", "szukam grupy na {event}, czekam w {map}"), ("{event} now, who wants to join? {map}", "{event} teraz, kto chętny? {map}"), ("going {event}, party anyone? at {map}", "idę na {event}, ktoś do grupy? {map}")); Add( - BotChatSituation.InvasionStarted, + configuration.InvasionLines, ("{event} in {map}!", "{event} w {map}!"), ("{event} on {map}, go go", "{event} na {map}, lecimy"), ("{map} - {event} started", "{map} - start: {event}")); Add( - BotChatSituation.LevelMilestone, + configuration.LevelMilestoneLines, ("{level} finally :)", "{level} w końcu :)"), ("lvl {level}, nice", "lvl {level}, nieźle"), ("{level} done", "{level} wbite")); - Add( - BotChatSituation.WhisperReply, + AddReplies( + string.Empty, ("sry busy hunting", "sry, poluję"), ("hi, cant talk now", "hej, nie mogę teraz gadać"), ("hi, what's up?", "hej, co tam?"), ("afk a bit", "chwilę afk")); Add( - BotChatSituation.PlayerLogin, + configuration.GreetingLines, ("hi {player}, have a nice game", "siema {player}, miłej gry"), ("welcome back {player}", "witaj z powrotem {player}"), ("yo {player} :)", "yo {player} :)"), ("hey {player}", "hej {player}")); Add( - BotChatSituation.PartyJoined, + configuration.PartyJoinedLines, ("thx for invite", "dzięki za zaproszenie"), ("hi all", "hej wszystkim"), ("ty {player}", "dzięki {player}"), ("hey, lets go", "hej, lecimy")); Add( - BotChatSituation.EventFinished, + configuration.EventFinishedLines, ("gg", "gg"), ("gg wp", "gg wp"), ("gg, thx for party", "gg, dzięki za grupę"), ("nice run", "niezły run")); Add( - BotChatSituation.KilledByPlayer, + configuration.KilledLines, ("why??", "czemu??"), ("wtf {player}", "wtf {player}"), ("really?", "serio?"), ("ok {player}, remember that", "ok {player}, zapamiętam")); Add( - BotChatSituation.KilledAgainByPlayer, + configuration.KilledAgainLines, ("you again...", "znowu ty..."), ("ok ok, i leave", "dobra dobra, idę stąd"), ("{player} pls stop", "{player} weź przestań"), ("enough, im out", "dość, spadam")); Add( - BotChatSituation.SmallTalk, + configuration.SmallTalkLines, ("lf party {map}", "szukam grupy {map}"), ("lag?", "lag?"), ("need a party for exp", "potrzebna grupa na expa"), @@ -240,11 +325,11 @@ public static BotChatConfiguration CreateDefault() ("cya", "na razie")); return configuration; - void Add(BotChatSituation situation, params (string English, string Polish)[] texts) + void Add(ICollection lines, params (string English, string Polish)[] texts) { foreach (var text in texts) { - configuration.Lines.Add(new BotChatLine { Situation = situation, Text = Translated(text) }); + lines.Add(new BotChatLine { Text = Translated(text) }); } } @@ -252,7 +337,7 @@ void AddReplies(string keywords, params (string English, string Polish)[] texts) { foreach (var text in texts) { - configuration.Lines.Add(new BotChatLine { Situation = BotChatSituation.WhisperReply, Text = Translated(text), Keywords = keywords }); + configuration.WhisperReplies.Add(new BotChatReply { Keywords = keywords, Text = Translated(text) }); } } diff --git a/src/GameLogic/Bots/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs index cb3866a830..415a096d34 100644 --- a/src/GameLogic/Bots/BotChatLine.cs +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -7,41 +7,23 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.Interfaces; /// -/// One line a server-side bot may say in a . +/// One line a server-side bot may say. The list a line is in (e.g. +/// ) decides when it is said. /// /// /// The text is a : every receiving player sees the translation of /// their own language, falling back to the English text. Placeholders in curly braces -/// (e.g. {event}) are filled in when the line is said; which ones a situation offers is -/// documented on . +/// (e.g. {event}) are filled in when the line is said; which ones a list offers is +/// documented on the list. /// public class BotChatLine { - /// - /// Gets or sets the situation in which the line may be said. - /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Situation_Name), Description = nameof(PlugInResources.BotChatLine_Situation_Description))] - public BotChatSituation Situation { get; set; } - /// /// Gets or sets the text of the line. /// [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] public LocalizedString Text { get; set; } - /// - /// Gets or sets the comma-separated words or phrases of a player's whisper this line answers, - /// e.g. how are you, how r u. Only used for ; - /// a reply without keywords is the fallback when no keyword matches. - /// - /// - /// The keywords are compared as whole words, ignoring case and punctuation, so hi doesn't - /// match "this". They may be written in several languages at once - the whisper's language is - /// not known. - /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Keywords_Name), Description = nameof(PlugInResources.BotChatLine_Keywords_Description))] - public string Keywords { get; set; } = string.Empty; - /// - public override string ToString() => $"{this.Situation}: {this.Text.ValueInNeutralLanguage}"; + public override string ToString() => this.Text.ValueInNeutralLanguage; } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index e6ba9f9dc2..9b30d06321 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -160,7 +160,7 @@ public void CharacterLeveledUp(Player player) if (level < configuration.LevelMilestoneStep || level % configuration.LevelMilestoneStep != 0 || !Rand.NextRandomBool(configuration.LevelMilestoneChancePercent) - || PickLine(configuration, BotChatSituation.LevelMilestone) is not { } line + || PickLine(configuration.LevelMilestoneLines) is not { } line || !this.GetThrottle(player.GameContext).TryReserveChat(player, DateTime.UtcNow, configuration.BotChatCooldown)) { return; @@ -170,7 +170,7 @@ public void CharacterLeveledUp(Player player) player.GameContext, RandomDelay(LevelMilestoneDelaySeconds), () => IsAvailable(player) - ? BotChatSpeaker.SayAsync(player, line, CreateBotValues(player)) + ? BotChatSpeaker.SayAsync(player, line.Text, CreateBotValues(player)) : ValueTask.CompletedTask); } @@ -181,7 +181,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag || !IsBot(receiver) || sender is OfflinePlayer || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) - || BotChatComposer.PickWhisperReply(configuration.Lines, message, count => Rand.NextInt(0, count)) is not { } line + || BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, count => Rand.NextInt(0, count)) is not { } line || !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; @@ -194,7 +194,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag receiver.GameContext, RandomDelay(WhisperReplyDelaySeconds), () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(receiver, sender, line, values) + ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values) : ValueTask.CompletedTask); } @@ -220,7 +220,7 @@ public void BotJoinedParty(Player bot, Player inviter) { if (this.Configuration is not { PartyChatChancePercent: > 0 } configuration || !Rand.NextRandomBool(configuration.PartyChatChancePercent) - || PickLine(configuration, BotChatSituation.PartyJoined) is not { } line + || PickLine(configuration.PartyJoinedLines) is not { } line || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) { return; @@ -229,7 +229,7 @@ public void BotJoinedParty(Player bot, Player inviter) var values = CreateBotValues(bot); var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => inviterName; - RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line, values)); + RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); } /// @@ -250,7 +250,7 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR foreach (var bot in speakers) { if (!Rand.NextRandomBool(configuration.PartyChatChancePercent) - || PickLine(configuration, BotChatSituation.EventFinished) is not { } line + || PickLine(configuration.EventFinishedLines) is not { } line || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) { continue; @@ -258,7 +258,7 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR var values = CreateBotValues(bot); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; - RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line, values)); + RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); } return ValueTask.CompletedTask; @@ -284,8 +284,8 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) RunLater(bot.GameContext, RandomDelay(KilledDelaySeconds), () => { // Read after the delay: the revenge plugin registers the death in the same notification. - var situation = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? BotChatSituation.KilledAgainByPlayer : BotChatSituation.KilledByPlayer; - if (PickLine(configuration, situation) is not { } line) + var lines = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? configuration.KilledAgainLines : configuration.KilledLines; + if (PickLine(lines) is not { } line) { return ValueTask.CompletedTask; } @@ -293,7 +293,7 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) var values = CreateBotValues(bot); var killerName = killerPlayer.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => killerName; - return BotChatSpeaker.SayAsync(bot, line, values); + return BotChatSpeaker.SayAsync(bot, line.Text, values); }); return ValueTask.CompletedTask; } @@ -325,7 +325,7 @@ await sender.ForEachObservingAsync( if (mentionedBots.FirstOrDefault(IsAvailable) is not { } bot || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) - || BotChatComposer.PickWhisperReply(configuration.Lines, message, count => Rand.NextInt(0, count)) is not { } line + || BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, count => Rand.NextInt(0, count)) is not { } line || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; @@ -336,7 +336,7 @@ await sender.ForEachObservingAsync( RunLater( bot.GameContext, RandomDelay(WhisperReplyDelaySeconds), - () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line, values) : ValueTask.CompletedTask); + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values) : ValueTask.CompletedTask); } /// @@ -361,7 +361,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } if (candidates.Count == 0 - || PickLine(configuration, BotChatSituation.SmallTalk) is not { } line) + || PickLine(configuration.SmallTalkLines) is not { } line) { return; } @@ -369,7 +369,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) var speaker = candidates[Rand.NextInt(0, candidates.Count)]; if (this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) { - await BotChatSpeaker.SayAsync(speaker, line, CreateBotValues(speaker)).ConfigureAwait(false); + await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker)).ConfigureAwait(false); } } @@ -462,8 +462,8 @@ await bot.ForEachObservingAsync( private static bool IsPostCommandActive(IGameContext gameContext) => gameContext.PlugInManager.IsPlugInActive(typeof(PostChatCommandPlugIn)); - private static BotChatLine? PickLine(BotChatConfiguration configuration, BotChatSituation situation) - => BotChatComposer.PickLine(configuration.Lines, situation, count => Rand.NextInt(0, count)); + private static BotChatLine? PickLine(IEnumerable lines) + => BotChatComposer.PickLine(lines, count => Rand.NextInt(0, count)); private static TimeSpan RandomDelay(TimeSpan min, TimeSpan max) => max <= min ? min : min + TimeSpan.FromMilliseconds(Rand.NextDouble() * (max - min).TotalMilliseconds); @@ -529,7 +529,7 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura var now = DateTime.UtcNow; var throttle = this.GetThrottle(player.GameContext); if (bot is null - || PickLine(configuration, BotChatSituation.PlayerLogin) is not { } line + || PickLine(configuration.GreetingLines) is not { } line || !throttle.TryReserveOccasion($"greeting {playerName}", now, configuration.GreetingCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) { @@ -538,7 +538,7 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura var values = CreateBotValues(bot); values["player"] = _ => playerName; - await BotChatSpeaker.SayAsync(bot, line, values).ConfigureAwait(false); + await BotChatSpeaker.SayAsync(bot, line.Text, values).ConfigureAwait(false); } private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) @@ -567,7 +567,7 @@ private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatCon if (candidates.Count == 0 || !throttle.TryReserveOccasion($"mini game {definition.Type}", now, miniGame.EnterEndsAtUtc - now) || !Rand.NextRandomBool(configuration.EventPostChancePercent) - || PickLine(configuration, BotChatSituation.EventEntranceOpened) is not { } line + || PickLine(configuration.EventLines) is not { } line || !throttle.TryReservePost(now, configuration.MinTimeBetweenPosts)) { return; @@ -578,7 +578,7 @@ private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatCon var values = CreateBotValues(chosen); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; values["map"] = culture => meeting!.Map.Name.GetTranslation(culture) ?? string.Empty; - await BotChatSpeaker.PostAsync(chosen, line, values).ConfigureAwait(false); + await BotChatSpeaker.PostAsync(chosen, line.Text, values).ConfigureAwait(false); } private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriodicTaskPlugIn invasion, IReadOnlyCollection maps, BotChatConfiguration configuration) @@ -593,7 +593,7 @@ private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriod if (candidates.Count == 0 || !throttle.TryReserveOccasion($"invasion {invasion.GetType().Name}", now, InvasionAnnouncementWindow) || !Rand.NextRandomBool(configuration.InvasionPostChancePercent) - || PickLine(configuration, BotChatSituation.InvasionStarted) is not { } line + || PickLine(configuration.InvasionLines) is not { } line || !throttle.TryReservePost(now, configuration.MinTimeBetweenPosts)) { return; @@ -603,6 +603,6 @@ private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriod var values = CreateBotValues(bot); values["event"] = culture => GetInvasionName(invasion, culture); values["map"] = culture => map.Name.GetTranslation(culture) ?? string.Empty; - await BotChatSpeaker.PostAsync(bot, line, values).ConfigureAwait(false); + await BotChatSpeaker.PostAsync(bot, line.Text, values).ConfigureAwait(false); } } diff --git a/src/GameLogic/Bots/BotChatReply.cs b/src/GameLogic/Bots/BotChatReply.cs new file mode 100644 index 0000000000..d18adf8f50 --- /dev/null +++ b/src/GameLogic/Bots/BotChatReply.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Interfaces; + +/// +/// One answer of a server-side bot to a player who whispered to it or mentioned its name in the +/// normal chat next to it (see ). +/// +/// +/// The bot picks among the replies whose match the player's message best; +/// when none matches, it uses a reply without keywords. +/// +public class BotChatReply +{ + /// + /// Gets or sets the comma-separated words or phrases of a player's message this reply answers, + /// e.g. how are you, how r u. A reply without keywords is the fallback when no keyword + /// matches. + /// + /// + /// The keywords are compared as whole words, ignoring case and punctuation, so hi doesn't + /// match "this". They may be written in several languages at once - the message's language is + /// not known. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatReply_Keywords_Name), Description = nameof(PlugInResources.BotChatReply_Keywords_Description))] + public string Keywords { get; set; } = string.Empty; + + /// + /// Gets or sets the text of the reply. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] + public LocalizedString Text { get; set; } + + /// + public override string ToString() => $"{this.Keywords}: {this.Text.ValueInNeutralLanguage}"; +} diff --git a/src/GameLogic/Bots/BotChatSituation.cs b/src/GameLogic/Bots/BotChatSituation.cs deleted file mode 100644 index 5eed90b738..0000000000 --- a/src/GameLogic/Bots/BotChatSituation.cs +++ /dev/null @@ -1,82 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.GameLogic.Bots; - -/// -/// The situations in which a server-side bot says something. Each line of the -/// belongs to one of them. -/// -public enum BotChatSituation -{ - /// - /// The entrance of a Blood Castle, Devil Square or Chaos Castle opened. A bot which meets the - /// event's entry requirements posts about it with /post and then waits for players at the - /// meeting point (see ) until the entrance closes. - /// Placeholders: {event}, {map} (the map of the meeting point), {level}. - /// - EventEntranceOpened, - - /// - /// An invasion (White Wizard, Golden, Red Dragon) started. A bot which may warp to the invaded - /// map posts about it with /post. - /// Placeholders: {event}, {map}, {level}. - /// - InvasionStarted, - - /// - /// The bot reached a level milestone (see ). - /// Said in the normal chat, so only players nearby see it. - /// Placeholders: {level}, {map}. - /// - LevelMilestone, - - /// - /// A human player whispered to the bot, or mentioned its name in the normal chat next to it. The - /// bot answers the same way: by whisper, or in the normal chat. A line may carry - /// . - /// Placeholders: {player}, {level}, {map}. - /// - WhisperReply, - - /// - /// A player entered the game (from the character selection, not after a warp). The nearest bot - /// which sees the player greets them in the normal chat. - /// Placeholders: {player}, {level}, {map}. - /// - /// New situations are appended: the configuration stores the situation as a number. - PlayerLogin, - - /// - /// The bot accepted a player's party invitation. Said in the party chat. - /// Placeholders: {player} (the inviter), {level}, {map}. - /// - PartyJoined, - - /// - /// An event (Blood Castle, Devil Square, Chaos Castle) the bot went through with a player's party - /// ended. One bot per party says it in the party chat. - /// Placeholders: {event}, {level}. - /// - EventFinished, - - /// - /// A player killed the bot (outside of an event). Said in the normal chat, right before the bot - /// respawns. Placeholders: {player} (the killer), {level}, {map}. - /// - KilledByPlayer, - - /// - /// The same player killed the bot again within a short time - the bot gives up its revenge - /// (see ). Placeholders as for . - /// - KilledAgainByPlayer, - - /// - /// Now and then (see ) a bot standing in a - /// safezone says something in the normal chat - only when a player sees it. - /// Placeholders: {level}, {map}. - /// - SmallTalk, -} diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs index 9b2e4ff9c9..675b2a7ac3 100644 --- a/src/GameLogic/Bots/BotChatSpeaker.cs +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -26,9 +26,9 @@ internal static class BotChatSpeaker /// Says the line in the normal chat, to the players who see the bot. /// /// The speaking bot. - /// The line. + /// The text. /// The placeholder values. - internal static ValueTask SayAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) { if (bot.SelectedCharacter?.Name is not { } name) { @@ -36,7 +36,7 @@ internal static ValueTask SayAsync(Player bot, BotChatLine line, IReadOnlyDictio } return bot.ForEachObservingAsync( - receiver => SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Normal), + receiver => SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Normal), false); } @@ -45,25 +45,25 @@ internal static ValueTask SayAsync(Player bot, BotChatLine line, IReadOnlyDictio /// /// The whispering bot. /// The receiving player. - /// The line. + /// The text. /// The placeholder values. - internal static ValueTask WhisperAsync(Player bot, Player receiver, BotChatLine line, IReadOnlyDictionary> values) + internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedString text, IReadOnlyDictionary> values) { if (bot.SelectedCharacter?.Name is not { } name) { return ValueTask.CompletedTask; } - return SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Whisper); + return SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Whisper); } /// /// Says the line in the party chat, to the players of the bot's party. /// /// The speaking bot. - /// The line. + /// The text. /// The placeholder values. - internal static async ValueTask PartySayAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) { if (bot.SelectedCharacter?.Name is not { } name || bot.Party is not { } party) { @@ -72,7 +72,7 @@ internal static async ValueTask PartySayAsync(Player bot, BotChatLine line, IRea foreach (var receiver in party.PartyList.OfType()) { - await SendAsync(receiver, BotChatComposer.Render(line, receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); + await SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); } } @@ -80,9 +80,9 @@ internal static async ValueTask PartySayAsync(Player bot, BotChatLine line, IRea /// Posts the line to all players of the game server, in the format of the /post command. /// /// The posting bot. - /// The line. + /// The text. /// The placeholder values. - internal static async ValueTask PostAsync(Player bot, BotChatLine line, IReadOnlyDictionary> values) + internal static async ValueTask PostAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) { if (bot.SelectedCharacter?.Name is not { } name) { @@ -92,7 +92,7 @@ internal static async ValueTask PostAsync(Player bot, BotChatLine line, IReadOnl await bot.GameContext.ForEachPlayerAsync(receiver => SendAsync( receiver, - PostChatCommandPlugIn.FormatPost(name, BotChatComposer.Render(line, receiver.Culture, values)), + PostChatCommandPlugIn.FormatPost(name, BotChatComposer.Render(text, receiver.Culture, values)), PostChatCommandPlugIn.PostSender, ChatMessageType.Gens).AsTask()) .ConfigureAwait(false); diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index ec45bfc047..b443585cdb 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4621,24 +4621,12 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatPlugIn_Description text. public static string BotChatPlugIn_Description => ResourceManager.GetString("BotChatPlugIn_Description", resourceCulture)!; - /// Gets the localized BotChatLine_Situation_Name text. - public static string BotChatLine_Situation_Name => ResourceManager.GetString("BotChatLine_Situation_Name", resourceCulture)!; - - /// Gets the localized BotChatLine_Situation_Description text. - public static string BotChatLine_Situation_Description => ResourceManager.GetString("BotChatLine_Situation_Description", resourceCulture)!; - /// Gets the localized BotChatLine_Text_Name text. public static string BotChatLine_Text_Name => ResourceManager.GetString("BotChatLine_Text_Name", resourceCulture)!; /// Gets the localized BotChatLine_Text_Description text. public static string BotChatLine_Text_Description => ResourceManager.GetString("BotChatLine_Text_Description", resourceCulture)!; - /// Gets the localized BotChatLine_Keywords_Name text. - public static string BotChatLine_Keywords_Name => ResourceManager.GetString("BotChatLine_Keywords_Name", resourceCulture)!; - - /// Gets the localized BotChatLine_Keywords_Description text. - public static string BotChatLine_Keywords_Description => ResourceManager.GetString("BotChatLine_Keywords_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_EventPostChancePercent_Name text. public static string BotChatConfiguration_EventPostChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_EventPostChancePercent_Name", resourceCulture)!; @@ -4693,12 +4681,6 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_WhisperReplyCooldown_Description text. public static string BotChatConfiguration_WhisperReplyCooldown_Description => ResourceManager.GetString("BotChatConfiguration_WhisperReplyCooldown_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_Lines_Name text. - public static string BotChatConfiguration_Lines_Name => ResourceManager.GetString("BotChatConfiguration_Lines_Name", resourceCulture)!; - - /// Gets the localized BotChatConfiguration_Lines_Description text. - public static string BotChatConfiguration_Lines_Description => ResourceManager.GetString("BotChatConfiguration_Lines_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_GreetingChancePercent_Name text. public static string BotChatConfiguration_GreetingChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_GreetingChancePercent_Name", resourceCulture)!; @@ -5232,5 +5214,71 @@ public static string DiscordIntegrationFeaturePlugIn_Description { return ResourceManager.GetString("DiscordIntegrationFeaturePlugIn_Description", resourceCulture); } } + /// Gets the localized BotChatReply_Keywords_Name text. + public static string BotChatReply_Keywords_Name => ResourceManager.GetString("BotChatReply_Keywords_Name", resourceCulture)!; + + /// Gets the localized BotChatReply_Keywords_Description text. + public static string BotChatReply_Keywords_Description => ResourceManager.GetString("BotChatReply_Keywords_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventLines_Name text. + public static string BotChatConfiguration_EventLines_Name => ResourceManager.GetString("BotChatConfiguration_EventLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventLines_Description text. + public static string BotChatConfiguration_EventLines_Description => ResourceManager.GetString("BotChatConfiguration_EventLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_InvasionLines_Name text. + public static string BotChatConfiguration_InvasionLines_Name => ResourceManager.GetString("BotChatConfiguration_InvasionLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_InvasionLines_Description text. + public static string BotChatConfiguration_InvasionLines_Description => ResourceManager.GetString("BotChatConfiguration_InvasionLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneLines_Name text. + public static string BotChatConfiguration_LevelMilestoneLines_Name => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_LevelMilestoneLines_Description text. + public static string BotChatConfiguration_LevelMilestoneLines_Description => ResourceManager.GetString("BotChatConfiguration_LevelMilestoneLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplies_Name text. + public static string BotChatConfiguration_WhisperReplies_Name => ResourceManager.GetString("BotChatConfiguration_WhisperReplies_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WhisperReplies_Description text. + public static string BotChatConfiguration_WhisperReplies_Description => ResourceManager.GetString("BotChatConfiguration_WhisperReplies_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_GreetingLines_Name text. + public static string BotChatConfiguration_GreetingLines_Name => ResourceManager.GetString("BotChatConfiguration_GreetingLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_GreetingLines_Description text. + public static string BotChatConfiguration_GreetingLines_Description => ResourceManager.GetString("BotChatConfiguration_GreetingLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyJoinedLines_Name text. + public static string BotChatConfiguration_PartyJoinedLines_Name => ResourceManager.GetString("BotChatConfiguration_PartyJoinedLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyJoinedLines_Description text. + public static string BotChatConfiguration_PartyJoinedLines_Description => ResourceManager.GetString("BotChatConfiguration_PartyJoinedLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventFinishedLines_Name text. + public static string BotChatConfiguration_EventFinishedLines_Name => ResourceManager.GetString("BotChatConfiguration_EventFinishedLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventFinishedLines_Description text. + public static string BotChatConfiguration_EventFinishedLines_Description => ResourceManager.GetString("BotChatConfiguration_EventFinishedLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_KilledLines_Name text. + public static string BotChatConfiguration_KilledLines_Name => ResourceManager.GetString("BotChatConfiguration_KilledLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_KilledLines_Description text. + public static string BotChatConfiguration_KilledLines_Description => ResourceManager.GetString("BotChatConfiguration_KilledLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_KilledAgainLines_Name text. + public static string BotChatConfiguration_KilledAgainLines_Name => ResourceManager.GetString("BotChatConfiguration_KilledAgainLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_KilledAgainLines_Description text. + public static string BotChatConfiguration_KilledAgainLines_Description => ResourceManager.GetString("BotChatConfiguration_KilledAgainLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_SmallTalkLines_Name text. + public static string BotChatConfiguration_SmallTalkLines_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_SmallTalkLines_Description text. + public static string BotChatConfiguration_SmallTalkLines_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 9788f2294d..3eb03f2e8f 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1899,10 +1899,8 @@ Bot chat Lets server-side bots talk: they announce events and invasions with /post, comment on level milestones and answer whispers, using configurable lines. - Situation - When the line may be said. Text - What the bot says. Placeholders: {event}, {map}, {level}, {player} - the documentation lists which situation offers which. + What the bot says. The placeholders in curly braces which the list offers are filled in - see the description of the list. Event post chance (%) Chance that a bot which meets the entry requirements announces an opened Blood Castle, Devil Square or Chaos Castle with /post. Rolled once per event type and opening. 0 = never. Invasion post chance (%) @@ -1921,10 +1919,6 @@ Chance that a bot answers a whisper of a player. 0 = never. Whisper reply cooldown Minimum time between two whisper replies of the same bot. - Lines - The lines the bots choose from, per situation. A situation without lines stays silent. - Keywords - Whisper replies only: comma-separated words or phrases of a player's whisper which this line answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. Greeting chance (%) Chance that the nearest bot greets a player who enters the game, in the normal chat. 0 = never. Greeting cooldown per player @@ -1937,4 +1931,26 @@ How often a bot standing in a safezone may say something in the normal chat, per game server. Only a bot which a player sees talks. Small talk chance (%) Chance that a bot makes small talk when the interval is over. 0 = never. + Keywords + Comma-separated words or phrases of a player's message which this reply answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. + Event lines + Posted with /post when the entrance of a Blood Castle, Devil Square or Chaos Castle opened; the bot then waits for players at the meeting point. Placeholders: {event}, {map} (the meeting point), {level}. + Invasion lines + Posted with /post when an invasion started. Placeholders: {event}, {map}, {level}. + Level milestones + Said in the normal chat when the bot reached a level milestone. Placeholders: {level}, {map}. + Whisper replies + Answers to a player who whispered to the bot or mentioned its name in the normal chat next to it. The best keyword match wins; replies without keywords are the fallback. Placeholders: {player}, {level}, {map}. + Greetings + Said by the nearest bot in the normal chat when a player entered the game. Placeholders: {player}, {level}, {map}. + Party: joined + Said in the party chat after the bot accepted a player's invitation. Placeholders: {player} (the inviter), {level}, {map}. + Party: event finished + Said in the party chat by one bot per party when an event it went through with a player's party ended. Placeholders: {event}, {level}. + Killed by a player + Said in the normal chat when a player killed the bot outside of an event. Placeholders: {player} (the killer), {level}, {map}. + Killed again + Said instead of the "Killed by a player" lines when the same player killed the bot again within a short time. Placeholders: {player}, {level}, {map}. + Small talk + Said now and then in the normal chat by a bot standing in a safezone which a player sees. Placeholders: {level}, {map}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index cea697756a..d0cfa175b4 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -90,21 +90,19 @@ public async ValueTask CooldownsArePerBotAndPerKindAsync() } /// - /// Only lines of the requested situation are picked, and blank lines are ignored - a situation - /// without a usable line stays silent. + /// Blank lines are ignored - a list without a usable line stays silent. /// [Test] - public void PicksOnlyUsableLinesOfTheSituation() + public void PicksOnlyUsableLines() { var lines = new List { - new() { Situation = BotChatSituation.WhisperReply, Text = "busy" }, - new() { Situation = BotChatSituation.LevelMilestone, Text = " " }, - new() { Situation = BotChatSituation.LevelMilestone, Text = "{level} finally" }, + new() { Text = " " }, + new() { Text = "{level} finally" }, }; - var picked = BotChatComposer.PickLine(lines, BotChatSituation.LevelMilestone, count => count - 1); - var silent = BotChatComposer.PickLine(lines, BotChatSituation.InvasionStarted, _ => 0); + var picked = BotChatComposer.PickLine(lines, count => count - 1); + var silent = BotChatComposer.PickLine(new List { new() { Text = string.Empty } }, _ => 0); Assert.That(picked?.Text.ValueInNeutralLanguage, Is.EqualTo("{level} finally")); Assert.That(silent, Is.Null); @@ -118,19 +116,15 @@ public void PicksOnlyUsableLinesOfTheSituation() public void RendersTheLineInTheReceiversLanguage() { var polish = CultureInfo.GetCultureInfo("pl"); - var line = new BotChatLine - { - Situation = BotChatSituation.InvasionStarted, - Text = new LocalizedString("{event} in {map}!").WithTranslation(polish, "{event} na {map}!"), - }; + var text = new LocalizedString("{event} in {map}!").WithTranslation(polish, "{event} na {map}!"); var values = new Dictionary> { ["event"] = culture => culture.Name == "pl" ? "Inwazja" : "Invasion", ["map"] = _ => "Devias", }; - Assert.That(BotChatComposer.Render(line, polish, values), Is.EqualTo("Inwazja na Devias!")); - Assert.That(BotChatComposer.Render(line, CultureInfo.GetCultureInfo("de"), values), Is.EqualTo("Invasion in Devias!")); + Assert.That(BotChatComposer.Render(text, polish, values), Is.EqualTo("Inwazja na Devias!")); + Assert.That(BotChatComposer.Render(text, CultureInfo.GetCultureInfo("de"), values), Is.EqualTo("Invasion in Devias!")); } /// @@ -140,9 +134,7 @@ public void RendersTheLineInTheReceiversLanguage() [Test] public void KeepsUnknownPlaceholders() { - var line = new BotChatLine { Situation = BotChatSituation.LevelMilestone, Text = "{lvl} done" }; - - Assert.That(BotChatComposer.Render(line, CultureInfo.InvariantCulture, NoValues), Is.EqualTo("{lvl} done")); + Assert.That(BotChatComposer.Render("{lvl} done", CultureInfo.InvariantCulture, NoValues), Is.EqualTo("{lvl} done")); } /// @@ -159,15 +151,14 @@ public void KeepsUnknownPlaceholders() [TestCase("asdf", "busy")] public void AnswersWhispersByKeywords(string message, string expected) { - var lines = new List + var replies = new List { - new() { Situation = BotChatSituation.WhisperReply, Text = "busy" }, - new() { Situation = BotChatSituation.WhisperReply, Text = "hi, what's up?", Keywords = "hi, hello" }, - new() { Situation = BotChatSituation.WhisperReply, Text = "fine, thanks for asking", Keywords = "how are you, how r u" }, - new() { Situation = BotChatSituation.LevelMilestone, Text = "not a reply", Keywords = "how are you" }, + new() { Text = "busy" }, + new() { Text = "hi, what's up?", Keywords = "hi, hello" }, + new() { Text = "fine, thanks for asking", Keywords = "how are you, how r u" }, }; - var reply = BotChatComposer.PickWhisperReply(lines, message, _ => 0); + var reply = BotChatComposer.PickWhisperReply(replies, message, _ => 0); Assert.That(reply?.Text.ValueInNeutralLanguage, Is.EqualTo(expected)); } @@ -196,7 +187,7 @@ public void DefaultRepliesAnswerHowAreYou() { var configuration = BotChatConfiguration.CreateDefault(); - var reply = BotChatComposer.PickWhisperReply(configuration.Lines, "hey, how are you?", _ => 0); + var reply = BotChatComposer.PickWhisperReply(configuration.WhisperReplies, "hey, how are you?", _ => 0); Assert.That(reply?.Keywords, Does.Contain("how are you")); } @@ -212,23 +203,24 @@ public void DefaultRepliesSpeakPolish(string message) { var configuration = BotChatConfiguration.CreateDefault(); - var reply = BotChatComposer.PickWhisperReply(configuration.Lines, message, _ => 0); - var text = reply is null ? null : BotChatComposer.Render(reply, CultureInfo.GetCultureInfo("pl-PL"), NoValues); + var reply = BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, _ => 0); + var text = reply is null ? null : BotChatComposer.Render(reply.Text, CultureInfo.GetCultureInfo("pl-PL"), NoValues); Assert.That(text, Is.EqualTo("dobrze, dzięki że pytasz")); } /// - /// A server which switches the plugin on has something to say in every situation. + /// A server which switches the plugin on has something to say in every situation: each list of + /// the default configuration has lines. /// [Test] public void DefaultConfigurationHasLinesForEverySituation() { var configuration = BotChatConfiguration.CreateDefault(); - foreach (var situation in Enum.GetValues()) + foreach (var property in typeof(BotChatConfiguration).GetProperties().Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>))) { - Assert.That(configuration.Lines.Any(line => line.Situation == situation), Is.True, situation.ToString()); + Assert.That(property.GetValue(configuration) as System.Collections.ICollection, Is.Not.Empty, property.Name); } } } From 7c4927f974d311973723085c7a2b7d98205a071b Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 17:57:29 +0100 Subject: [PATCH 11/44] feat(bots): tell where the bot stands with {location} {map} names the map only, which is little help on a big map: a player asking a bot "where are you?" still has to search for it. The new {location} placeholder adds the bot's coordinates the way players write them, e.g. "Devias 215, 40", so the mini map leads the way. The default "where are you" replies use it. In an event post, {location} is the meeting point next to the entry NPC; in an invasion post, the invaded map alone. --- docs-website/docs/server-features/bots.md | 6 +++++- src/GameLogic/Bots/BotChatComposer.cs | 13 ++++++++++++ src/GameLogic/Bots/BotChatConfiguration.cs | 21 ++++++++++--------- src/GameLogic/Bots/BotChatPlugIn.cs | 4 ++++ src/GameLogic/Properties/PlugInResources.resx | 18 ++++++++-------- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 12 +++++++++++ 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 485adb934d..6fa8ce0f4e 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -371,7 +371,11 @@ The default lines cover greetings, "how are you", the level, where the bot is, party, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, -`{level}`, and `{player}` (whisper replies). Each line is a localized text, so +`{level}`, `{player}` (whisper replies), and `{location}` — the map with the +bot's coordinates the way players write them, e.g. "Devias 215, 40", so a player +can find it with the mini map. The default "where are you?" replies use it. In +an event post, `{location}` is the meeting point; in an invasion post, just the +invaded map. Each line is a localized text, so every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index f3086b87f4..356d6a2216 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Globalization; using System.Text; using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; /// /// Chooses the line a bot says and renders it for one receiver: the translation of the receiver's @@ -88,6 +89,18 @@ internal static string Render(LocalizedString text, CultureInfo culture, IReadOn return result.ToString(); } + /// + /// Formats a place the way players write it in the chat, e.g. "Devias 215, 40", so a player can + /// find it with the coordinates of the mini map. + /// + /// The (translated) name of the map. + /// The coordinates on the map; null for the map alone. + /// The formatted place. + internal static string FormatLocation(string? mapName, Point? position) + => position is { } point + ? string.Create(CultureInfo.InvariantCulture, $"{mapName} {point.X}, {point.Y}") + : mapName ?? string.Empty; + /// /// Determines whether the message mentions the name as a whole word, ignoring case and /// punctuation - "hi Serael!" mentions Serael, "Seraelion" does not. diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 19c1523ecd..3319b8b8a3 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -129,7 +129,8 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot posts with /post when the entrance of a Blood Castle, Devil Square /// or Chaos Castle opened, before it waits for players at the meeting point (see ). - /// Placeholders: {event}, {map} (the map of the meeting point), {level}. + /// Placeholders: {event}, {map} (the map of the meeting point), {location} + /// (the meeting point with its coordinates), {level}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -138,7 +139,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot posts with /post when an invasion (White Wizard, Golden, Red Dragon) - /// started. Placeholders: {event}, {map}, {level}. + /// started. Placeholders: {event}, {map}, {location} (the map alone), {level}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -147,7 +148,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot says in the normal chat when it reached a level milestone - /// (see ). Placeholders: {level}, {map}. + /// (see ). Placeholders: {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -157,7 +158,7 @@ public class BotChatConfiguration /// /// Gets or sets the answers of a bot to a player who whispered to it, or mentioned its name in the normal /// chat next to it. The bot answers the same way: by whisper, or in the normal chat. - /// Placeholders: {player}, {level}, {map}. + /// Placeholders: {player}, {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -166,7 +167,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines the nearest bot greets a player with who entered the game. - /// Placeholders: {player}, {level}, {map}. + /// Placeholders: {player}, {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -175,7 +176,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot says in the party chat after it accepted a player's party invitation. - /// Placeholders: {player} (the inviter), {level}, {map}. + /// Placeholders: {player} (the inviter), {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -193,7 +194,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot says in the normal chat when a player killed it outside of an event, right - /// before it respawns. Placeholders: {player} (the killer), {level}, {map}. + /// before it respawns. Placeholders: {player} (the killer), {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -212,7 +213,7 @@ public class BotChatConfiguration /// /// Gets or sets the lines a bot standing in a safezone says now and then in the normal chat (see - /// ) - only when a player sees it. Placeholders: {level}, {map}. + /// ) - only when a player sees it. Placeholders: {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -309,8 +310,8 @@ public static BotChatConfiguration CreateDefault() ("lvl {level}", "lvl {level}")); AddReplies( "where are you, where r u, where, gdzie jestes, gdzie jesteś, gdzie", - ("{map}", "{map}"), - ("in {map}", "w {map}")); + ("{location}", "{location}"), + ("in {location}", "w {location}")); AddReplies( "party, pt, grupa, grupe, grupę, do grupy", ("sure, invite me", "jasne, zaproś mnie"), diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 9b30d06321..c63dae8c21 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -475,10 +475,12 @@ private static Dictionary> CreateBotValues(Pla { var level = GetLevel(bot).ToString(CultureInfo.InvariantCulture); var map = bot.CurrentMap?.Definition.Name; + var position = bot.Position; return new Dictionary> { ["level"] = _ => level, ["map"] = culture => map?.GetTranslation(culture) ?? string.Empty, + ["location"] = culture => BotChatComposer.FormatLocation(map?.GetTranslation(culture), position), }; } @@ -578,6 +580,7 @@ private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatCon var values = CreateBotValues(chosen); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; values["map"] = culture => meeting!.Map.Name.GetTranslation(culture) ?? string.Empty; + values["location"] = culture => BotChatComposer.FormatLocation(meeting!.Map.Name.GetTranslation(culture), meeting.NpcPosition); await BotChatSpeaker.PostAsync(chosen, line.Text, values).ConfigureAwait(false); } @@ -603,6 +606,7 @@ private async ValueTask PostAboutInvasionAsync(IGameContext gameContext, IPeriod var values = CreateBotValues(bot); values["event"] = culture => GetInvasionName(invasion, culture); values["map"] = culture => map.Name.GetTranslation(culture) ?? string.Empty; + values["location"] = values["map"]; await BotChatSpeaker.PostAsync(bot, line.Text, values).ConfigureAwait(false); } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 3eb03f2e8f..3bd4f8e737 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1934,23 +1934,23 @@ Keywords Comma-separated words or phrases of a player's message which this reply answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. Event lines - Posted with /post when the entrance of a Blood Castle, Devil Square or Chaos Castle opened; the bot then waits for players at the meeting point. Placeholders: {event}, {map} (the meeting point), {level}. + Posted with /post when the entrance of a Blood Castle, Devil Square or Chaos Castle opened; the bot then waits for players at the meeting point. Placeholders: {event}, {map} (the meeting point), {location} (the meeting point with coordinates), {level}. Invasion lines - Posted with /post when an invasion started. Placeholders: {event}, {map}, {level}. + Posted with /post when an invasion started. Placeholders: {event}, {map}, {location} (the map alone), {level}. Level milestones - Said in the normal chat when the bot reached a level milestone. Placeholders: {level}, {map}. + Said in the normal chat when the bot reached a level milestone. Placeholders: {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Whisper replies - Answers to a player who whispered to the bot or mentioned its name in the normal chat next to it. The best keyword match wins; replies without keywords are the fallback. Placeholders: {player}, {level}, {map}. + Answers to a player who whispered to the bot or mentioned its name in the normal chat next to it. The best keyword match wins; replies without keywords are the fallback. Placeholders: {player}, {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Greetings - Said by the nearest bot in the normal chat when a player entered the game. Placeholders: {player}, {level}, {map}. + Said by the nearest bot in the normal chat when a player entered the game. Placeholders: {player}, {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Party: joined - Said in the party chat after the bot accepted a player's invitation. Placeholders: {player} (the inviter), {level}, {map}. + Said in the party chat after the bot accepted a player's invitation. Placeholders: {player} (the inviter), {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Party: event finished Said in the party chat by one bot per party when an event it went through with a player's party ended. Placeholders: {event}, {level}. Killed by a player - Said in the normal chat when a player killed the bot outside of an event. Placeholders: {player} (the killer), {level}, {map}. + Said in the normal chat when a player killed the bot outside of an event. Placeholders: {player} (the killer), {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Killed again - Said instead of the "Killed by a player" lines when the same player killed the bot again within a short time. Placeholders: {player}, {level}, {map}. + Said instead of the "Killed by a player" lines when the same player killed the bot again within a short time. Placeholders: {player}, {level}, {map}, {location}. Small talk - Said now and then in the normal chat by a bot standing in a safezone which a player sees. Placeholders: {level}, {map}. + Said now and then in the normal chat by a bot standing in a safezone which a player sees. Placeholders: {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index d0cfa175b4..1c1fe662f5 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Tests; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; /// /// Tests the bot chat (): the limits which keep a large bot population @@ -137,6 +138,17 @@ public void KeepsUnknownPlaceholders() Assert.That(BotChatComposer.Render("{lvl} done", CultureInfo.InvariantCulture, NoValues), Is.EqualTo("{lvl} done")); } + /// + /// A place is written the way players write it - the map, then the coordinates - and without + /// coordinates it's the map alone. + /// + [Test] + public void FormatsALocationLikePlayersDo() + { + Assert.That(BotChatComposer.FormatLocation("Devias", new Point(215, 40)), Is.EqualTo("Devias 215, 40")); + Assert.That(BotChatComposer.FormatLocation("Devias", null), Is.EqualTo("Devias")); + } + /// /// A whisper is answered by the lines whose keywords match it - ignoring case and punctuation, /// preferring the longest match, and matching whole words only. Without a match, a reply From 984cb074ba3231044de36f38ccf8523162c7ce5f Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:02:45 +0100 Subject: [PATCH 12/44] feat(bots): answer a player's party search posted with /post A player looking for party members can now ask the bots the way players ask each other: "/post party 300-350 bk dw". A post with a party word and a level (a range, "300+", "min 300" or a single number) calls up to five free bots of the game server whose level fits; class words narrow it down, each meaning its whole class line (bk = Dark Knight, Blade Knight, Blade Master). The bots whisper to the player a few seconds apart, and the same search posted again calls five others. The player whispers back to a bot where to meet - "devias 215 40", a map name alone, or "here" - and the bot warps there, walks up and waits for the invitation, using the meeting of the event announcements. A map the bot may not go to it declines; a whisper without a place gets asked again. Map names are recognized in every translation. The /post command had no hook, and its messages go out as gens chat, so they can't be told apart from the gens chat in the chat-sent plugin point. A new plugin point reports the posts alone. --- docs-website/docs/server-features/bots.md | 16 +- src/GameLogic/Bots/BotChatComposer.cs | 57 ++++++ src/GameLogic/Bots/BotChatConfiguration.cs | 113 +++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 179 ++++++++++++++++++ src/GameLogic/Bots/BotChatThrottle.cs | 89 ++++++++- src/GameLogic/Bots/BotClassAlias.cs | 31 +++ src/GameLogic/Bots/BotEventMeeting.cs | 17 +- src/GameLogic/Bots/BotPartyOffer.cs | 13 ++ src/GameLogic/Bots/BotPartyRequest.cs | 88 +++++++++ .../ChatCommands/PostChatCommandPlugIn.cs | 4 + .../PlugIns/IPostMessageSentPlugIn.cs | 30 +++ .../Properties/PlugInResources.Designer.cs | 72 +++++++ src/GameLogic/Properties/PlugInResources.resx | 24 +++ .../BotPartySearchTest.cs | 151 +++++++++++++++ 14 files changed, 872 insertions(+), 12 deletions(-) create mode 100644 src/GameLogic/Bots/BotClassAlias.cs create mode 100644 src/GameLogic/Bots/BotPartyOffer.cs create mode 100644 src/GameLogic/Bots/BotPartyRequest.cs create mode 100644 src/GameLogic/PlugIns/IPostMessageSentPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 6fa8ce0f4e..8522edd431 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -362,6 +362,20 @@ invites it, it goes back to hunting when the entrance closes. A bot does not announce an event whose entrance closes in less than two minutes, and a bot already in a party with a player does not look for company. +A player can also search party members among the bots with a post: +`/post party 300-350 bk dw`. A post with one of the party words (`party`, `pt`, +`grupa`, …) and a level — `300-350`, `300+`, `min 300` or just `300` — calls up +to five free bots of the game server whose level fits; class words (`dk`, `bk`, +`bm`, `dw`, `elf`, `mg`, `dl`, `sum`, `rf`, … — each meaning its whole class +line) narrow it down. Each of them whispers to the player, a few seconds apart. +The same search posted again calls five others. The player whispers back to a +bot where to meet — `devias 215 40`, a map name alone, or `here` for where the +player stands — and the bot warps there, walks up and waits five minutes for +the invitation. A map it may not go to (its level is too low, no warp leads +there) it declines; a whisper without a place gets asked again. Map names are +recognized in every translation of the map. The words, the number of bots and +the times are part of the configuration. + A whisper reply can carry `Keywords`, a comma-separated list of words or phrases it answers (`how are you, how r u`). The bot picks among the replies whose keyword matches the whisper — whole words, ignoring case and punctuation, @@ -371,7 +385,7 @@ The default lines cover greetings, "how are you", the level, where the bot is, party, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, -`{level}`, `{player}` (whisper replies), and `{location}` — the map with the +`{level}`, `{class}`, `{player}` (whisper replies), and `{location}` — the map with the bot's coordinates the way players write them, e.g. "Devias 215, 40", so a player can find it with the mini map. The default "where are you?" replies use it. In an event post, `{location}` is the meeting point; in an invasion post, just the diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 356d6a2216..16aff4a846 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -112,6 +112,63 @@ internal static bool MentionsName(string message, string name) => Normalize(name) is { Length: > 0 } normalizedName && $" {Normalize(message)} ".Contains($" {normalizedName} ", StringComparison.Ordinal); + /// + /// Determines whether the message contains one of the comma-separated keywords as whole words, + /// ignoring case and punctuation. + /// + /// The message. + /// The comma-separated keywords, e.g. party, pt. + /// true, if a keyword is in the message. + internal static bool ContainsAnyKeyword(string message, string? keywords) + { + var normalizedMessage = $" {Normalize(message)} "; + return GetKeywords(keywords).Any(keyword => normalizedMessage.Contains($" {keyword} ", StringComparison.Ordinal)); + } + + /// + /// Reads a meeting place from a player's message like "devias 215 40": the map whose name + /// (in any of its translations) is in the message, and the first two numbers after removing the + /// name as the coordinates. + /// + /// The player's message. + /// The maps of the game. + /// The map and, if the message names them, the coordinates; null when no map is named. + /// + /// The longest matching name wins, so "lost tower" is not taken for a map called "tower". The + /// name is removed before the numbers are read, so a map like "Kalima 1" keeps its digit. + /// + internal static (GameMapDefinition Map, Point? Position)? FindPlace(string message, IEnumerable maps) + { + var normalizedMessage = $" {Normalize(message)} "; + var best = maps + .SelectMany(map => GetNames(map).Select(name => (Map: map, Name: name))) + .Where(candidate => normalizedMessage.Contains($" {candidate.Name} ", StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Name.Length) + .FirstOrDefault(); + if (best.Map is null) + { + return null; + } + + var rest = normalizedMessage.Replace($" {best.Name} ", " ", StringComparison.Ordinal); + var numbers = rest + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => byte.TryParse(word, NumberStyles.None, CultureInfo.InvariantCulture, out var number) ? (byte?)number : null) + .Where(number => number is not null) + .Take(2) + .ToList(); + Point? position = numbers.Count == 2 ? new Point(numbers[0]!.Value, numbers[1]!.Value) : null; + return (best.Map, position); + + static IEnumerable GetNames(GameMapDefinition map) + => map.Name.GetTranslations() + .Select(translation => translation.Value) + .Append(map.Name.ValueInNeutralLanguage) + .Select(Normalize) + .Where(name => name.Length > 0) + .Distinct(); + } + private static IEnumerable GetKeywords(string? keywords) => (keywords ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 3319b8b8a3..034f9b5ded 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -106,6 +106,43 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Description))] public int PartyChatChancePercent { get; set; } = 70; + /// + /// Gets or sets how many bots answer a player's party search posted with /post, e.g. + /// "party 300-350 bk" (see ); 0 switches it off. The same search + /// posted again is answered by other bots. + /// + [Range(0, 20)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerCount_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerCount_Description))] + public int PartyVolunteerCount { get; set; } = 5; + + /// + /// Gets or sets the comma-separated words which mark a post as a party search; the post needs a + /// level as well. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyRequestKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyRequestKeywords_Description))] + public string PartyRequestKeywords { get; set; } = "party, pt, grupa, grupy, grupe, grupę, grp"; + + /// + /// Gets or sets the comma-separated words with which a player tells an answering bot to come to + /// where the player stands, instead of naming a map and coordinates. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyHereKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyHereKeywords_Description))] + public string PartyHereKeywords { get; set; } = "here, come here, to me, tu, tutaj, do mnie"; + + /// + /// Gets or sets how long a bot which answered a party search waits for the player to whisper + /// back where to meet. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyOfferTimeout_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyOfferTimeout_Description))] + public TimeSpan PartyOfferTimeout { get; set; } = TimeSpan.FromMinutes(3); + + /// + /// Gets or sets how long a bot waits at the meeting place for the player's party invitation + /// before it goes back to hunting. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyMeetingWaitTime_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyMeetingWaitTime_Description))] + public TimeSpan PartyMeetingWaitTime { get; set; } = TimeSpan.FromMinutes(5); + /// /// Gets or sets the chance, in percent, that a bot killed by a player says something about it. /// @@ -192,6 +229,51 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Description))] public ICollection EventFinishedLines { get; set; } = new List(); + /// + /// Gets or sets the whispers with which a bot answers a player's party search. + /// Placeholders: {player}, {level}, {class}, {map}, {location}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerLines_Description))] + public ICollection PartyVolunteerLines { get; set; } = new List(); + + /// + /// Gets or sets the whispers with which a bot asks again when it didn't understand where to meet. + /// Placeholders: {player}, {level}, {class}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyAskWhereLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyAskWhereLines_Description))] + public ICollection PartyAskWhereLines { get; set; } = new List(); + + /// + /// Gets or sets the whispers with which a bot confirms that it's on its way to the meeting place. + /// Placeholders: {player}, {map} and {location} (the meeting place). + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyOnTheWayLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyOnTheWayLines_Description))] + public ICollection PartyOnTheWayLines { get; set; } = new List(); + + /// + /// Gets or sets the whispers with which a bot declines a meeting place it may not go to - its level + /// is too low for the map, or the map can't be reached by warp. + /// Placeholders: {player}, {map} (the named map), {level}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyCantGoLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyCantGoLines_Description))] + public ICollection PartyCantGoLines { get; set; } = new List(); + + /// + /// Gets or sets the words players use for the character classes in a party search. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyClassAliases_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyClassAliases_Description))] + public ICollection PartyClassAliases { get; set; } = new List(); + /// /// Gets or sets the lines a bot says in the normal chat when a player killed it outside of an event, right /// before it respawns. Placeholders: {player} (the killer), {level}, {map}, {location}. @@ -226,6 +308,8 @@ public class BotChatConfiguration /// /// The default configuration. /// + /// The class words leave out "me" and "he" (Muse Elf, High Elf): "party 300 for me" would + /// otherwise call elves only. /// The lines are configuration data like the names of the seeded items, not texts of the game /// logic: a server owner is expected to rewrite them and to add translations. Event, map and item /// names stay English in the Polish lines - the game data names them that way. The keywords hold @@ -294,6 +378,32 @@ public static BotChatConfiguration CreateDefault() ("anyone up for bc later?", "ktoś na bc później?"), ("so many people today", "dużo ludzi dzisiaj"), ("gm online?", "jest gm?")); + Add( + configuration.PartyVolunteerLines, + ("me? {level} {class}", "ja? {level} {class}"), + ("i can come, {class} {level}", "mogę przyjść, {class} {level}"), + ("{class} {level} here, where are you?", "{class} {level}, gdzie jesteś?"), + ("count me in, lvl {level}. where?", "wchodzę, lvl {level}. gdzie?")); + Add( + configuration.PartyAskWhereLines, + ("where? write map and coords, e.g. devias 215 40", "gdzie? napisz mapę i koordy, np. devias 215 40"), + ("which map? like lorencia 130 120", "jaka mapa? np. lorencia 130 120")); + Add( + configuration.PartyOnTheWayLines, + ("omw to {location}", "idę, {location}"), + ("ok, coming to {location}", "ok, już idę do {location}"), + ("on my way", "już lecę")); + Add( + configuration.PartyCantGoLines, + ("cant go to {map}, sry", "nie mogę iść do {map}, sry"), + ("{map} is too much for me", "{map} to za dużo dla mnie")); + AddClass("dk, bk, bm, knight, dark knight, blade knight, blade master, rycerz", BotClassNumbers.DarkKnightNumber); + AddClass("dw, sm, gm, wizard, dark wizard, soul master, grand master, mag", BotClassNumbers.DarkWizardNumber); + AddClass("elf, fe, muse, fairy elf, muse elf, high elf, elfka", BotClassNumbers.FairyElfNumber); + AddClass("mg, dm, gladiator, magic gladiator, duel master", BotClassNumbers.MagicGladiatorNumber); + AddClass("dl, le, lord, dark lord, lord emperor", BotClassNumbers.DarkLordNumber); + AddClass("sum, summoner, bs, dim, bloody summoner, dimension master", BotClassNumbers.SummonerNumber); + AddClass("rf, fm, fighter, rage fighter, fist master", BotClassNumbers.RageFighterNumber); AddReplies( "how are you, how r u, how are u, hru, how's it going, how is it going, jak sie masz, jak się masz, co slychac, co słychać, co tam", ("fine, thanks for asking", "dobrze, dzięki że pytasz"), @@ -342,6 +452,9 @@ void AddReplies(string keywords, params (string English, string Polish)[] texts) } } + void AddClass(string keywords, byte classNumber) + => configuration.PartyClassAliases.Add(new BotClassAlias { Keywords = keywords, ClassNumber = classNumber }); + static LocalizedString Translated((string English, string Polish) text) => new LocalizedString(text.English).WithTranslation(CultureInfo.GetCultureInfo("pl"), text.Polish); } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index c63dae8c21..2fd629b948 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -16,6 +16,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; /// @@ -48,6 +49,7 @@ public class BotChatPlugIn IAttackableGotKilledPlugIn, IChatMessageSentPlugIn, IPeriodicTaskPlugIn, + IPostMessageSentPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -97,6 +99,12 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) KilledDelaySeconds = (1, 2); + /// + /// The gap between the whispers of two bots answering the same party search, so the answers + /// trickle in like those of players who read the post at different moments. + /// + private static readonly (int Min, int Max) PartyVolunteerSpacingSeconds = (2, 6); + private readonly ConditionalWeakTable _throttles = new(); /// @@ -177,6 +185,19 @@ public void CharacterLeveledUp(Player player) /// public void WhisperMessageReceived(Player sender, Player receiver, string message, CancelEventArgs cancelEventArgs) { + // A bot which answered the sender's party search waits for the meeting place - that whisper is + // part of a conversation the player started, so it's always answered. + if (this.Configuration is { } partyConfiguration + && receiver is OfflinePlayer partyBot + && IsBot(partyBot) + && sender is not OfflinePlayer + && this.GetThrottle(receiver.GameContext).GetOpenPartyOffer(partyBot, DateTime.UtcNow) is { } offer + && ReferenceEquals(offer.Requester, sender)) + { + this.AnswerMeetingPlace(partyBot, sender, message, partyConfiguration); + return; + } + if (this.Configuration is not { WhisperReplyChancePercent: > 0 } configuration || !IsBot(receiver) || sender is OfflinePlayer @@ -376,6 +397,51 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) /// public void ForceStart() => Interlocked.Increment(ref this._forceStartCount); + /// + public async ValueTask PostMessageSentAsync(Player sender, string message) + { + if (this.Configuration is not { PartyVolunteerCount: > 0 } configuration + || sender is OfflinePlayer + || sender.SelectedCharacter?.Name is not { } senderName + || BotPartyRequest.TryParse(message, configuration.PartyRequestKeywords, configuration.PartyClassAliases) is not { } request) + { + return; + } + + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(sender.GameContext); + var classes = sender.GameContext.Configuration.CharacterClasses; + var players = await sender.GameContext.GetPlayersAsync().ConfigureAwait(false); + var candidates = players + .OfType() + .Where(bot => IsBot(bot) + && IsAvailable(bot) + && IsFreeToMeet(bot) + && throttle.GetOpenPartyOffer(bot, now) is null + && request.IsWantedLevel(GetLevel(bot)) + && IsOfWantedClass(bot, request, classes)); + var volunteers = throttle.TakeNewVolunteers(sender, candidates, configuration.PartyVolunteerCount, now, count => Rand.NextInt(0, count)); + var delay = TimeSpan.Zero; + foreach (var bot in volunteers) + { + if (PickLine(configuration.PartyVolunteerLines) is not { } line) + { + return; + } + + throttle.SetPartyOffer(bot, new BotPartyOffer(sender, now + configuration.PartyOfferTimeout)); + delay += RandomDelay(PartyVolunteerSpacingSeconds); + var values = CreateBotValues(bot); + values["player"] = _ => senderName; + RunLater( + bot.GameContext, + delay, + () => IsAvailable(bot) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld + ? BotChatSpeaker.WhisperAsync(bot, sender, line.Text, values) + : ValueTask.CompletedTask); + } + } + /// /// Determines whether the bot may warp to the map according to the warp list - the same rule the /// bots' navigation follows (the plain level against the class-reduced warp requirement) - and the @@ -419,8 +485,51 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) .Select(candidate => candidate.Bot) .FirstOrDefault(); + /// + /// Gets the first class of the line the class belongs to, e.g. the Dark Knight for the Blade + /// Master - by following the class evolution of the configuration backwards. + /// + /// The character class. + /// All character classes of the configuration. + /// The first class of the line. + internal static CharacterClass GetFirstClassOfLine(CharacterClass characterClass, ICollection classes) + { + var current = characterClass; + + // Bounded, so a misconfigured evolution cycle can't hang the game logic. + for (var step = 0; step < classes.Count; step++) + { + if (classes.FirstOrDefault(c => c.NextGenerationClass?.Number == current.Number) is not { } previous) + { + break; + } + + current = previous; + } + + return current; + } + private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; + private static bool IsOfWantedClass(Player bot, BotPartyRequest request, ICollection classes) + { + if (request.ClassNumbers.Count == 0) + { + return true; + } + + if (bot.SelectedCharacter?.CharacterClass is not { } ownClass) + { + return false; + } + + var ownLine = GetFirstClassOfLine(ownClass, classes).Number; + return request.ClassNumbers.Any(number => + classes.FirstOrDefault(c => c.Number == number) is { } wanted + && GetFirstClassOfLine(wanted, classes).Number == ownLine); + } + /// /// Determines whether the bot is free to wait for players at an event: not already waiting, not on /// an errand or a revenge, and not in a party with a player - it would look for company it has. @@ -476,9 +585,11 @@ private static Dictionary> CreateBotValues(Pla var level = GetLevel(bot).ToString(CultureInfo.InvariantCulture); var map = bot.CurrentMap?.Definition.Name; var position = bot.Position; + var characterClass = bot.SelectedCharacter?.CharacterClass?.Name; return new Dictionary> { ["level"] = _ => level, + ["class"] = culture => characterClass?.GetTranslation(culture) ?? string.Empty, ["map"] = culture => map?.GetTranslation(culture) ?? string.Empty, ["location"] = culture => BotChatComposer.FormatLocation(map?.GetTranslation(culture), position), }; @@ -517,6 +628,74 @@ private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + /// + /// Answers the whisper in which a player, whose party search the bot answered, names the meeting + /// place: "devias 215 40", or "here" for where the player stands. A bot which may go there sets + /// off and waits for the invitation (see ); a place it may not go to + /// is declined, and a whisper without a place gets asked again. + /// + private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string message, BotChatConfiguration configuration) + { + var throttle = this.GetThrottle(bot.GameContext); + var values = CreateBotValues(bot); + var requesterName = requester.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => requesterName; + + // A named map wins over "here": "i'm here, devias 215 40" names the place. + var place = BotChatComposer.FindPlace(message, bot.GameContext.Configuration.Maps); + if (place is null + && BotChatComposer.ContainsAnyKeyword(message, configuration.PartyHereKeywords) + && requester.CurrentMap is { } requesterMap) + { + place = (requesterMap.Definition, requester.Position); + } + + if (place is not { } found) + { + if (throttle.TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown) + && PickLine(configuration.PartyAskWhereLines) is { } askLine) + { + this.WhisperLater(bot, requester, askLine, values); + } + + return; + } + + var (map, position) = found; + throttle.RemovePartyOffer(bot); + values["map"] = culture => map.Name.GetTranslation(culture) ?? string.Empty; + values["location"] = culture => BotChatComposer.FormatLocation(map.Name.GetTranslation(culture), position); + if (!IsAvailable(bot) || !IsFreeToMeet(bot)) + { + // Taken into another party, or busy otherwise, since it answered: it lets the player be. + return; + } + + if (!MayWarpTo(bot, map) || !map.ExitGates.Any(gate => gate.IsSpawnGate)) + { + if (PickLine(configuration.PartyCantGoLines) is { } cantGoLine) + { + this.WhisperLater(bot, requester, cantGoLine, values); + } + + return; + } + + bot.EventMeeting = new BotEventMeeting(map, position, DateTime.UtcNow + configuration.PartyMeetingWaitTime); + if (PickLine(configuration.PartyOnTheWayLines) is { } onTheWayLine) + { + this.WhisperLater(bot, requester, onTheWayLine, values); + } + } + + private void WhisperLater(Player bot, Player receiver, BotChatLine line, Dictionary> values) + => RunLater( + bot.GameContext, + RandomDelay(WhisperReplyDelaySeconds), + () => receiver.PlayerState.CurrentState == PlayerState.EnteredWorld + ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values) + : ValueTask.CompletedTask); + private async ValueTask GreetAsync(Player player, BotChatConfiguration configuration) { if (player.PlayerState.CurrentState != PlayerState.EnteredWorld diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 157fc4837b..e5b6d3190b 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -9,9 +9,9 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Threading; /// -/// The chat limits of the bots of one game server: the server-wide gap between two /post -/// messages, the once-per-occasion announcement of an event, and the per-bot cooldowns of the -/// normal chat and of whisper replies. +/// The chat limits and the short-term memory of the bots of one game server: the server-wide gap +/// between two /post messages, the once-per-occasion announcement of an event, the per-bot +/// cooldowns of the normal chat and of whisper replies, and the bots' answers to party searches. /// /// /// Every check reserves its slot atomically, so two bots reacting to the same event at the same @@ -25,12 +25,22 @@ internal sealed class BotChatThrottle /// private static readonly TimeSpan MinimumSmallTalkInterval = TimeSpan.FromSeconds(10); + /// + /// How long the bots remember whom they answered a party search of: the same search posted again + /// within this time is answered by other bots. + /// + private static readonly TimeSpan PartySearchMemory = TimeSpan.FromMinutes(30); + private readonly ConcurrentDictionary _occasions = new(); private readonly ConditionalWeakTable _bots = new(); private readonly Lock _smallTalkLock = new(); + private readonly ConditionalWeakTable _partyOffers = new(); + + private readonly ConditionalWeakTable _partySearches = new(); + private long _nextPostTicks; private DateTime _nextSmallTalkUtc; @@ -125,6 +135,63 @@ public bool TryStartSmallTalk(DateTime now, TimeSpan interval, int forceStartCou } } + /// + /// Picks the bots which answer a player's party search: up to the given number of the candidates, + /// at random, leaving out the bots which already answered this player's earlier searches. + /// + /// The searching player. + /// The bots which could answer. + /// The maximum number of answering bots. + /// The current time. + /// Returns a random index below the given count. + /// The answering bots. + public IReadOnlyList TakeNewVolunteers(Player requester, IEnumerable candidates, int count, DateTime now, Func pickIndex) + { + var memory = this._partySearches.GetOrCreateValue(requester); + lock (memory) + { + if (now - memory.LastSearchUtc > PartySearchMemory) + { + memory.Bots.Clear(); + } + + memory.LastSearchUtc = now; + var remaining = candidates.Where(bot => !memory.Bots.Contains(bot)).ToList(); + var volunteers = new List(); + while (volunteers.Count < count && remaining.Count > 0) + { + var index = pickIndex(remaining.Count); + volunteers.Add(remaining[index]); + remaining.RemoveAt(index); + } + + memory.Bots.UnionWith(volunteers); + return volunteers; + } + } + + /// + /// Remembers that the bot answered a player's party search and waits for the meeting place. + /// + /// The bot. + /// The offer. + public void SetPartyOffer(Player bot, BotPartyOffer offer) => this._partyOffers.AddOrUpdate(bot, offer); + + /// + /// Gets the bot's open offer to a player's party search, if it has one which didn't expire yet. + /// + /// The bot. + /// The current time. + /// The open offer, or null. + public BotPartyOffer? GetOpenPartyOffer(Player bot, DateTime now) + => this._partyOffers.TryGetValue(bot, out var offer) && offer.ExpiresUtc > now ? offer : null; + + /// + /// Forgets the bot's offer to a player's party search. + /// + /// The bot. + public void RemovePartyOffer(Player bot) => this._partyOffers.Remove(bot); + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) @@ -142,6 +209,22 @@ private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan } } + /// + /// The bots which answered the party searches of one player. + /// + private sealed class PartySearchVolunteers + { + /// + /// Gets the bots which answered. + /// + public HashSet Bots { get; } = []; + + /// + /// Gets or sets the time of the player's last search. + /// + public DateTime LastSearchUtc { get; set; } + } + /// /// The cooldowns of one bot, as raw ticks for the interlocked updates. /// diff --git a/src/GameLogic/Bots/BotClassAlias.cs b/src/GameLogic/Bots/BotClassAlias.cs new file mode 100644 index 0000000000..c8212e1c21 --- /dev/null +++ b/src/GameLogic/Bots/BotClassAlias.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// The words players use for a line of character classes in a party search, e.g. "dk, bk, bm" for +/// the Dark Knight, the Blade Knight and the Blade Master (see ). +/// +/// +/// The stands for its whole line of classes: a search for "bk" is answered +/// by Dark Knights, Blade Knights and Blade Masters, whichever of them the alias names. +/// +public class BotClassAlias +{ + /// + /// Gets or sets the comma-separated words for the class line, e.g. dk, bk, bm, knight. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotClassAlias_Keywords_Name), Description = nameof(PlugInResources.BotClassAlias_Keywords_Description))] + public string Keywords { get; set; } = string.Empty; + + /// + /// Gets or sets the number of a character class of the line, e.g. 4 for the Dark Knight. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotClassAlias_ClassNumber_Name), Description = nameof(PlugInResources.BotClassAlias_ClassNumber_Description))] + public byte ClassNumber { get; set; } + + /// + public override string ToString() => $"{this.ClassNumber}: {this.Keywords}"; +} diff --git a/src/GameLogic/Bots/BotEventMeeting.cs b/src/GameLogic/Bots/BotEventMeeting.cs index a7d87626af..e1ea7d0e83 100644 --- a/src/GameLogic/Bots/BotEventMeeting.cs +++ b/src/GameLogic/Bots/BotEventMeeting.cs @@ -7,16 +7,17 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.Pathfinding; /// -/// Where and until when a bot waits for players after it announced an opened event (see -/// ). A bot which posts "Blood Castle open, anyone going?" and keeps -/// hunting in another corner of the world could never be found - so it goes to the meeting point and -/// stays there until the entrance closes, ready to be invited into a party. +/// Where and until when a bot waits for players after it announced an opened event, or after a +/// player whose party search it answered named the meeting place (see ). +/// A bot which posts "Blood Castle open, anyone going?" and keeps hunting in another corner of the +/// world could never be found - so it goes to the meeting point and stays there until the entrance +/// closes, ready to be invited into a party. /// /// The map of the meeting point. /// -/// The position of the event's entry NPC (Messenger of Archangel, Charon), next to which the bot waits; -/// null to wait anywhere in the map's safezone - Chaos Castle is entered with the ticket alone, -/// it has no entry NPC. +/// The position next to which the bot waits: the event's entry NPC (Messenger of Archangel, Charon), +/// or the coordinates the player named; null to wait anywhere in the map's safezone - Chaos +/// Castle is entered with the ticket alone, it has no entry NPC. /// -/// When the event's entrance closes. +/// When the bot stops waiting, e.g. when the event's entrance closes. internal sealed record BotEventMeeting(GameMapDefinition Map, Point? NpcPosition, DateTime UntilUtc); diff --git a/src/GameLogic/Bots/BotPartyOffer.cs b/src/GameLogic/Bots/BotPartyOffer.cs new file mode 100644 index 0000000000..4d63794853 --- /dev/null +++ b/src/GameLogic/Bots/BotPartyOffer.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// A bot's answer to a player's party search (see ): the bot whispered +/// that it would come and now waits for the player to whisper back where to meet. +/// +/// The player who searched for party members. +/// Until when the bot waits for the player's answer. +internal sealed record BotPartyOffer(Player Requester, DateTime ExpiresUtc); diff --git a/src/GameLogic/Bots/BotPartyRequest.cs b/src/GameLogic/Bots/BotPartyRequest.cs new file mode 100644 index 0000000000..acb807bacc --- /dev/null +++ b/src/GameLogic/Bots/BotPartyRequest.cs @@ -0,0 +1,88 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +using System.Text.RegularExpressions; + +/// +/// A player's search for party members, read from a /post like "party 300-350 bk dw": +/// which levels and - optionally - which character classes the player looks for. +/// +/// The lowest wanted level. +/// The highest wanted level; when the post names a minimum only. +/// +/// The numbers of the wanted character classes, as configured in the +/// ; empty when the post names no class. +/// +internal sealed partial record BotPartyRequest(int MinimumLevel, int MaximumLevel, IReadOnlyCollection ClassNumbers) +{ + /// + /// Reads a party search from a posted message. The message must contain one of the keywords and a + /// level: "300-350" (a range), "300+", "min 300" or a single "300" (a minimum). + /// + /// The posted message. + /// The comma-separated words which mark a party search, e.g. party, pt. + /// The words for the character classes. + /// The party search, or null when the message is no party search. + /// + /// A post without a level is no party search: "thx for the party" must not call the bots. + /// + internal static BotPartyRequest? TryParse(string message, string keywords, IEnumerable classAliases) + { + if (!BotChatComposer.ContainsAnyKeyword(message, keywords) + || ParseLevels(message.ToLowerInvariant()) is not { } levels) + { + return null; + } + + var classNumbers = classAliases + .Where(alias => BotChatComposer.ContainsAnyKeyword(message, alias.Keywords)) + .Select(alias => alias.ClassNumber) + .Distinct() + .ToList(); + return new BotPartyRequest(levels.Minimum, levels.Maximum, classNumbers); + } + + /// + /// Determines whether the level is wanted. + /// + /// The level. + /// true, if the level is within the wanted range. + internal bool IsWantedLevel(int level) => level >= this.MinimumLevel && level <= this.MaximumLevel; + + private static (int Minimum, int Maximum)? ParseLevels(string message) + { + if (RangeRegex().Match(message) is { Success: true } range) + { + var first = ParseNumber(range.Groups[1].Value); + var second = ParseNumber(range.Groups[2].Value); + return (Math.Min(first, second), Math.Max(first, second)); + } + + if (MinimumRegex().Match(message) is { Success: true } minimum) + { + return (ParseNumber(minimum.Groups[1].Success ? minimum.Groups[1].Value : minimum.Groups[2].Value), int.MaxValue); + } + + return NumberRegex().Match(message) is { Success: true } number + ? (ParseNumber(number.Groups[1].Value), int.MaxValue) + : null; + } + + private static int ParseNumber(string text) => int.Parse(text, NumberStyles.None, CultureInfo.InvariantCulture); + + /// "300-350", "300 - 350", "300~350". + [GeneratedRegex(@"\b(\d{1,4})\s*[-~]\s*(\d{1,4})\b")] + private static partial Regex RangeRegex(); + + /// "300+" or "min 300", "min. 300". + [GeneratedRegex(@"\b(\d{1,4})\s*\+|\bmin\.?\s*(\d{1,4})\b")] + private static partial Regex MinimumRegex(); + + /// A single number, e.g. in "party 300" or "lvl 300". + [GeneratedRegex(@"\b(\d{1,4})\b")] + private static partial Regex NumberRegex(); +} diff --git a/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs index 605e53e1da..6f8cd9a6fc 100644 --- a/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs @@ -43,6 +43,10 @@ public async ValueTask HandleCommandAsync(Player player, string command) } await player.GameContext.SendGlobalChatMessageAsync(PostSender, FormatPost(player.SelectedCharacter?.Name, message), ChatMessageType.Gens).ConfigureAwait(false); + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.PostMessageSentAsync(player, message).ConfigureAwait(false); + } } /// diff --git a/src/GameLogic/PlugIns/IPostMessageSentPlugIn.cs b/src/GameLogic/PlugIns/IPostMessageSentPlugIn.cs new file mode 100644 index 0000000000..2217322712 --- /dev/null +++ b/src/GameLogic/PlugIns/IPostMessageSentPlugIn.cs @@ -0,0 +1,30 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called after a player posted a message to the whole game server with +/// the /post command. +/// +/// +/// A post goes out as a message, so it can't be told +/// apart from the gens chat through the ; this plugin point +/// reports the posts alone. +/// +[Guid("CCBECE06-7FDA-42F2-BA16-97CF1ACD1EAF")] +[PlugInPoint("Post message sent", "Plugins which are called after a player posted a message to the whole game server with /post.")] +public interface IPostMessageSentPlugIn +{ + /// + /// Is called after the player posted the message. + /// + /// The posting player. + /// The posted message, without the command. + /// The task. + ValueTask PostMessageSentAsync(Player sender, string message); +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index b443585cdb..ff1efaf248 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5280,5 +5280,77 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_SmallTalkLines_Description text. public static string BotChatConfiguration_SmallTalkLines_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_PartyVolunteerCount_Name text. + public static string BotChatConfiguration_PartyVolunteerCount_Name => ResourceManager.GetString("BotChatConfiguration_PartyVolunteerCount_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyVolunteerCount_Description text. + public static string BotChatConfiguration_PartyVolunteerCount_Description => ResourceManager.GetString("BotChatConfiguration_PartyVolunteerCount_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyRequestKeywords_Name text. + public static string BotChatConfiguration_PartyRequestKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyRequestKeywords_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyRequestKeywords_Description text. + public static string BotChatConfiguration_PartyRequestKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyRequestKeywords_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyHereKeywords_Name text. + public static string BotChatConfiguration_PartyHereKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyHereKeywords_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyHereKeywords_Description text. + public static string BotChatConfiguration_PartyHereKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyHereKeywords_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyOfferTimeout_Name text. + public static string BotChatConfiguration_PartyOfferTimeout_Name => ResourceManager.GetString("BotChatConfiguration_PartyOfferTimeout_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyOfferTimeout_Description text. + public static string BotChatConfiguration_PartyOfferTimeout_Description => ResourceManager.GetString("BotChatConfiguration_PartyOfferTimeout_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyMeetingWaitTime_Name text. + public static string BotChatConfiguration_PartyMeetingWaitTime_Name => ResourceManager.GetString("BotChatConfiguration_PartyMeetingWaitTime_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyMeetingWaitTime_Description text. + public static string BotChatConfiguration_PartyMeetingWaitTime_Description => ResourceManager.GetString("BotChatConfiguration_PartyMeetingWaitTime_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyVolunteerLines_Name text. + public static string BotChatConfiguration_PartyVolunteerLines_Name => ResourceManager.GetString("BotChatConfiguration_PartyVolunteerLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyVolunteerLines_Description text. + public static string BotChatConfiguration_PartyVolunteerLines_Description => ResourceManager.GetString("BotChatConfiguration_PartyVolunteerLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyAskWhereLines_Name text. + public static string BotChatConfiguration_PartyAskWhereLines_Name => ResourceManager.GetString("BotChatConfiguration_PartyAskWhereLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyAskWhereLines_Description text. + public static string BotChatConfiguration_PartyAskWhereLines_Description => ResourceManager.GetString("BotChatConfiguration_PartyAskWhereLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyOnTheWayLines_Name text. + public static string BotChatConfiguration_PartyOnTheWayLines_Name => ResourceManager.GetString("BotChatConfiguration_PartyOnTheWayLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyOnTheWayLines_Description text. + public static string BotChatConfiguration_PartyOnTheWayLines_Description => ResourceManager.GetString("BotChatConfiguration_PartyOnTheWayLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyCantGoLines_Name text. + public static string BotChatConfiguration_PartyCantGoLines_Name => ResourceManager.GetString("BotChatConfiguration_PartyCantGoLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyCantGoLines_Description text. + public static string BotChatConfiguration_PartyCantGoLines_Description => ResourceManager.GetString("BotChatConfiguration_PartyCantGoLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyClassAliases_Name text. + public static string BotChatConfiguration_PartyClassAliases_Name => ResourceManager.GetString("BotChatConfiguration_PartyClassAliases_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyClassAliases_Description text. + public static string BotChatConfiguration_PartyClassAliases_Description => ResourceManager.GetString("BotChatConfiguration_PartyClassAliases_Description", resourceCulture)!; + + /// Gets the localized BotClassAlias_Keywords_Name text. + public static string BotClassAlias_Keywords_Name => ResourceManager.GetString("BotClassAlias_Keywords_Name", resourceCulture)!; + + /// Gets the localized BotClassAlias_Keywords_Description text. + public static string BotClassAlias_Keywords_Description => ResourceManager.GetString("BotClassAlias_Keywords_Description", resourceCulture)!; + + /// Gets the localized BotClassAlias_ClassNumber_Name text. + public static string BotClassAlias_ClassNumber_Name => ResourceManager.GetString("BotClassAlias_ClassNumber_Name", resourceCulture)!; + + /// Gets the localized BotClassAlias_ClassNumber_Description text. + public static string BotClassAlias_ClassNumber_Description => ResourceManager.GetString("BotClassAlias_ClassNumber_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 3bd4f8e737..f11ba1a662 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1953,4 +1953,28 @@ Said instead of the "Killed by a player" lines when the same player killed the bot again within a short time. Placeholders: {player}, {level}, {map}, {location}. Small talk Said now and then in the normal chat by a bot standing in a safezone which a player sees. Placeholders: {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). + Party search: answering bots + How many bots whisper to a player who searches party members with /post, e.g. "/post party 300-350 bk dw". The same search posted again is answered by other bots. 0 = off. + Party search: keywords + Comma-separated words which mark a /post as a party search. The post also needs a level: "300-350", "300+", "min 300" or "300". + Party search: "here" words + Comma-separated words with which the player tells an answering bot to come to where the player stands, instead of naming a map and coordinates. + Party search: answer timeout + How long a bot which answered a party search waits for the player to whisper back where to meet, e.g. "devias 215 40". + Party search: waiting time + How long a bot waits at the meeting place for the party invitation before it goes back to hunting. + Party search: answers + Whispered to a player whose party search the bot answers. Placeholders: {player}, {level}, {class}, {map}, {location}. + Party search: where? + Whispered when the bot didn't understand where to meet. Placeholders: {player}, {level}, {class}. + Party search: on the way + Whispered when the bot sets off to the meeting place. Placeholders: {player}, {map} and {location} (the meeting place). + Party search: can't go + Whispered when the bot may not go to the named map: its level is too low, or the map can't be reached by warp. Placeholders: {player}, {map} (the named map), {level}. + Party search: class words + The words players use for the character classes in a party search, e.g. "dk, bk, bm". The class number stands for its whole line of classes. + Words + Comma-separated words for the class line, e.g. "dk, bk, bm, knight". Whole words, case and punctuation are ignored. + Class number + The number of a character class of the line, e.g. 4 for the Dark Knight - the whole line (Dark Knight, Blade Knight, Blade Master) is meant. diff --git a/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs new file mode 100644 index 0000000000..ac8351eda1 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs @@ -0,0 +1,151 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests the party search of a player which the bots answer (see ): +/// reading the post, reading the meeting place from the player's whisper, and choosing the bots +/// which answer. +/// +[TestFixture] +public class BotPartySearchTest +{ + private const string Keywords = "party, pt, grupa"; + + private static readonly DateTime Start = new(2026, 11, 4, 20, 0, 0, DateTimeKind.Utc); + + private static readonly BotClassAlias[] Aliases = + [ + new() { Keywords = "dk, bk, bm", ClassNumber = 4 }, + new() { Keywords = "dw, sm, gm", ClassNumber = 0 }, + new() { Keywords = "elf", ClassNumber = 8 }, + ]; + + /// + /// A post with a party keyword and a level is a party search: a range, a minimum written as + /// "300+", "min 300" or a single number, and optionally the wanted classes. + /// + /// The posted message. + /// The expected minimum level. + /// The expected maximum level. + /// The expected class numbers, comma-separated. + [TestCase("party 300-350 bk dw", 300, 350, "4,0")] + [TestCase("PT 350 - 300", 300, 350, "")] + [TestCase("party 300+ elf", 300, int.MaxValue, "8")] + [TestCase("grupa min 250", 250, int.MaxValue, "")] + [TestCase("lf party lvl 380, need BM", 380, int.MaxValue, "4")] + public void ReadsAPartySearch(string message, int minimum, int maximum, string classes) + { + var request = BotPartyRequest.TryParse(message, Keywords, Aliases); + + Assert.That(request, Is.Not.Null); + Assert.That(request!.MinimumLevel, Is.EqualTo(minimum)); + Assert.That(request.MaximumLevel, Is.EqualTo(maximum)); + Assert.That(string.Join(",", request.ClassNumbers), Is.EqualTo(classes)); + } + + /// + /// Without a party keyword, or without a level, a post calls no bot - "thx for the party" is no + /// search. + /// + /// The posted message. + [TestCase("thx for the party guys")] + [TestCase("selling wings 300kk")] + public void IgnoresOtherPosts(string message) + { + Assert.That(BotPartyRequest.TryParse(message, Keywords, Aliases), Is.Null); + } + + /// + /// The meeting place is the map named in the whisper - in any of its translations, the longest + /// name winning - and the first two numbers after the name as the coordinates. + /// + /// The whisper. + /// The expected map number. + /// The expected x coordinate, or -1 for none. + /// The expected y coordinate, or -1 for none. + [TestCase("devias 215 40", 2, 215, 40)] + [TestCase("Devias, 215,40 pls", 2, 215, 40)] + [TestCase("come to lost tower", 4, -1, -1)] + [TestCase("kalima 1 at 20 30", 24, 20, 30)] + [TestCase("chodź: Wieża 100 120", 4, 100, 120)] + public void ReadsTheMeetingPlace(string message, int expectedMap, int expectedX, int expectedY) + { + var place = BotChatComposer.FindPlace(message, CreateMaps()); + + Assert.That(place, Is.Not.Null); + Assert.That(place!.Value.Map.Number, Is.EqualTo(expectedMap)); + Point? expectedPosition = expectedX < 0 ? null : new Point((byte)expectedX, (byte)expectedY); + Assert.That(place.Value.Position, Is.EqualTo(expectedPosition)); + } + + /// + /// A whisper without a known map names no meeting place. + /// + [Test] + public void NoMapNoPlace() + { + Assert.That(BotChatComposer.FindPlace("somewhere 10 10", CreateMaps()), Is.Null); + } + + /// + /// A class line is followed back to its first class, so a Blade Master counts as a Dark Knight. + /// + [Test] + public void FindsTheFirstClassOfALine() + { + var bladeMaster = new CharacterClass { Number = 7 }; + var bladeKnight = new CharacterClass { Number = 6, NextGenerationClass = bladeMaster }; + var darkKnight = new CharacterClass { Number = 4, NextGenerationClass = bladeKnight }; + var darkWizard = new CharacterClass { Number = 0 }; + var classes = new List { darkWizard, bladeMaster, bladeKnight, darkKnight }; + + Assert.That(BotChatPlugIn.GetFirstClassOfLine(bladeMaster, classes), Is.SameAs(darkKnight)); + Assert.That(BotChatPlugIn.GetFirstClassOfLine(darkWizard, classes), Is.SameAs(darkWizard)); + } + + /// + /// A search is answered by up to the wanted number of bots; the same search posted again is + /// answered by the others. + /// + /// The task. + [Test] + public async ValueTask AnotherSearchCallsOtherBotsAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var requester = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var bots = new List(); + for (var i = 0; i < 7; i++) + { + bots.Add(await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false)); + } + + var throttle = new BotChatThrottle(); + + var first = throttle.TakeNewVolunteers(requester, bots, 5, Start, _ => 0); + var second = throttle.TakeNewVolunteers(requester, bots, 5, Start.AddMinutes(1), _ => 0); + var afterAWhile = throttle.TakeNewVolunteers(requester, bots, 5, Start.AddHours(1), _ => 0); + + Assert.That(first, Has.Count.EqualTo(5)); + Assert.That(second, Has.Count.EqualTo(2)); + Assert.That(second.Intersect(first), Is.Empty); + Assert.That(afterAWhile, Has.Count.EqualTo(5)); + } + + private static List CreateMaps() => + [ + new() { Number = 0, Name = "Lorencia" }, + new() { Number = 2, Name = "Devias" }, + new() { Number = 4, Name = new LocalizedString("Lost Tower").WithTranslation(CultureInfo.GetCultureInfo("pl"), "Wieża") }, + new() { Number = 5, Name = "Tower" }, + new() { Number = 24, Name = "Kalima 1" }, + ]; +} From bbe343c37dae807ed59016b21cdd971cc008ca2b Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:10:19 +0100 Subject: [PATCH 13/44] feat(bots): let two bots talk to each other near a player Up to now a bot only said a line on its own. Now two bots standing close together talk like the townsfolk of a role-playing game: one says the opener ("my potions are running low"), the other answers a few seconds later ("the shop here sells them cheap"). They only talk when a human player stands near them - nobody else would hear it - and each player overhears about one conversation every five minutes; the interval counts per player, so a quiet corner doesn't wait for a busy town. Each conversation has a place: in a town (both bots in a safezone), out hunting (both outside of it, usually fighting) or anywhere. Conversations are drawn without repetition until all of a place were used. The default configuration comes with seventy-three conversations, each with a Polish translation; they live in a file of their own. --- docs-website/docs/server-features/bots.md | 13 +- src/GameLogic/Bots/BotChatConfiguration.cs | 27 +++ src/GameLogic/Bots/BotChatPlugIn.cs | 174 +++++++++++++++--- src/GameLogic/Bots/BotChatThrottle.cs | 83 +++++++++ src/GameLogic/Bots/BotConversation.cs | 40 ++++ src/GameLogic/Bots/BotConversationDefaults.cs | 125 +++++++++++++ src/GameLogic/Bots/BotConversationPlace.cs | 27 +++ .../Properties/PlugInResources.Designer.cs | 36 ++++ src/GameLogic/Properties/PlugInResources.resx | 12 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 52 ++++++ 10 files changed, 559 insertions(+), 30 deletions(-) create mode 100644 src/GameLogic/Bots/BotConversation.cs create mode 100644 src/GameLogic/Bots/BotConversationDefaults.cs create mode 100644 src/GameLogic/Bots/BotConversationPlace.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 8522edd431..7fe4f5417e 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -349,6 +349,7 @@ only talks about what it could actually do: | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | | A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | | Now and then (every 10 minutes per game server, 50 % chance) | normal chat | a bot standing in a safezone which a player sees | +| A player stands near two bots (every 5 minutes per player) | normal chat: one bot says something, the other answers | two bots close together, both in a town or both out hunting | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody @@ -376,6 +377,15 @@ there) it declines; a whisper without a place gets asked again. Map names are recognized in every translation of the map. The words, the number of bots and the times are part of the configuration. +The conversations make a town feel lived in, like the townsfolk of a +role-playing game: "my potions are running low" — "the shop here sells them +cheap". Only when a human player stands near two bots which stand close +together do they talk — nobody else would hear it. Each conversation has a +place: `Town` (both bots in a safezone), `Hunting` (both outside of it, usually +fighting) or `Anywhere`. They are drawn without repetition until all of a place +were used, and each player hears about one every five minutes. The default +configuration comes with some seventy of them. + A whisper reply can carry `Keywords`, a comma-separated list of words or phrases it answers (`how are you, how r u`). The bot picks among the replies whose keyword matches the whisper — whole words, ignoring case and punctuation, @@ -385,7 +395,8 @@ The default lines cover greetings, "how are you", the level, where the bot is, party, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, -`{level}`, `{class}`, `{player}` (whisper replies), and `{location}` — the map with the +`{level}`, `{class}`, `{player}` (whisper replies), `{partner}` (the other bot of +a conversation), and `{location}` — the map with the bot's coordinates the way players write them, e.g. "Devias 215, 40", so a player can find it with the mini map. The default "where are you?" replies use it. In an event post, `{location}` is the meeting point; in an invasion post, just the diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 034f9b5ded..8db6fc28eb 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -163,6 +163,21 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Description))] public int SmallTalkChancePercent { get; set; } = 50; + /// + /// Gets or sets how often a human player may overhear a conversation of two bots near them (see + /// ). The time counts per player: each player who stands next to two + /// bots hears them talk about once per interval. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ConversationInterval_Name), Description = nameof(PlugInResources.BotChatConfiguration_ConversationInterval_Description))] + public TimeSpan ConversationInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the chance, in percent, that two bots near a player talk when the interval is over. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ConversationChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_ConversationChancePercent_Description))] + public int ConversationChancePercent { get; set; } = 100; + /// /// Gets or sets the lines a bot posts with /post when the entrance of a Blood Castle, Devil Square /// or Chaos Castle opened, before it waits for players at the meeting point (see ). @@ -302,6 +317,17 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Description))] public ICollection SmallTalkLines { get; set; } = new List(); + /// + /// Gets or sets the conversations two bots near a player have now and then: one bot says the + /// opener, the other one answers. They are drawn without repetition until all of a place were + /// used. Placeholders: {map}, {location}, {level}, {class} of the + /// speaking bot, and {partner} - the name of the other bot. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_Conversations_Name), Description = nameof(PlugInResources.BotChatConfiguration_Conversations_Description))] + public ICollection Conversations { get; set; } = new List(); + /// /// Creates the configuration a server starts with: a set of English lines, each with a Polish /// translation. @@ -397,6 +423,7 @@ public static BotChatConfiguration CreateDefault() configuration.PartyCantGoLines, ("cant go to {map}, sry", "nie mogę iść do {map}, sry"), ("{map} is too much for me", "{map} to za dużo dla mnie")); + BotConversationDefaults.AddTo(configuration.Conversations); AddClass("dk, bk, bm, knight, dark knight, blade knight, blade master, rycerz", BotClassNumbers.DarkKnightNumber); AddClass("dw, sm, gm, wizard, dark wizard, soul master, grand master, mag", BotClassNumbers.DarkWizardNumber); AddClass("elf, fe, muse, fairy elf, muse elf, high elf, elfka", BotClassNumbers.FairyElfNumber); diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 2fd629b948..1f8f3d2313 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -55,10 +55,17 @@ public class BotChatPlugIn IDisabledByDefault { /// - /// How close a bot must stand to greet an entering player - roughly the range in which the - /// player sees it, so the greeting comes from a character on the screen. + /// How close a bot must stand to a player to talk to them - to greet an entering player, or to + /// let them overhear a conversation: roughly the range in which the player sees it, so the line + /// comes from a character on the screen. /// - private const double GreetingRange = 12; + private const double HearingRange = 12; + + /// + /// How close two bots must stand to each other to have a conversation - close enough to look like + /// they are talking to each other. + /// + private const double ConversationPairRange = 5; /// /// How long an invasion counts as announced. Long enough that a second invasion of the same kind @@ -99,6 +106,11 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) KilledDelaySeconds = (1, 2); + /// + /// The delay range of the answer in a conversation of two bots: the time to read and type. + /// + private static readonly (int Min, int Max) ConversationReplyDelaySeconds = (3, 6); + /// /// The gap between the whispers of two bots answering the same party search, so the answers /// trickle in like those of players who read the post at different moments. @@ -363,35 +375,13 @@ await sender.ForEachObservingAsync( /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { - if (this.Configuration is not { SmallTalkChancePercent: > 0 } configuration - || !this.GetThrottle(gameContext).TryStartSmallTalk(DateTime.UtcNow, configuration.SmallTalkInterval, Volatile.Read(ref this._forceStartCount)) - || !Rand.NextRandomBool(configuration.SmallTalkChancePercent)) - { - return; - } - - var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); - var candidates = new List(); - foreach (var bot in players.Where(p => IsBot(p) && IsAvailable(p) && IsInSafezone(p))) - { - // Talking to nobody would be wasted: only a bot which a player sees may talk. - if (await HasHumanObserverAsync(bot).ConfigureAwait(false)) - { - candidates.Add(bot); - } - } - - if (candidates.Count == 0 - || PickLine(configuration.SmallTalkLines) is not { } line) + if (this.Configuration is not { } configuration) { return; } - var speaker = candidates[Rand.NextInt(0, candidates.Count)]; - if (this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) - { - await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker)).ConfigureAwait(false); - } + await this.MakeSmallTalkAsync(gameContext, configuration).ConfigureAwait(false); + await this.HoldConversationsAsync(gameContext, configuration).ConfigureAwait(false); } /// @@ -480,7 +470,7 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) => players .Where(p => IsBot(p) && IsAvailable(p) && ReferenceEquals(p.CurrentMap, map)) .Select(p => (Bot: p, Distance: p.GetDistanceTo(player))) - .Where(candidate => candidate.Distance <= GreetingRange) + .Where(candidate => candidate.Distance <= HearingRange) .OrderBy(candidate => candidate.Distance) .Select(candidate => candidate.Bot) .FirstOrDefault(); @@ -553,6 +543,37 @@ private static bool IsAvailable(Player bot) private static bool IsInSafezone(Player bot) => bot.CurrentMap is { } map && map.Terrain.SafezoneMap[bot.Position.X, bot.Position.Y]; + private static bool IsInHearingRange(Player speaker, Player listener) + => ReferenceEquals(speaker.CurrentMap, listener.CurrentMap) && speaker.GetDistanceTo(listener) <= HearingRange; + + /// + /// Finds two bots for a conversation among the bots near a player: a random pair standing close + /// together, both in a safezone (a town) or both outside of it (out hunting). + /// + private static (Player First, Player Second, BotConversationPlace Place)? FindConversationPair(IReadOnlyList bots) + { + var pairs = new List<(Player First, Player Second, BotConversationPlace Place)>(); + for (var i = 0; i < bots.Count; i++) + { + for (var j = i + 1; j < bots.Count; j++) + { + var (a, b) = (bots[i], bots[j]); + var inTown = IsInSafezone(a); + if (ReferenceEquals(a.CurrentMap, b.CurrentMap) + && a.GetDistanceTo(b) <= ConversationPairRange + && inTown == IsInSafezone(b)) + { + var place = inTown ? BotConversationPlace.Town : BotConversationPlace.Hunting; + + // Random order, so it's not always the same bot which starts. + pairs.Add(Rand.NextRandomBool() ? (a, b, place) : (b, a, place)); + } + } + } + + return pairs.Count == 0 ? null : pairs[Rand.NextInt(0, pairs.Count)]; + } + private static async ValueTask HasHumanObserverAsync(Player bot) { var seen = false; @@ -696,6 +717,101 @@ private void WhisperLater(Player bot, Player receiver, BotChatLine line, Diction ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values) : ValueTask.CompletedTask); + private async ValueTask MakeSmallTalkAsync(GameContext gameContext, BotChatConfiguration configuration) + { + if (configuration.SmallTalkChancePercent <= 0 + || !this.GetThrottle(gameContext).TryStartSmallTalk(DateTime.UtcNow, configuration.SmallTalkInterval, Volatile.Read(ref this._forceStartCount)) + || !Rand.NextRandomBool(configuration.SmallTalkChancePercent)) + { + return; + } + + var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); + var candidates = new List(); + foreach (var bot in players.Where(p => IsBot(p) && IsAvailable(p) && IsInSafezone(p))) + { + // Talking to nobody would be wasted: only a bot which a player sees may talk. + if (await HasHumanObserverAsync(bot).ConfigureAwait(false)) + { + candidates.Add(bot); + } + } + + if (candidates.Count == 0 + || PickLine(configuration.SmallTalkLines) is not { } line) + { + return; + } + + var speaker = candidates[Rand.NextInt(0, candidates.Count)]; + if (this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) + { + await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker)).ConfigureAwait(false); + } + } + + /// + /// Lets two bots near a human player talk, for every player whose interval is over: the pair + /// stands close together - both in a town or both out hunting - and the conversation fits the + /// place. Every player who hears it waits a whole interval for the next one. + /// + private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatConfiguration configuration) + { + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(gameContext); + if (configuration.ConversationChancePercent <= 0 + || configuration.Conversations.Count == 0 + || !throttle.TryStartConversationScan(now)) + { + return; + } + + var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); + var humans = players.Where(p => p is not OfflinePlayer && p.PlayerState.CurrentState == PlayerState.EnteredWorld && p.CurrentMap is not null).ToList(); + var bots = players.Where(p => IsBot(p) && IsAvailable(p)).ToList(); + foreach (var human in humans) + { + if (!throttle.IsConversationDue(human, now, configuration.ConversationInterval) + || FindConversationPair(bots.Where(bot => IsInHearingRange(bot, human)).ToList()) is not { } pair) + { + continue; + } + + // Whoever hears this one waits a whole interval - also when the dice said no. + foreach (var listener in humans.Where(listener => IsInHearingRange(pair.First, listener) || IsInHearingRange(pair.Second, listener))) + { + throttle.PostponeConversation(listener, now + configuration.ConversationInterval); + } + + var candidates = configuration.Conversations + .Where(c => (c.Where == BotConversationPlace.Anywhere || c.Where == pair.Place) + && !string.IsNullOrWhiteSpace(c.Opener.ValueInNeutralLanguage) + && !string.IsNullOrWhiteSpace(c.Reply.ValueInNeutralLanguage)) + .ToList(); + if (!Rand.NextRandomBool(configuration.ConversationChancePercent) + || !throttle.TryReserveChat(pair.First, now, configuration.BotChatCooldown) + || !throttle.TryReserveChat(pair.Second, now, configuration.BotChatCooldown) + || throttle.DrawConversation(candidates, count => Rand.NextInt(0, count)) is not { } conversation) + { + continue; + } + + var (first, second) = (pair.First, pair.Second); + var firstValues = CreateBotValues(first); + var secondName = second.SelectedCharacter?.Name ?? string.Empty; + firstValues["partner"] = _ => secondName; + var secondValues = CreateBotValues(second); + var firstName = first.SelectedCharacter?.Name ?? string.Empty; + secondValues["partner"] = _ => firstName; + + await BotChatSpeaker.SayAsync(first, conversation.Opener, firstValues).ConfigureAwait(false); + RunLater( + gameContext, + RandomDelay(ConversationReplyDelaySeconds), + () => IsAvailable(second) ? BotChatSpeaker.SayAsync(second, conversation.Reply, secondValues) : ValueTask.CompletedTask); + } + } + private async ValueTask GreetAsync(Player player, BotChatConfiguration configuration) { if (player.PlayerState.CurrentState != PlayerState.EnteredWorld diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index e5b6d3190b..51be31a95b 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -31,6 +31,12 @@ internal sealed class BotChatThrottle /// private static readonly TimeSpan PartySearchMemory = TimeSpan.FromMinutes(30); + /// + /// How often the bots look for players who are due to overhear a conversation. A second-by-second + /// look over all players would cost for nothing; a few seconds late doesn't matter. + /// + private static readonly TimeSpan ConversationScanInterval = TimeSpan.FromSeconds(5); + private readonly ConcurrentDictionary _occasions = new(); private readonly ConditionalWeakTable _bots = new(); @@ -41,6 +47,14 @@ internal sealed class BotChatThrottle private readonly ConditionalWeakTable _partySearches = new(); + private readonly ConditionalWeakTable> _nextConversations = new(); + + private readonly HashSet _usedConversations = []; + + private readonly Lock _conversationLock = new(); + + private long _nextConversationScanTicks; + private long _nextPostTicks; private DateTime _nextSmallTalkUtc; @@ -192,6 +206,75 @@ public IReadOnlyList TakeNewVolunteers(Player requester, IEnumerableThe bot. public void RemovePartyOffer(Player bot) => this._partyOffers.Remove(bot); + /// + /// Determines whether it's time for the next look for players who are due to overhear a + /// conversation. + /// + /// The current time. + /// true, if the caller should look now. + public bool TryStartConversationScan(DateTime now) + => TryReserve(ref this._nextConversationScanTicks, now, ConversationScanInterval); + + /// + /// Determines whether the player is due to overhear a conversation. A player seen for the first + /// time waits one interval first - the player just entered the game. + /// + /// The human player. + /// The current time. + /// The configured interval between two conversations a player hears. + /// true, if the player is due. + public bool IsConversationDue(Player player, DateTime now, TimeSpan interval) + { + var next = this._nextConversations.GetValue(player, _ => new StrongBox(now + interval)); + lock (next) + { + return now >= next.Value; + } + } + + /// + /// Postpones the next conversation the player may overhear. + /// + /// The human player. + /// The time of the player's next conversation. + public void PostponeConversation(Player player, DateTime until) + { + var next = this._nextConversations.GetValue(player, _ => new StrongBox(until)); + lock (next) + { + next.Value = until; + } + } + + /// + /// Draws a conversation from the candidates without repetition: a conversation comes again only + /// after all candidates were used. + /// + /// The conversations which fit the place. + /// Returns a random index below the given count. + /// The drawn conversation, or null without candidates. + public BotConversation? DrawConversation(IReadOnlyCollection candidates, Func pickIndex) + { + if (candidates.Count == 0) + { + return null; + } + + lock (this._conversationLock) + { + var unused = candidates.Where(c => !this._usedConversations.Contains(c)).ToList(); + if (unused.Count == 0) + { + this._usedConversations.ExceptWith(candidates); + unused = candidates.ToList(); + } + + var drawn = unused[pickIndex(unused.Count)]; + this._usedConversations.Add(drawn); + return drawn; + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/src/GameLogic/Bots/BotConversation.cs b/src/GameLogic/Bots/BotConversation.cs new file mode 100644 index 0000000000..1ab87cdb6e --- /dev/null +++ b/src/GameLogic/Bots/BotConversation.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Interfaces; + +/// +/// A short exchange of two server-side bots, like the chatter of the townsfolk in a role-playing +/// game: one bot says the , the other one answers with the . +/// +/// +/// Both texts are s; every listening player sees the translation of +/// their own language. Placeholders: {map}, {location}, {level} and +/// {class} of the speaking bot, and {partner} - the name of the other bot. +/// +public class BotConversation +{ + /// + /// Gets or sets what the first bot says. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Opener_Name), Description = nameof(PlugInResources.BotConversation_Opener_Description))] + public LocalizedString Opener { get; set; } + + /// + /// Gets or sets what the second bot answers. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Reply_Name), Description = nameof(PlugInResources.BotConversation_Reply_Description))] + public LocalizedString Reply { get; set; } + + /// + /// Gets or sets where the two bots may have this conversation. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Where_Name), Description = nameof(PlugInResources.BotConversation_Where_Description))] + public BotConversationPlace Where { get; set; } + + /// + public override string ToString() => $"{this.Opener.ValueInNeutralLanguage} - {this.Reply.ValueInNeutralLanguage}"; +} diff --git a/src/GameLogic/Bots/BotConversationDefaults.cs b/src/GameLogic/Bots/BotConversationDefaults.cs new file mode 100644 index 0000000000..67193201cf --- /dev/null +++ b/src/GameLogic/Bots/BotConversationDefaults.cs @@ -0,0 +1,125 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +using MUnique.OpenMU.Interfaces; + +/// +/// The conversations a server starts with (see ): +/// English, each with a Polish translation. Kept apart from the rest of the default configuration, +/// since they are many. +/// +/// +/// Like the other default lines, they are configuration data a server owner is expected to rewrite +/// and extend. Map, item and monster names stay English in the Polish lines - the game data names +/// them that way. +/// +internal static class BotConversationDefaults +{ + /// + /// Adds the default conversations. + /// + /// The list of the configuration. + internal static void AddTo(ICollection conversations) + { + Add( + conversations, + BotConversationPlace.Town, + ("great weather for exp today", "indeed, not a cloud over {map}", "świetna pogoda na expa dzisiaj", "fakt, ani chmurki nad {map}"), + ("did you hear? a red dragon was seen near devias", "then i'm staying in town today", "słyszałeś? podobno widzieli czerwonego smoka koło devias", "to ja dziś zostaję w mieście"), + ("my potions are running low", "the shop here sells them cheap", "kończą mi się potki", "w sklepie tutaj są tanie"), + ("anyone know a good spot for exp?", "lost tower, if you can take the hits", "zna ktoś dobre miejsce na expa?", "lost tower, jak wytrzymasz bicie"), + ("i heard the blacksmith raised his prices", "everyone raises prices, except the monsters", "kowal podobno podniósł ceny", "wszyscy podnoszą ceny, tylko potwory nie"), + ("what a crowd today", "everyone wants that jewel of bless", "ale tłok dzisiaj", "każdy chce ten jewel of bless"), + ("how much is a jewel of soul now?", "too much, as always", "ile teraz chodzi jewel of soul?", "za dużo, jak zawsze"), + ("i failed +7 again...", "the chaos machine hates us all", "znowu spaliłem +7...", "chaos machine nas wszystkich nienawidzi"), + ("ever tried blood castle solo?", "once. never again", "próbowałeś kiedyś bc solo?", "raz. nigdy więcej"), + ("the guards here look bored", "better bored than busy", "strażnicy tu wyglądają na znudzonych", "lepiej znudzeni niż zajęci"), + ("have you seen my wings? oh wait, i don't have any", "one day, friend, one day", "widziałeś moje skrzydła? a nie, nie mam", "kiedyś, stary, kiedyś"), + ("need a break from killing spiders", "spiders never take a break", "muszę odpocząć od pająków", "pająki nie robią przerw"), + ("i dropped a jewel of chaos yesterday", "lucky you, i got only apples", "wczoraj wypadł mi jewel of chaos", "szczęściarz, mi tylko jabłka"), + ("who sells the best armor in town?", "nobody, you have to find it out there", "kto sprzedaje najlepszą zbroję w mieście?", "nikt, trzeba ją znaleźć za murami"), + ("is devil square open soon?", "check the clock, every few hours", "niedługo devil square?", "patrz na zegar, co kilka godzin"), + ("i miss the old days in lorencia", "the budge dragons miss you too", "tęsknię za starymi czasami w lorencii", "budge dragony też za tobą tęsknią"), + ("my sword got dull again", "the repair guy will love you", "znowu stępił mi się miecz", "naprawiacz cię pokocha"), + ("anyone seen the golden goblins?", "they run faster than my wallet empties", "ktoś widział złote gobliny?", "uciekają szybciej niż pustoszeje mi sakiewka"), + ("time for a quick rest", "rest is for the weak. and for me", "czas na krótki odpoczynek", "odpoczynek jest dla słabych. i dla mnie"), + ("i heard kundun is waking up", "let him sleep, please", "słyszałem, że kundun się budzi", "niech śpi, błagam"), + ("the elves are singing again", "noria always sounds like a song", "elfy znów śpiewają", "noria zawsze brzmi jak piosenka"), + ("lag?", "nope, just you", "lag?", "nie, tylko u ciebie"), + ("i should finally reset", "and lose all those levels? brave", "w końcu powinienem zrobić reset", "i stracić te wszystkie levele? odważnie"), + ("where do you sell your junk?", "the merchant takes anything", "gdzie sprzedajesz śmieci?", "kupiec bierze wszystko"), + ("nice armor, where did you get it?", "found it on a skeleton. he didn't need it anymore", "fajna zbroja, skąd masz?", "znalazłem przy szkielecie. już mu się nie przyda"), + ("did the gm say anything about an event?", "not yet, keep an eye on the chat", "gm mówił coś o evencie?", "jeszcze nie, patrz na czat"), + ("ugh, i died twice in atlans", "the bahamuts don't forgive", "eh, dwa razy padłem w atlans", "bahamuty nie wybaczają"), + ("how long have you been playing today?", "too long to admit", "długo dziś grasz?", "za długo, żeby się przyznać"), + ("{map} is lively today", "the market always is", "{map} dziś tętni życiem", "rynek zawsze tętni"), + ("i bought too many potions again", "never too many, trust me", "znowu kupiłem za dużo potek", "nigdy za dużo, uwierz mi"), + ("the sky looks strange today", "maybe a dragon is coming", "niebo dziś dziwnie wygląda", "może leci smok"), + ("{partner}, still selling that old shield?", "it's a classic, not old", "{partner}, dalej sprzedajesz tę starą tarczę?", "to klasyk, a nie stara")); + Add( + conversations, + BotConversationPlace.Hunting, + ("these monsters never end", "keep swinging, we're almost done", "te potwory się nie kończą", "bij dalej, zaraz koniec"), + ("watch out, behind you!", "got it, thanks", "uważaj, za tobą!", "mam go, dzięki"), + ("my mana is almost gone", "drink something, quick", "kończy mi się mana", "wypij coś, szybko"), + ("nice hit!", "lucky one, don't expect it again", "niezły cios!", "fart, nie licz na powtórkę"), + ("did something drop?", "just zen, as usual", "coś wypadło?", "tylko zen, jak zwykle"), + ("this spot is good for exp", "until someone takes it", "dobre miejsce na expa", "dopóki ktoś go nie zajmie"), + ("i need a break soon", "one more round, then we rest", "zaraz muszę odpocząć", "jeszcze jedna runda i przerwa"), + ("my armor won't last long", "then don't get hit", "zbroja długo nie pociągnie", "to nie daj się trafić"), + ("they hit harder than i thought", "that's why we go together", "biją mocniej, niż myślałem", "dlatego chodzimy razem"), + ("look, a jewel!", "wait, it's just an apple", "patrz, jewel!", "czekaj, to tylko jabłko"), + ("almost leveled up", "nice, keep going", "prawie level", "nieźle, dawaj dalej"), + ("is it me or are there more of them?", "it's not you, they keep coming", "to ja czy jest ich coraz więcej?", "to nie ty, ciągle przychodzą"), + ("pull them to me", "careful what you wish for", "ściągaj je do mnie", "uważaj, czego sobie życzysz"), + ("how much exp do you need?", "too much, as always", "ile ci brakuje expa?", "za dużo, jak zawsze"), + ("i hate the ones with poison", "everyone does", "nienawidzę tych z trucizną", "wszyscy nienawidzą"), + ("stay close, {partner}", "right behind you", "trzymaj się blisko, {partner}", "jestem tuż za tobą"), + ("this map is full of surprises", "mostly the bad kind", "ta mapa jest pełna niespodzianek", "głównie tych złych"), + ("my hands are tired from clicking", "the monsters don't care", "ręce mnie bolą od klikania", "potwory to nie obchodzi"), + ("we should try a harder map", "after the next level", "powinniśmy spróbować trudniejszej mapy", "po następnym levelu"), + ("did you see that crit?", "i did, now do it again", "widziałeś tego kryta?", "widziałem, teraz zrób to jeszcze raz"), + ("one more and i go repair", "you said that ten monsters ago", "jeszcze jeden i idę naprawiać", "mówiłeś to dziesięć potworów temu"), + ("not much drop here", "but good exp", "mały drop tutaj", "ale exp dobry"), + ("the boss should spawn soon", "then we wait", "boss powinien się zaraz pojawić", "to czekamy"), + ("my potions are gone", "take a few of mine... joking", "skończyły mi się potki", "weź kilka moich... żartuję"), + ("{map} at this hour, nice", "less people, more monsters", "{map} o tej porze, fajnie", "mniej ludzi, więcej potworów")); + Add( + conversations, + BotConversationPlace.Anywhere, + ("how is your day going?", "better now, thanks", "jak ci mija dzień?", "teraz lepiej, dzięki"), + ("have you been to kalima?", "once, i still have nightmares", "byłeś w kalimie?", "raz, do dziś mam koszmary"), + ("do you know a good guild?", "a few, ask around in town", "znasz jakąś dobrą gildię?", "kilka, popytaj w mieście"), + ("i wonder what's behind the next map", "more monsters, probably", "ciekawe, co jest za następną mapą", "pewnie więcej potworów"), + ("anyone up for blood castle later?", "sure, if we find a third", "ktoś na bc później?", "jasne, jak znajdziemy trzeciego"), + ("looking for a party, you?", "always", "szukam grupy, a ty?", "zawsze"), + ("remember when we were level 1?", "the spiders were scary back then", "pamiętasz, jak mieliśmy level 1?", "pająki były wtedy straszne"), + ("the server runs smooth today", "no lag, no complaints", "serwer dziś dobrze chodzi", "zero lagów, zero narzekań"), + ("what's your favorite map?", "the one where i don't die", "jaka twoja ulubiona mapa?", "ta, na której nie ginę"), + ("do you think the dragons are real?", "ask the ones who met them", "myślisz, że smoki istnieją?", "zapytaj tych, którzy je spotkali"), + ("gm online?", "dunno, didn't see any", "jest gm?", "nie wiem, nie widziałem"), + ("so many people today", "the more the merrier", "dużo ludzi dzisiaj", "im więcej, tym weselej"), + ("i should sleep", "one more level first", "powinienem iść spać", "najpierw jeszcze jeden level"), + ("have you seen a dark lord around?", "he was looking for his horse", "widziałeś gdzieś dark lorda?", "szukał swojego konia"), + ("what's the best class?", "the one you enjoy", "jaka klasa jest najlepsza?", "ta, którą lubisz"), + ("need a party for exp", "same here, let's wait for someone", "potrzebna grupa na expa", "też, poczekajmy na kogoś")); + } + + private static void Add(ICollection conversations, BotConversationPlace place, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) + { + var polish = CultureInfo.GetCultureInfo("pl"); + foreach (var text in texts) + { + conversations.Add(new BotConversation + { + Opener = new LocalizedString(text.Opener).WithTranslation(polish, text.PolishOpener), + Reply = new LocalizedString(text.Reply).WithTranslation(polish, text.PolishReply), + Where = place, + }); + } + } +} diff --git a/src/GameLogic/Bots/BotConversationPlace.cs b/src/GameLogic/Bots/BotConversationPlace.cs new file mode 100644 index 0000000000..06cec82b76 --- /dev/null +++ b/src/GameLogic/Bots/BotConversationPlace.cs @@ -0,0 +1,27 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// Where two bots may have a . +/// +/// New values are appended: the configuration stores the place as a number. +public enum BotConversationPlace +{ + /// + /// In a town as well as out hunting. + /// + Anywhere, + + /// + /// Only when both bots stand in a safezone - "the shop here sells potions cheap". + /// + Town, + + /// + /// Only when both bots are out of the safezone, usually fighting - "watch out, behind you!". + /// + Hunting, +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index ff1efaf248..52223b50b0 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5352,5 +5352,41 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotClassAlias_ClassNumber_Description text. public static string BotClassAlias_ClassNumber_Description => ResourceManager.GetString("BotClassAlias_ClassNumber_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_ConversationInterval_Name text. + public static string BotChatConfiguration_ConversationInterval_Name => ResourceManager.GetString("BotChatConfiguration_ConversationInterval_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ConversationInterval_Description text. + public static string BotChatConfiguration_ConversationInterval_Description => ResourceManager.GetString("BotChatConfiguration_ConversationInterval_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ConversationChancePercent_Name text. + public static string BotChatConfiguration_ConversationChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_ConversationChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ConversationChancePercent_Description text. + public static string BotChatConfiguration_ConversationChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_ConversationChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_Conversations_Name text. + public static string BotChatConfiguration_Conversations_Name => ResourceManager.GetString("BotChatConfiguration_Conversations_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_Conversations_Description text. + public static string BotChatConfiguration_Conversations_Description => ResourceManager.GetString("BotChatConfiguration_Conversations_Description", resourceCulture)!; + + /// Gets the localized BotConversation_Opener_Name text. + public static string BotConversation_Opener_Name => ResourceManager.GetString("BotConversation_Opener_Name", resourceCulture)!; + + /// Gets the localized BotConversation_Opener_Description text. + public static string BotConversation_Opener_Description => ResourceManager.GetString("BotConversation_Opener_Description", resourceCulture)!; + + /// Gets the localized BotConversation_Reply_Name text. + public static string BotConversation_Reply_Name => ResourceManager.GetString("BotConversation_Reply_Name", resourceCulture)!; + + /// Gets the localized BotConversation_Reply_Description text. + public static string BotConversation_Reply_Description => ResourceManager.GetString("BotConversation_Reply_Description", resourceCulture)!; + + /// Gets the localized BotConversation_Where_Name text. + public static string BotConversation_Where_Name => ResourceManager.GetString("BotConversation_Where_Name", resourceCulture)!; + + /// Gets the localized BotConversation_Where_Description text. + public static string BotConversation_Where_Description => ResourceManager.GetString("BotConversation_Where_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index f11ba1a662..17b1a2afd3 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1977,4 +1977,16 @@ Comma-separated words for the class line, e.g. "dk, bk, bm, knight". Whole words, case and punctuation are ignored. Class number The number of a character class of the line, e.g. 4 for the Dark Knight - the whole line (Dark Knight, Blade Knight, Blade Master) is meant. + Conversation interval per player + How often a player may overhear two bots near them talking. Counts per player: everyone who stands next to two bots hears them talk about once per interval. + Conversation chance (%) + Chance that two bots near a player talk when the interval is over. 0 = never. + Conversations + Exchanges of two bots near a player, like the townsfolk of a role-playing game: one says the opener, the other answers. Drawn without repetition. Placeholders: {map}, {location}, {level}, {class} of the speaking bot, {partner} (the other bot). + Opener + What the first bot says. + Reply + What the second bot answers, a few seconds later. + Where + Where the bots may have this conversation: Town (both in a safezone), Hunting (both outside of it) or Anywhere. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 1c1fe662f5..bd613aef9c 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -69,6 +69,58 @@ public void SmallTalkFollowsTheIntervalOrAForcedStart() Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(12), interval, 1), Is.False); } + /// + /// Conversations are drawn without repetition: each one comes again only after all of them were + /// used. + /// + [Test] + public void ConversationsDontRepeatUntilAllWereUsed() + { + var throttle = new BotChatThrottle(); + var conversations = Enumerable.Range(0, 5).Select(i => new BotConversation { Opener = $"opener {i}", Reply = "reply" }).ToList(); + + var firstRound = Enumerable.Range(0, 5).Select(_ => throttle.DrawConversation(conversations, _ => 0)).ToList(); + var nextRound = throttle.DrawConversation(conversations, _ => 0); + + Assert.That(firstRound, Is.EquivalentTo(conversations)); + Assert.That(nextRound, Is.Not.Null); + } + + /// + /// A player overhears a conversation once per interval: the first one only after an interval in + /// the game, the next one an interval after the last. + /// + /// The task. + [Test] + public async ValueTask ConversationsFollowTheIntervalPerPlayerAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var throttle = new BotChatThrottle(); + var interval = TimeSpan.FromMinutes(5); + + Assert.That(throttle.IsConversationDue(player, Start, interval), Is.False); + Assert.That(throttle.IsConversationDue(player, Start.AddMinutes(5), interval), Is.True); + throttle.PostponeConversation(player, Start.AddMinutes(10)); + Assert.That(throttle.IsConversationDue(player, Start.AddMinutes(9), interval), Is.False); + Assert.That(throttle.IsConversationDue(player, Start.AddMinutes(10), interval), Is.True); + } + + /// + /// The default configuration comes with 50 to 100 conversations for every place, each with a + /// Polish translation of both texts. + /// + [Test] + public void DefaultConversationsAreManyAndTranslated() + { + var conversations = BotChatConfiguration.CreateDefault().Conversations; + var polish = CultureInfo.GetCultureInfo("pl"); + + Assert.That(conversations, Has.Count.InRange(50, 100)); + Assert.That(conversations.Select(c => c.Where).Distinct().Count(), Is.EqualTo(Enum.GetValues().Length)); + Assert.That(conversations.All(c => c.Opener.GetOwnTranslation(polish) is not null && c.Reply.GetOwnTranslation(polish) is not null), Is.True); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From 49c8894ebf83e15d45e1a4bf19a5b73bf0d6aa6c Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:14:48 +0100 Subject: [PATCH 14/44] feat(bots): turn the lone small talk into merchant adverts With the conversations, a bot no longer needs to talk to itself in town: "lf party", "lag?" and the like are now said by two bots to each other. The lone line of a bot standing in a safezone gets a new purpose instead - adverts like those of a merchant ("selling potions, cheap! Lorencia 130, 125"), as the groundwork for a merchant bot. The adverts are off by default (chance 0) until such a bot exists; the settings and the list are renamed after them. --- docs-website/docs/server-features/bots.md | 2 +- src/GameLogic/Bots/BotChatConfiguration.cs | 38 ++++++++++--------- src/GameLogic/Bots/BotChatPlugIn.cs | 14 +++---- src/GameLogic/Bots/BotChatThrottle.cs | 26 ++++++------- .../Properties/PlugInResources.Designer.cs | 36 +++++++++--------- src/GameLogic/Properties/PlugInResources.resx | 12 +++--- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 12 +++--- 7 files changed, 72 insertions(+), 68 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 7fe4f5417e..4c78d98938 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -348,7 +348,7 @@ only talks about what it could actually do: | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | | A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | -| Now and then (every 10 minutes per game server, 50 % chance) | normal chat | a bot standing in a safezone which a player sees | +| Now and then, like a merchant advertising (off by default — meant for a future merchant bot) | normal chat, `{location}` | a bot standing in a safezone which a player sees | | A player stands near two bots (every 5 minutes per player) | normal chat: one bot says something, the other answers | two bots close together, both in a town or both out hunting | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 8db6fc28eb..22cfb782ca 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -151,17 +151,20 @@ public class BotChatConfiguration public int KilledByPlayerChancePercent { get; set; } = 70; /// - /// Gets or sets how often a bot in a safezone may make small talk, per game server. + /// Gets or sets how often a bot standing in a safezone may advertise like a merchant (see + /// ), per game server. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkInterval_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkInterval_Description))] - public TimeSpan SmallTalkInterval { get; set; } = TimeSpan.FromMinutes(10); + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantTalkInterval_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantTalkInterval_Description))] + public TimeSpan MerchantTalkInterval { get; set; } = TimeSpan.FromMinutes(10); /// - /// Gets or sets the chance, in percent, that a bot makes small talk when the interval is over. + /// Gets or sets the chance, in percent, that a bot advertises when the interval is over. 0 by + /// default: the lines are meant for a merchant bot which doesn't exist yet - the conversations + /// (see ) took the place of the lone small talk. /// [Range(0, 100)] - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkChancePercent_Description))] - public int SmallTalkChancePercent { get; set; } = 50; + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantTalkChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantTalkChancePercent_Description))] + public int MerchantTalkChancePercent { get; set; } /// /// Gets or sets how often a human player may overhear a conversation of two bots near them (see @@ -309,13 +312,14 @@ public class BotChatConfiguration public ICollection KilledAgainLines { get; set; } = new List(); /// - /// Gets or sets the lines a bot standing in a safezone says now and then in the normal chat (see - /// ) - only when a player sees it. Placeholders: {level}, {map}, {location}. + /// Gets or sets the lines a bot standing in a safezone advertises like a merchant, now and then + /// (see ) - only when a player sees it. + /// Placeholders: {level}, {map}, {location}. /// [MemberOfAggregate] [ScaffoldColumn(true)] - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_SmallTalkLines_Description))] - public ICollection SmallTalkLines { get; set; } = new List(); + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantLines_Description))] + public ICollection MerchantLines { get; set; } = new List(); /// /// Gets or sets the conversations two bots near a player have now and then: one bot says the @@ -397,13 +401,13 @@ public static BotChatConfiguration CreateDefault() ("{player} pls stop", "{player} weź przestań"), ("enough, im out", "dość, spadam")); Add( - configuration.SmallTalkLines, - ("lf party {map}", "szukam grupy {map}"), - ("lag?", "lag?"), - ("need a party for exp", "potrzebna grupa na expa"), - ("anyone up for bc later?", "ktoś na bc później?"), - ("so many people today", "dużo ludzi dzisiaj"), - ("gm online?", "jest gm?")); + configuration.MerchantLines, + ("selling potions, cheap! {location}", "sprzedam potki, tanio! {location}"), + ("buying jewels of bless, whisper me", "kupię jewele bless, pisz pw"), + ("selling +9 weapons, good price, {location}", "sprzedam bronie +9, dobra cena, {location}"), + ("trade? whisper me, i'm at {location}", "handel? pisz pw, stoję w {location}"), + ("buying wings, paying well", "kupię skrzydła, dobrze płacę"), + ("jewels for sale, come to {location}", "jewele na sprzedaż, zapraszam do {location}")); Add( configuration.PartyVolunteerLines, ("me? {level} {class}", "ja? {level} {class}"), diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 1f8f3d2313..0c3a88814c 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -121,7 +121,7 @@ public class BotChatPlugIn /// /// The number of calls of ; a server which didn't see the current number - /// yet makes its small talk without waiting for the interval. + /// yet lets a bot advertise like a merchant without waiting for the interval. /// private int _forceStartCount; @@ -380,7 +380,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) return; } - await this.MakeSmallTalkAsync(gameContext, configuration).ConfigureAwait(false); + await this.MakeMerchantTalkAsync(gameContext, configuration).ConfigureAwait(false); await this.HoldConversationsAsync(gameContext, configuration).ConfigureAwait(false); } @@ -717,11 +717,11 @@ private void WhisperLater(Player bot, Player receiver, BotChatLine line, Diction ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values) : ValueTask.CompletedTask); - private async ValueTask MakeSmallTalkAsync(GameContext gameContext, BotChatConfiguration configuration) + private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatConfiguration configuration) { - if (configuration.SmallTalkChancePercent <= 0 - || !this.GetThrottle(gameContext).TryStartSmallTalk(DateTime.UtcNow, configuration.SmallTalkInterval, Volatile.Read(ref this._forceStartCount)) - || !Rand.NextRandomBool(configuration.SmallTalkChancePercent)) + if (configuration.MerchantTalkChancePercent <= 0 + || !this.GetThrottle(gameContext).TryStartMerchantTalk(DateTime.UtcNow, configuration.MerchantTalkInterval, Volatile.Read(ref this._forceStartCount)) + || !Rand.NextRandomBool(configuration.MerchantTalkChancePercent)) { return; } @@ -738,7 +738,7 @@ private async ValueTask MakeSmallTalkAsync(GameContext gameContext, BotChatConfi } if (candidates.Count == 0 - || PickLine(configuration.SmallTalkLines) is not { } line) + || PickLine(configuration.MerchantLines) is not { } line) { return; } diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 51be31a95b..613a631f20 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -20,10 +20,10 @@ namespace MUnique.OpenMU.GameLogic.Bots; internal sealed class BotChatThrottle { /// - /// The shortest gap between two small talks, so an interval of zero doesn't make the bots talk + /// The shortest gap between two merchant adverts, so an interval of zero doesn't make the bots talk /// every second. /// - private static readonly TimeSpan MinimumSmallTalkInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan MinimumMerchantTalkInterval = TimeSpan.FromSeconds(10); /// /// How long the bots remember whom they answered a party search of: the same search posted again @@ -41,7 +41,7 @@ internal sealed class BotChatThrottle private readonly ConditionalWeakTable _bots = new(); - private readonly Lock _smallTalkLock = new(); + private readonly Lock _merchantTalkLock = new(); private readonly ConditionalWeakTable _partyOffers = new(); @@ -57,7 +57,7 @@ internal sealed class BotChatThrottle private long _nextPostTicks; - private DateTime _nextSmallTalkUtc; + private DateTime _nextMerchantTalkUtc; private int _handledForceStartCount; @@ -121,30 +121,30 @@ public bool TryReserveWhisperReply(Player bot, DateTime now, TimeSpan cooldown) => TryReserve(ref this._bots.GetOrCreateValue(bot).NextWhisperReplyTicks, now, cooldown); /// - /// Determines whether the next small talk of this server is due, and if so, schedules the one + /// Determines whether the next merchant advert of this server is due, and if so, schedules the one /// after it. A forced start (from the admin panel) makes it due at once. /// /// The current time. - /// The configured interval between two small talks. + /// The configured interval between two merchant adverts. /// The current number of forced starts of the plugin. - /// true, if a bot may make small talk now. - public bool TryStartSmallTalk(DateTime now, TimeSpan interval, int forceStartCount) + /// true, if a bot may advertise now. + public bool TryStartMerchantTalk(DateTime now, TimeSpan interval, int forceStartCount) { - lock (this._smallTalkLock) + lock (this._merchantTalkLock) { - if (this._nextSmallTalkUtc == default) + if (this._nextMerchantTalkUtc == default) { // A freshly started server waits one interval first - the bots are just logging in. - this._nextSmallTalkUtc = now + interval; + this._nextMerchantTalkUtc = now + interval; } - if (now < this._nextSmallTalkUtc && forceStartCount == this._handledForceStartCount) + if (now < this._nextMerchantTalkUtc && forceStartCount == this._handledForceStartCount) { return false; } this._handledForceStartCount = forceStartCount; - this._nextSmallTalkUtc = now + (interval > MinimumSmallTalkInterval ? interval : MinimumSmallTalkInterval); + this._nextMerchantTalkUtc = now + (interval > MinimumMerchantTalkInterval ? interval : MinimumMerchantTalkInterval); return true; } } diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 52223b50b0..66496cdb06 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4705,18 +4705,6 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized BotChatConfiguration_KilledByPlayerChancePercent_Description text. public static string BotChatConfiguration_KilledByPlayerChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_KilledByPlayerChancePercent_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_SmallTalkInterval_Name text. - public static string BotChatConfiguration_SmallTalkInterval_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkInterval_Name", resourceCulture)!; - - /// Gets the localized BotChatConfiguration_SmallTalkInterval_Description text. - public static string BotChatConfiguration_SmallTalkInterval_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkInterval_Description", resourceCulture)!; - - /// Gets the localized BotChatConfiguration_SmallTalkChancePercent_Name text. - public static string BotChatConfiguration_SmallTalkChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkChancePercent_Name", resourceCulture)!; - - /// Gets the localized BotChatConfiguration_SmallTalkChancePercent_Description text. - public static string BotChatConfiguration_SmallTalkChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkChancePercent_Description", resourceCulture)!; - /// Gets the localized BotSkillProgressionPlugIn_Name text. public static string BotSkillProgressionPlugIn_Name => ResourceManager.GetString("BotSkillProgressionPlugIn_Name", resourceCulture)!; @@ -5274,12 +5262,6 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_KilledAgainLines_Description text. public static string BotChatConfiguration_KilledAgainLines_Description => ResourceManager.GetString("BotChatConfiguration_KilledAgainLines_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_SmallTalkLines_Name text. - public static string BotChatConfiguration_SmallTalkLines_Name => ResourceManager.GetString("BotChatConfiguration_SmallTalkLines_Name", resourceCulture)!; - - /// Gets the localized BotChatConfiguration_SmallTalkLines_Description text. - public static string BotChatConfiguration_SmallTalkLines_Description => ResourceManager.GetString("BotChatConfiguration_SmallTalkLines_Description", resourceCulture)!; - /// Gets the localized BotChatConfiguration_PartyVolunteerCount_Name text. public static string BotChatConfiguration_PartyVolunteerCount_Name => ResourceManager.GetString("BotChatConfiguration_PartyVolunteerCount_Name", resourceCulture)!; @@ -5388,5 +5370,23 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotConversation_Where_Description text. public static string BotConversation_Where_Description => ResourceManager.GetString("BotConversation_Where_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_MerchantTalkInterval_Name text. + public static string BotChatConfiguration_MerchantTalkInterval_Name => ResourceManager.GetString("BotChatConfiguration_MerchantTalkInterval_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MerchantTalkInterval_Description text. + public static string BotChatConfiguration_MerchantTalkInterval_Description => ResourceManager.GetString("BotChatConfiguration_MerchantTalkInterval_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MerchantTalkChancePercent_Name text. + public static string BotChatConfiguration_MerchantTalkChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_MerchantTalkChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MerchantTalkChancePercent_Description text. + public static string BotChatConfiguration_MerchantTalkChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_MerchantTalkChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MerchantLines_Name text. + public static string BotChatConfiguration_MerchantLines_Name => ResourceManager.GetString("BotChatConfiguration_MerchantLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MerchantLines_Description text. + public static string BotChatConfiguration_MerchantLines_Description => ResourceManager.GetString("BotChatConfiguration_MerchantLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 17b1a2afd3..1713df2fcb 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1927,10 +1927,6 @@ Chance that a bot says something in the party chat of a player: thanks for the invitation, "gg" after an event. 0 = never. Killed by a player chance (%) Chance that a bot killed by a player (outside of an event) says something about it in the normal chat, before it respawns. 0 = never. - Small talk interval - How often a bot standing in a safezone may say something in the normal chat, per game server. Only a bot which a player sees talks. - Small talk chance (%) - Chance that a bot makes small talk when the interval is over. 0 = never. Keywords Comma-separated words or phrases of a player's message which this reply answers, e.g. "how are you, how r u". Whole words, case and punctuation are ignored. Replies without keywords are used when nothing matches. Event lines @@ -1951,8 +1947,6 @@ Said in the normal chat when a player killed the bot outside of an event. Placeholders: {player} (the killer), {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Killed again Said instead of the "Killed by a player" lines when the same player killed the bot again within a short time. Placeholders: {player}, {level}, {map}, {location}. - Small talk - Said now and then in the normal chat by a bot standing in a safezone which a player sees. Placeholders: {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Party search: answering bots How many bots whisper to a player who searches party members with /post, e.g. "/post party 300-350 bk dw". The same search posted again is answered by other bots. 0 = off. Party search: keywords @@ -1989,4 +1983,10 @@ What the second bot answers, a few seconds later. Where Where the bots may have this conversation: Town (both in a safezone), Hunting (both outside of it) or Anywhere. + Merchant adverts: interval + How often a bot standing in a safezone may advertise like a merchant, per game server. Only when a player sees it. + Merchant adverts: chance (%) + Chance that a bot advertises when the interval is over. 0 (default) = never: the lines are meant for a future merchant bot. + Merchant adverts + Said now and then in the normal chat by a bot standing in a safezone, like a merchant advertising. Off by default. Placeholders: {level}, {map}, {location}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index bd613aef9c..1731b8d532 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -57,16 +57,16 @@ public void AnOccasionIsAnnouncedOncePerWindow() /// while the bots log in - and a forced start from the admin panel makes it due at once. /// [Test] - public void SmallTalkFollowsTheIntervalOrAForcedStart() + public void MerchantTalkFollowsTheIntervalOrAForcedStart() { var throttle = new BotChatThrottle(); var interval = TimeSpan.FromMinutes(10); - Assert.That(throttle.TryStartSmallTalk(Start, interval, 0), Is.False); - Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(10), interval, 0), Is.True); - Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(11), interval, 0), Is.False); - Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(11), interval, 1), Is.True); - Assert.That(throttle.TryStartSmallTalk(Start.AddMinutes(12), interval, 1), Is.False); + Assert.That(throttle.TryStartMerchantTalk(Start, interval, 0), Is.False); + Assert.That(throttle.TryStartMerchantTalk(Start.AddMinutes(10), interval, 0), Is.True); + Assert.That(throttle.TryStartMerchantTalk(Start.AddMinutes(11), interval, 0), Is.False); + Assert.That(throttle.TryStartMerchantTalk(Start.AddMinutes(11), interval, 1), Is.True); + Assert.That(throttle.TryStartMerchantTalk(Start.AddMinutes(12), interval, 1), Is.False); } /// From 79e19cf74121f4fef656d373c24f8aff2ca514a7 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:18:04 +0100 Subject: [PATCH 15/44] feat(bots): congratulate players on level-ups and resets Few things make an online world feel as alive as a stranger's "gz". When a player levels up or makes a reset, the nearest bot which sees them now and then congratulates them in the normal chat ("gz", "nice, 400!", "reset 12, nice Hero"). The chance is 30 % and each player gets at most one congratulation per ten minutes for each kind - with a high experience rate a player levels up every minute. A reset logs the player out or not, depending on the reset configuration, so it's noticed by the grown reset count when the bots look over the players every few seconds. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 44 +++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 99 +++++++++++++++++-- src/GameLogic/Bots/BotChatThrottle.cs | 38 +++++-- .../Properties/PlugInResources.Designer.cs | 24 +++++ src/GameLogic/Properties/PlugInResources.resx | 8 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 17 ++++ 7 files changed, 215 insertions(+), 16 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 4c78d98938..8f011ff14f 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -344,6 +344,7 @@ only talks about what it could actually do: | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | +| A player leveled up or made a reset | normal chat, "gz", `{player}`, `{level}`, `{resets}` | the nearest bot which sees the player — 30 % chance, once per player in 10 minutes | | The bot accepted a player's party invitation | party chat, `{player}` | the bot | | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 22cfb782ca..3281c450d4 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -106,6 +106,21 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyChatChancePercent_Description))] public int PartyChatChancePercent { get; set; } = 70; + /// + /// Gets or sets the chance, in percent, that the nearest bot congratulates a player on a level-up + /// or a reset - "gz". + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationChancePercent_Description))] + public int CongratulationChancePercent { get; set; } = 30; + + /// + /// Gets or sets the minimum time between two congratulations of the same player on a level-up + /// (and, separately, on a reset) - with a high experience rate, a player levels up every minute. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Description))] + public TimeSpan CongratulationCooldown { get; set; } = TimeSpan.FromMinutes(10); + /// /// Gets or sets how many bots answer a player's party search posted with /post, e.g. /// "party 300-350 bk" (see ); 0 switches it off. The same search @@ -247,6 +262,24 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Description))] public ICollection EventFinishedLines { get; set; } = new List(); + /// + /// Gets or sets the lines with which the nearest bot congratulates a player on a level-up. + /// Placeholders: {player}, {level} and {resets} of the player, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationLines_Description))] + public ICollection CongratulationLines { get; set; } = new List(); + + /// + /// Gets or sets the lines with which the nearest bot congratulates a player on a reset. + /// Placeholders: {player}, {level} and {resets} of the player, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Description))] + public ICollection ResetCongratulationLines { get; set; } = new List(); + /// /// Gets or sets the whispers with which a bot answers a player's party search. /// Placeholders: {player}, {level}, {class}, {map}, {location}. @@ -408,6 +441,17 @@ public static BotChatConfiguration CreateDefault() ("trade? whisper me, i'm at {location}", "handel? pisz pw, stoję w {location}"), ("buying wings, paying well", "kupię skrzydła, dobrze płacę"), ("jewels for sale, come to {location}", "jewele na sprzedaż, zapraszam do {location}")); + Add( + configuration.CongratulationLines, + ("gz", "gz"), + ("gz {player}", "gratki {player}"), + ("nice, {level}!", "nieźle, {level}!"), + ("grats :)", "gratulacje :)")); + Add( + configuration.ResetCongratulationLines, + ("gz on the reset!", "gratki resetu!"), + ("reset {resets}, nice {player}", "reset {resets}, nieźle {player}"), + ("another reset? gz", "kolejny reset? gz")); Add( configuration.PartyVolunteerLines, ("me? {level} {class}", "ja? {level} {class}"), diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 0c3a88814c..14ce27a717 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -106,6 +106,11 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) KilledDelaySeconds = (1, 2); + /// + /// The delay range of a congratulation: the level-up or reset effect first, then the "gz". + /// + private static readonly (int Min, int Max) CongratulationDelaySeconds = (2, 5); + /// /// The delay range of the answer in a conversation of two bots: the time to read and type. /// @@ -170,7 +175,18 @@ public ValueTask InvasionStartedAsync(IGameContext gameContext, IPeriodicTaskPlu /// public void CharacterLeveledUp(Player player) { - if (this.Configuration is not { LevelMilestoneStep: > 0, LevelMilestoneChancePercent: > 0 } configuration + if (this.Configuration is not { } configuration) + { + return; + } + + if (player is not OfflinePlayer) + { + this.Congratulate(player, configuration, configuration.CongratulationLines, $"congratulation {player.SelectedCharacter?.Name}"); + return; + } + + if (configuration is not { LevelMilestoneStep: > 0, LevelMilestoneChancePercent: > 0 } || !IsBot(player)) { return; @@ -381,7 +397,12 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } await this.MakeMerchantTalkAsync(gameContext, configuration).ConfigureAwait(false); - await this.HoldConversationsAsync(gameContext, configuration).ConfigureAwait(false); + if (this.GetThrottle(gameContext).TryStartPlayerScan(DateTime.UtcNow)) + { + var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); + await this.HoldConversationsAsync(gameContext, configuration, players).ConfigureAwait(false); + this.CongratulateResets(players, configuration); + } } /// @@ -459,8 +480,8 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) } /// - /// Finds the bot which greets an entering player: the nearest available bot on the player's map, - /// within the range in which the player sees it. + /// Finds the bot which talks to a player - greets the entering player, congratulates them: the + /// nearest available bot on the player's map, within the range in which the player sees it. /// /// The players of the game server. /// The entering player. @@ -755,18 +776,16 @@ private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatCo /// stands close together - both in a town or both out hunting - and the conversation fits the /// place. Every player who hears it waits a whole interval for the next one. /// - private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatConfiguration configuration) + private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatConfiguration configuration, IList players) { var now = DateTime.UtcNow; var throttle = this.GetThrottle(gameContext); if (configuration.ConversationChancePercent <= 0 - || configuration.Conversations.Count == 0 - || !throttle.TryStartConversationScan(now)) + || configuration.Conversations.Count == 0) { return; } - var players = await gameContext.GetPlayersAsync().ConfigureAwait(false); var humans = players.Where(p => p is not OfflinePlayer && p.PlayerState.CurrentState == PlayerState.EnteredWorld && p.CurrentMap is not null).ToList(); var bots = players.Where(p => IsBot(p) && IsAvailable(p)).ToList(); foreach (var human in humans) @@ -812,6 +831,70 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC } } + /// + /// Lets the nearest bot congratulate the players whose reset count grew since the last look - the + /// reset may log the player out to the character selection or not, so it's noticed by the count. + /// + private void CongratulateResets(IEnumerable players, BotChatConfiguration configuration) + { + foreach (var player in players) + { + if (player is OfflinePlayer + || player.SelectedCharacter?.Name is not { } name + || player.Attributes is not { } attributes) + { + continue; + } + + var resets = (int)attributes[Stats.Resets]; + if (this.GetThrottle(player.GameContext).TryUpdateKnownResets(name, resets)) + { + this.Congratulate(player, configuration, configuration.ResetCongratulationLines, $"reset congratulation {name}"); + } + } + } + + /// + /// Lets the nearest bot congratulate a player in the normal chat - "gz" - after a moment, at most + /// once per player and cooldown for each kind of occasion. + /// + private void Congratulate(Player player, BotChatConfiguration configuration, ICollection lines, string occasionKey) + { + if (configuration.CongratulationChancePercent <= 0 + || player.SelectedCharacter?.Name is not { } playerName + || !Rand.NextRandomBool(configuration.CongratulationChancePercent)) + { + return; + } + + var playerLevel = GetLevel(player).ToString(CultureInfo.InvariantCulture); + var playerResets = ((int)(player.Attributes?[Stats.Resets] ?? 0)).ToString(CultureInfo.InvariantCulture); + RunLater(player.GameContext, RandomDelay(CongratulationDelaySeconds), async () => + { + if (player.PlayerState.CurrentState != PlayerState.EnteredWorld || player.CurrentMap is not { } map) + { + return; + } + + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(player.GameContext); + var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); + if (FindGreeter(players, player, map) is not { } bot + || PickLine(lines) is not { } line + || !throttle.TryReserveOccasion(occasionKey, now, configuration.CongratulationCooldown) + || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => playerName; + values["level"] = _ => playerLevel; + values["resets"] = _ => playerResets; + await BotChatSpeaker.SayAsync(bot, line.Text, values).ConfigureAwait(false); + }); + } + private async ValueTask GreetAsync(Player player, BotChatConfiguration configuration) { if (player.PlayerState.CurrentState != PlayerState.EnteredWorld diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 613a631f20..2df4c74cc9 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -32,10 +32,11 @@ internal sealed class BotChatThrottle private static readonly TimeSpan PartySearchMemory = TimeSpan.FromMinutes(30); /// - /// How often the bots look for players who are due to overhear a conversation. A second-by-second - /// look over all players would cost for nothing; a few seconds late doesn't matter. + /// How often the bots look over the players - who is due to overhear a conversation, whose reset + /// count grew. A second-by-second look over all players would cost for nothing; a few seconds late + /// doesn't matter. /// - private static readonly TimeSpan ConversationScanInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan PlayerScanInterval = TimeSpan.FromSeconds(5); private readonly ConcurrentDictionary _occasions = new(); @@ -53,7 +54,9 @@ internal sealed class BotChatThrottle private readonly Lock _conversationLock = new(); - private long _nextConversationScanTicks; + private readonly ConcurrentDictionary _knownResets = new(); + + private long _nextPlayerScanTicks; private long _nextPostTicks; @@ -207,13 +210,32 @@ public IReadOnlyList TakeNewVolunteers(Player requester, IEnumerable this._partyOffers.Remove(bot); /// - /// Determines whether it's time for the next look for players who are due to overhear a - /// conversation. + /// Determines whether it's time for the next look over the players. /// /// The current time. /// true, if the caller should look now. - public bool TryStartConversationScan(DateTime now) - => TryReserve(ref this._nextConversationScanTicks, now, ConversationScanInterval); + public bool TryStartPlayerScan(DateTime now) + => TryReserve(ref this._nextPlayerScanTicks, now, PlayerScanInterval); + + /// + /// Remembers the reset count of a character and tells whether it grew since the last look. A + /// character seen for the first time didn't reset in front of the bots. + /// + /// The name of the character. + /// The current reset count. + /// true, if the count grew. + public bool TryUpdateKnownResets(string characterName, int resets) + { + var previous = this._knownResets.GetOrAdd(characterName, resets); + if (resets < previous) + { + // Lowered by an admin: start counting from there. + this._knownResets.TryUpdate(characterName, resets, previous); + return false; + } + + return resets > previous && this._knownResets.TryUpdate(characterName, resets, previous); + } /// /// Determines whether the player is due to overhear a conversation. A player seen for the first diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 66496cdb06..5c025733c8 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5388,5 +5388,29 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_MerchantLines_Description text. public static string BotChatConfiguration_MerchantLines_Description => ResourceManager.GetString("BotChatConfiguration_MerchantLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_CongratulationChancePercent_Name text. + public static string BotChatConfiguration_CongratulationChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_CongratulationChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_CongratulationChancePercent_Description text. + public static string BotChatConfiguration_CongratulationChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_CongratulationChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_CongratulationCooldown_Name text. + public static string BotChatConfiguration_CongratulationCooldown_Name => ResourceManager.GetString("BotChatConfiguration_CongratulationCooldown_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_CongratulationCooldown_Description text. + public static string BotChatConfiguration_CongratulationCooldown_Description => ResourceManager.GetString("BotChatConfiguration_CongratulationCooldown_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_CongratulationLines_Name text. + public static string BotChatConfiguration_CongratulationLines_Name => ResourceManager.GetString("BotChatConfiguration_CongratulationLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_CongratulationLines_Description text. + public static string BotChatConfiguration_CongratulationLines_Description => ResourceManager.GetString("BotChatConfiguration_CongratulationLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ResetCongratulationLines_Name text. + public static string BotChatConfiguration_ResetCongratulationLines_Name => ResourceManager.GetString("BotChatConfiguration_ResetCongratulationLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ResetCongratulationLines_Description text. + public static string BotChatConfiguration_ResetCongratulationLines_Description => ResourceManager.GetString("BotChatConfiguration_ResetCongratulationLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 1713df2fcb..fd6e354377 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1989,4 +1989,12 @@ Chance that a bot advertises when the interval is over. 0 (default) = never: the lines are meant for a future merchant bot. Merchant adverts Said now and then in the normal chat by a bot standing in a safezone, like a merchant advertising. Off by default. Placeholders: {level}, {map}, {location}. + Congratulation chance (%) + Chance that the nearest bot congratulates a player on a level-up or a reset in the normal chat ("gz"). 0 = never. + Congratulation cooldown per player + Minimum time between two congratulations of the same player on a level-up (and, separately, on a reset). + Congratulations: level-up + Said by the nearest bot when a player leveled up. Placeholders: {player}, {level} and {resets} of the player, {map}. + Congratulations: reset + Said by the nearest bot when a player made a reset. Placeholders: {player}, {level} and {resets} of the player, {map}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 1731b8d532..99258c8072 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -121,6 +121,23 @@ public void DefaultConversationsAreManyAndTranslated() Assert.That(conversations.All(c => c.Opener.GetOwnTranslation(polish) is not null && c.Reply.GetOwnTranslation(polish) is not null), Is.True); } + /// + /// A reset is noticed by the grown reset count of a character - not when the character is seen + /// for the first time, and not when an admin lowered the count. + /// + [Test] + public void NoticesAGrownResetCount() + { + var throttle = new BotChatThrottle(); + + Assert.That(throttle.TryUpdateKnownResets("Hero", 3), Is.False); + Assert.That(throttle.TryUpdateKnownResets("Hero", 3), Is.False); + Assert.That(throttle.TryUpdateKnownResets("Hero", 4), Is.True); + Assert.That(throttle.TryUpdateKnownResets("Hero", 4), Is.False); + Assert.That(throttle.TryUpdateKnownResets("Hero", 1), Is.False); + Assert.That(throttle.TryUpdateKnownResets("Hero", 2), Is.True); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From 98a885e31daddaa529d1870f5f2704b8f0cac81d Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:18:41 +0100 Subject: [PATCH 16/44] feat(bots): take the time to type an answer A bot answered every whisper after the same random few seconds, whether it said "np" or a whole sentence - a pattern a player notices. Now the answer takes the time a player would need: a moment to read, then a little time per character, capped at twelve seconds. This applies to whisper and mention replies, the party chat, the party search whispers and the second line of a conversation. --- docs-website/docs/server-features/bots.md | 4 ++ src/GameLogic/Bots/BotChatPlugIn.cs | 64 ++++++++++++++++------- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 15 ++++++ 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 8f011ff14f..ec93ebaec7 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -406,6 +406,10 @@ every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. +A bot answers like a typing player: it takes a moment to read, then a little +time per character, so a quick "np" comes at once and a whole sentence takes a +few seconds (at most twelve). + The limits keep a large population from flooding the chat. All bots of a game server share one gap between two posts (`Minimum time between posts`, 3 minutes), each opened event type or started invasion is announced at most once, and each diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 14ce27a717..c84034f52e 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -16,6 +16,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; @@ -85,21 +86,11 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) LevelMilestoneDelaySeconds = (1, 4); - /// - /// The delay range of a whisper reply - the time a player needs to read and type. - /// - private static readonly (int Min, int Max) WhisperReplyDelaySeconds = (2, 7); - /// /// The delay range of a greeting: the entering player's client needs a moment to load the world. /// private static readonly (int Min, int Max) GreetingDelaySeconds = (3, 8); - /// - /// The delay range of a party chat line - after the party window or the event's score popped up. - /// - private static readonly (int Min, int Max) PartyChatDelaySeconds = (2, 6); - /// /// The delay range of a comment on being killed: said while the bot still lies there, before it /// respawns, so the killer sees it. @@ -112,9 +103,19 @@ public class BotChatPlugIn private static readonly (int Min, int Max) CongratulationDelaySeconds = (2, 5); /// - /// The delay range of the answer in a conversation of two bots: the time to read and type. + /// The delay range before a bot starts typing an answer: the time to notice and read the message. + /// + private static readonly (int Min, int Max) TypingReactionSeconds = (1, 3); + + /// + /// The range of the time a bot needs per typed character - a casual typist in a game chat. + /// + private static readonly (int Min, int Max) TypingMillisecondsPerCharacter = (120, 220); + + /// + /// The longest a bot types, so a long line doesn't arrive after the player gave up waiting. /// - private static readonly (int Min, int Max) ConversationReplyDelaySeconds = (3, 6); + private static readonly TimeSpan MaximumTypingTime = TimeSpan.FromSeconds(12); /// /// The gap between the whispers of two bots answering the same party search, so the answers @@ -241,7 +242,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag values["player"] = _ => senderName; RunLater( receiver.GameContext, - RandomDelay(WhisperReplyDelaySeconds), + TypingDelay(line.Text), () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values) : ValueTask.CompletedTask); @@ -278,7 +279,7 @@ public void BotJoinedParty(Player bot, Player inviter) var values = CreateBotValues(bot); var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => inviterName; - RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); } /// @@ -307,7 +308,7 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR var values = CreateBotValues(bot); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; - RunLater(bot.GameContext, RandomDelay(PartyChatDelaySeconds), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); } return ValueTask.CompletedTask; @@ -384,7 +385,7 @@ await sender.ForEachObservingAsync( values["player"] = _ => senderName; RunLater( bot.GameContext, - RandomDelay(WhisperReplyDelaySeconds), + TypingDelay(line.Text), () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values) : ValueTask.CompletedTask); } @@ -442,11 +443,12 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) throttle.SetPartyOffer(bot, new BotPartyOffer(sender, now + configuration.PartyOfferTimeout)); delay += RandomDelay(PartyVolunteerSpacingSeconds); + var whisperDelay = delay + TypingDelay(line.Text); var values = CreateBotValues(bot); values["player"] = _ => senderName; RunLater( bot.GameContext, - delay, + whisperDelay, () => IsAvailable(bot) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld ? BotChatSpeaker.WhisperAsync(bot, sender, line.Text, values) : ValueTask.CompletedTask); @@ -521,6 +523,20 @@ internal static CharacterClass GetFirstClassOfLine(CharacterClass characterClass return current; } + /// + /// Gets the time a bot needs to answer with a text of the given length: the reaction, then the + /// typing of each character - "np" comes quickly, a whole sentence takes a while. + /// + /// The length of the text. + /// The time to notice and read the message. + /// The time per typed character. + /// The delay, at most . + internal static TimeSpan GetTypingTime(int length, TimeSpan reaction, TimeSpan perCharacter) + { + var time = reaction + (perCharacter * length); + return time < MaximumTypingTime ? time : MaximumTypingTime; + } + private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; private static bool IsOfWantedClass(Player bot, BotPartyRequest request, ICollection classes) @@ -616,6 +632,16 @@ private static bool IsPostCommandActive(IGameContext gameContext) private static BotChatLine? PickLine(IEnumerable lines) => BotChatComposer.PickLine(lines, count => Rand.NextInt(0, count)); + /// + /// Gets a random, human-like time to answer with the text (see ). The + /// English text stands in for all translations - they are about as long. + /// + private static TimeSpan TypingDelay(LocalizedString text) + => GetTypingTime( + text.ValueInNeutralLanguage.Length, + RandomDelay(TypingReactionSeconds), + TimeSpan.FromMilliseconds(Rand.NextInt(TypingMillisecondsPerCharacter.Min, TypingMillisecondsPerCharacter.Max))); + private static TimeSpan RandomDelay(TimeSpan min, TimeSpan max) => max <= min ? min : min + TimeSpan.FromMilliseconds(Rand.NextDouble() * (max - min).TotalMilliseconds); @@ -733,7 +759,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess private void WhisperLater(Player bot, Player receiver, BotChatLine line, Dictionary> values) => RunLater( bot.GameContext, - RandomDelay(WhisperReplyDelaySeconds), + TypingDelay(line.Text), () => receiver.PlayerState.CurrentState == PlayerState.EnteredWorld ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values) : ValueTask.CompletedTask); @@ -826,7 +852,7 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC await BotChatSpeaker.SayAsync(first, conversation.Opener, firstValues).ConfigureAwait(false); RunLater( gameContext, - RandomDelay(ConversationReplyDelaySeconds), + TypingDelay(conversation.Reply), () => IsAvailable(second) ? BotChatSpeaker.SayAsync(second, conversation.Reply, secondValues) : ValueTask.CompletedTask); } } diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 99258c8072..a7d832ff0f 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -138,6 +138,21 @@ public void NoticesAGrownResetCount() Assert.That(throttle.TryUpdateKnownResets("Hero", 2), Is.True); } + /// + /// A bot answers like a typing player: a short "np" comes quickly, a long sentence takes longer - + /// but never longer than the cap, so the player doesn't give up waiting. + /// + [Test] + public void TypesLongerForLongerTexts() + { + var reaction = TimeSpan.FromSeconds(1); + var perCharacter = TimeSpan.FromMilliseconds(200); + + Assert.That(BotChatPlugIn.GetTypingTime(2, reaction, perCharacter), Is.EqualTo(TimeSpan.FromMilliseconds(1400))); + Assert.That(BotChatPlugIn.GetTypingTime(30, reaction, perCharacter), Is.EqualTo(TimeSpan.FromSeconds(7))); + Assert.That(BotChatPlugIn.GetTypingTime(500, reaction, perCharacter), Is.EqualTo(TimeSpan.FromSeconds(12))); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From a7782c567ddcf64330c1c09b0c4f7d416e1854aa Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:19:25 +0100 Subject: [PATCH 17/44] feat(bots): don't give a player the same answer twice in a row A player who whispers "how are you?" twice and gets the very same "fine, thanks for asking" twice knows they talk to a machine. The bots now remember, per player and list, the line they said last and pick another one when the list has another to offer. This covers the lines said to one player: whisper and mention replies, greetings, congratulations, the comments on being killed and the party search questions. --- src/GameLogic/Bots/BotChatComposer.cs | 36 ++++++++++++++----- src/GameLogic/Bots/BotChatPlugIn.cs | 42 +++++++++++++++++++---- src/GameLogic/Bots/BotChatThrottle.cs | 32 +++++++++++++++++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 22 ++++++++++++ 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 16aff4a846..a7533ce841 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -20,12 +20,13 @@ internal static class BotChatComposer /// /// The lines of one list, e.g. the greetings. /// Returns a random index below the given count. + /// The line said last time to the same player, which is left out when there are others. /// The picked line, or null when the list has no usable line. - internal static BotChatLine? PickLine(IEnumerable lines, Func pickIndex) + internal static BotChatLine? PickLine(IEnumerable lines, Func pickIndex, object? previous = null) { - var candidates = lines - .Where(line => !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)) - .ToList(); + var candidates = WithoutPrevious( + lines.Where(line => !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)).ToList(), + previous); return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } @@ -36,12 +37,13 @@ internal static class BotChatComposer /// The configured replies. /// The player's message. /// Returns a random index below the given count. + /// The reply given last time to the same player, which is left out when there are others. /// The picked reply, or null when there is no usable reply. /// /// The best match is the longest matching keyword, so "hi, how are you?" is answered by the /// "how are you" replies rather than by the greetings matching "hi". /// - internal static BotChatReply? PickWhisperReply(IEnumerable replies, string message, Func pickIndex) + internal static BotChatReply? PickWhisperReply(IEnumerable replies, string message, Func pickIndex, object? previous = null) { var usable = replies .Where(reply => !string.IsNullOrWhiteSpace(reply.Text.ValueInNeutralLanguage)) @@ -51,9 +53,11 @@ internal static class BotChatComposer .Select(reply => (Reply: reply, Score: GetKeywords(reply.Keywords).Where(k => normalizedMessage.Contains($" {k} ", StringComparison.Ordinal)).Select(k => k.Length).DefaultIfEmpty(0).Max())) .ToList(); var bestScore = scored.Count == 0 ? 0 : scored.Max(s => s.Score); - var candidates = bestScore > 0 - ? scored.Where(s => s.Score == bestScore).Select(s => s.Reply).ToList() - : usable.Where(reply => !GetKeywords(reply.Keywords).Any()).ToList(); + var candidates = WithoutPrevious( + bestScore > 0 + ? scored.Where(s => s.Score == bestScore).Select(s => s.Reply).ToList() + : usable.Where(reply => !GetKeywords(reply.Keywords).Any()).ToList(), + previous); return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } @@ -169,6 +173,22 @@ static IEnumerable GetNames(GameMapDefinition map) .Distinct(); } + /// + /// Leaves out the previous pick, unless it's the only candidate - a player who asks twice must + /// not get the very same answer twice, but an answer is better than none. + /// + private static List WithoutPrevious(List candidates, object? previous) + where T : class + { + if (previous is null || candidates.Count < 2) + { + return candidates; + } + + candidates.RemoveAll(candidate => ReferenceEquals(candidate, previous)); + return candidates; + } + private static IEnumerable GetKeywords(string? keywords) => (keywords ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index c84034f52e..ddf1475020 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -231,7 +231,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag || !IsBot(receiver) || sender is OfflinePlayer || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) - || BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, count => Rand.NextInt(0, count)) is not { } line + || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line || !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; @@ -335,7 +335,7 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) { // Read after the delay: the revenge plugin registers the death in the same notification. var lines = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? configuration.KilledAgainLines : configuration.KilledLines; - if (PickLine(lines) is not { } line) + if (this.PickLineFor(killerPlayer, lines) is not { } line) { return ValueTask.CompletedTask; } @@ -375,7 +375,7 @@ await sender.ForEachObservingAsync( if (mentionedBots.FirstOrDefault(IsAvailable) is not { } bot || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) - || BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, count => Rand.NextInt(0, count)) is not { } line + || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; @@ -696,6 +696,36 @@ private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + /// + /// Picks a line said to the player, other than the one of this list the player got last time. + /// + private BotChatLine? PickLineFor(Player listener, ICollection lines) + { + var throttle = this.GetThrottle(listener.GameContext); + var line = BotChatComposer.PickLine(lines, count => Rand.NextInt(0, count), throttle.GetLastLine(listener, lines)); + if (line is not null) + { + throttle.SetLastLine(listener, lines, line); + } + + return line; + } + + /// + /// Picks the reply to the player's message, other than the reply the player got last time. + /// + private BotChatReply? PickReplyFor(Player listener, ICollection replies, string message) + { + var throttle = this.GetThrottle(listener.GameContext); + var reply = BotChatComposer.PickWhisperReply(replies, message, count => Rand.NextInt(0, count), throttle.GetLastLine(listener, replies)); + if (reply is not null) + { + throttle.SetLastLine(listener, replies, reply); + } + + return reply; + } + /// /// Answers the whisper in which a player, whose party search the bot answered, names the meeting /// place: "devias 215 40", or "here" for where the player stands. A bot which may go there sets @@ -721,7 +751,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess if (place is not { } found) { if (throttle.TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown) - && PickLine(configuration.PartyAskWhereLines) is { } askLine) + && this.PickLineFor(requester, configuration.PartyAskWhereLines) is { } askLine) { this.WhisperLater(bot, requester, askLine, values); } @@ -906,7 +936,7 @@ private void Congratulate(Player player, BotChatConfiguration configuration, ICo var throttle = this.GetThrottle(player.GameContext); var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); if (FindGreeter(players, player, map) is not { } bot - || PickLine(lines) is not { } line + || this.PickLineFor(player, lines) is not { } line || !throttle.TryReserveOccasion(occasionKey, now, configuration.CongratulationCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) { @@ -935,7 +965,7 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura var now = DateTime.UtcNow; var throttle = this.GetThrottle(player.GameContext); if (bot is null - || PickLine(configuration.GreetingLines) is not { } line + || this.PickLineFor(player, configuration.GreetingLines) is not { } line || !throttle.TryReserveOccasion($"greeting {playerName}", now, configuration.GreetingCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) { diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 2df4c74cc9..7261ad13ca 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -56,6 +56,8 @@ internal sealed class BotChatThrottle private readonly ConcurrentDictionary _knownResets = new(); + private readonly ConditionalWeakTable> _lastLines = new(); + private long _nextPlayerScanTicks; private long _nextPostTicks; @@ -297,6 +299,36 @@ public void PostponeConversation(Player player, DateTime until) } } + /// + /// Gets the line of the list which the bots said last to the player. + /// + /// The player the line was said to. + /// The list of the line, e.g. the whisper replies. + /// The line, or null when none of the list was said to the player yet. + public object? GetLastLine(Player player, object list) + { + var lines = this._lastLines.GetOrCreateValue(player); + lock (lines) + { + return lines.GetValueOrDefault(list); + } + } + + /// + /// Remembers the line of the list which the bots said to the player, so the next one differs. + /// + /// The player the line was said to. + /// The list of the line, e.g. the whisper replies. + /// The line. + public void SetLastLine(Player player, object list, object line) + { + var lines = this._lastLines.GetOrCreateValue(player); + lock (lines) + { + lines[list] = line; + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index a7d832ff0f..19518eed6b 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -259,6 +259,28 @@ public void AnswersWhispersByKeywords(string message, string expected) Assert.That(reply?.Text.ValueInNeutralLanguage, Is.EqualTo(expected)); } + /// + /// A player who asks the same twice doesn't get the very same answer twice - unless there is no + /// other answer. + /// + [Test] + public void DoesNotRepeatTheLastAnswer() + { + var replies = new List + { + new() { Text = "fine", Keywords = "how are you" }, + new() { Text = "good, you?", Keywords = "how are you" }, + new() { Text = "only one", Keywords = "level" }, + }; + + var first = BotChatComposer.PickWhisperReply(replies, "how are you", _ => 0); + var second = BotChatComposer.PickWhisperReply(replies, "how are you", _ => 0, first); + var only = BotChatComposer.PickWhisperReply(replies, "level", _ => 0, replies[2]); + + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(only, Is.SameAs(replies[2])); + } + /// /// A bot feels addressed when its name is in a chat message as a whole word - not when it is only /// a part of a longer word. From db490d540d19695a1711f48677c59c6d73103b5b Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:20:24 +0100 Subject: [PATCH 18/44] feat(bots): fit lines and conversations to the time of day A "good morning" at midnight gives a bot away. Every line and conversation now has a "When": anytime (the default), morning, day, evening or night, by the local time of the server, and a bot picks only among those which fit the hour. The defaults add a few: "good morning", "late night grind?", "still up?" - "the monsters don't sleep, neither do i". The hours are fixed; they only pick fitting lines, so configurable boundaries wouldn't be worth the extra settings. --- docs-website/docs/server-features/bots.md | 5 +++ src/GameLogic/Bots/BotChatComposer.cs | 14 ++++++- src/GameLogic/Bots/BotChatConfiguration.cs | 11 +++++ src/GameLogic/Bots/BotChatLine.cs | 6 +++ src/GameLogic/Bots/BotChatPlugIn.cs | 19 ++++++++- src/GameLogic/Bots/BotChatTimeOfDay.cs | 42 +++++++++++++++++++ src/GameLogic/Bots/BotConversation.cs | 6 +++ src/GameLogic/Bots/BotConversationDefaults.cs | 25 ++++++++++- .../Properties/PlugInResources.Designer.cs | 6 +++ src/GameLogic/Properties/PlugInResources.resx | 2 + tests/MUnique.OpenMU.Tests/BotChatTest.cs | 39 ++++++++++++++++- 11 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/GameLogic/Bots/BotChatTimeOfDay.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index ec93ebaec7..ba2998f3ac 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -406,6 +406,11 @@ every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. +Every line and conversation has a `When`: `Anytime` (the default), `Morning` +(5–11 o'clock), `Day` (11–18), `Evening` (18–23) or `Night` (23–5), by the local +time of the server. A greeting may be "good morning" in the morning and "late +night grind?" at night. + A bot answers like a typing player: it takes a moment to read, then a little time per character, so a quick "np" comes at once and a whole sentence takes a few seconds (at most twelve). diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index a7533ce841..69c352a058 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -19,17 +19,27 @@ internal static class BotChatComposer /// Picks a random usable line of the list. /// /// The lines of one list, e.g. the greetings. + /// The current time of day; lines of another time of day are left out. /// Returns a random index below the given count. /// The line said last time to the same player, which is left out when there are others. /// The picked line, or null when the list has no usable line. - internal static BotChatLine? PickLine(IEnumerable lines, Func pickIndex, object? previous = null) + internal static BotChatLine? PickLine(IEnumerable lines, BotChatTimeOfDay timeOfDay, Func pickIndex, object? previous = null) { var candidates = WithoutPrevious( - lines.Where(line => !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage)).ToList(), + lines.Where(line => !string.IsNullOrWhiteSpace(line.Text.ValueInNeutralLanguage) && FitsTimeOfDay(line.When, timeOfDay)).ToList(), previous); return candidates.Count == 0 ? null : candidates[pickIndex(candidates.Count)]; } + /// + /// Determines whether a line or conversation of the given time of day may be said now. + /// + /// The time of day of the line. + /// The current time of day. + /// true, if it may be said now. + internal static bool FitsTimeOfDay(BotChatTimeOfDay when, BotChatTimeOfDay now) + => when == BotChatTimeOfDay.Anytime || when == now; + /// /// Picks the reply to a player's message: a random one of the replies whose keywords match the /// message best, or - when no keyword matches - one of the replies without keywords. diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 3281c450d4..b402614bdf 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -409,6 +409,9 @@ public static BotChatConfiguration CreateDefault() ("welcome back {player}", "witaj z powrotem {player}"), ("yo {player} :)", "yo {player} :)"), ("hey {player}", "hej {player}")); + AddAt(configuration.GreetingLines, BotChatTimeOfDay.Morning, ("good morning {player}", "dzień dobry {player}"), ("morning {player}, coffee first?", "cześć {player}, najpierw kawa?")); + AddAt(configuration.GreetingLines, BotChatTimeOfDay.Evening, ("good evening {player}", "dobry wieczór {player}")); + AddAt(configuration.GreetingLines, BotChatTimeOfDay.Night, ("late night grind, {player}?", "nocny exp, {player}?")); Add( configuration.PartyJoinedLines, ("thx for invite", "dzięki za zaproszenie"), @@ -519,6 +522,14 @@ void Add(ICollection lines, params (string English, string Polish)[ } } + void AddAt(ICollection lines, BotChatTimeOfDay when, params (string English, string Polish)[] texts) + { + foreach (var text in texts) + { + lines.Add(new BotChatLine { Text = Translated(text), When = when }); + } + } + void AddReplies(string keywords, params (string English, string Polish)[] texts) { foreach (var text in texts) diff --git a/src/GameLogic/Bots/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs index 415a096d34..425a7892b2 100644 --- a/src/GameLogic/Bots/BotChatLine.cs +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -24,6 +24,12 @@ public class BotChatLine [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] public LocalizedString Text { get; set; } + /// + /// Gets or sets when the line may be said, by the local time of the server. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_When_Name), Description = nameof(PlugInResources.BotChatLine_When_Description))] + public BotChatTimeOfDay When { get; set; } + /// public override string ToString() => this.Text.ValueInNeutralLanguage; } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index ddf1475020..dc45d48c07 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -481,6 +481,19 @@ internal static bool MayWarpTo(Player bot, GameMapDefinition map) && character.GetEffectiveMoveLevelRequirement(warp.LevelRequirement) <= level); } + /// + /// Gets the time of day of an hour of the server's local time (see ). + /// + /// The hour, 0 to 23. + /// The time of day. + internal static BotChatTimeOfDay GetTimeOfDay(int hour) => hour switch + { + >= 5 and < 11 => BotChatTimeOfDay.Morning, + >= 11 and < 18 => BotChatTimeOfDay.Day, + >= 18 and < 23 => BotChatTimeOfDay.Evening, + _ => BotChatTimeOfDay.Night, + }; + /// /// Finds the bot which talks to a player - greets the entering player, congratulates them: the /// nearest available bot on the player's map, within the range in which the player sees it. @@ -630,7 +643,7 @@ private static bool IsPostCommandActive(IGameContext gameContext) => gameContext.PlugInManager.IsPlugInActive(typeof(PostChatCommandPlugIn)); private static BotChatLine? PickLine(IEnumerable lines) - => BotChatComposer.PickLine(lines, count => Rand.NextInt(0, count)); + => BotChatComposer.PickLine(lines, GetTimeOfDay(DateTime.Now.Hour), count => Rand.NextInt(0, count)); /// /// Gets a random, human-like time to answer with the text (see ). The @@ -702,7 +715,7 @@ private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo private BotChatLine? PickLineFor(Player listener, ICollection lines) { var throttle = this.GetThrottle(listener.GameContext); - var line = BotChatComposer.PickLine(lines, count => Rand.NextInt(0, count), throttle.GetLastLine(listener, lines)); + var line = BotChatComposer.PickLine(lines, GetTimeOfDay(DateTime.Now.Hour), count => Rand.NextInt(0, count), throttle.GetLastLine(listener, lines)); if (line is not null) { throttle.SetLastLine(listener, lines, line); @@ -858,8 +871,10 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC throttle.PostponeConversation(listener, now + configuration.ConversationInterval); } + var timeOfDay = GetTimeOfDay(DateTime.Now.Hour); var candidates = configuration.Conversations .Where(c => (c.Where == BotConversationPlace.Anywhere || c.Where == pair.Place) + && BotChatComposer.FitsTimeOfDay(c.When, timeOfDay) && !string.IsNullOrWhiteSpace(c.Opener.ValueInNeutralLanguage) && !string.IsNullOrWhiteSpace(c.Reply.ValueInNeutralLanguage)) .ToList(); diff --git a/src/GameLogic/Bots/BotChatTimeOfDay.cs b/src/GameLogic/Bots/BotChatTimeOfDay.cs new file mode 100644 index 0000000000..d0f2228f2c --- /dev/null +++ b/src/GameLogic/Bots/BotChatTimeOfDay.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// When a bot may say a or have a , by the +/// local time of the server: "good morning" in the morning, "late night grind?" at night. +/// +/// +/// New values are appended: the configuration stores the time of day as a number. The hours are +/// fixed (see ); they only pick fitting lines, so a +/// configurable boundary wouldn't be worth the extra settings. +/// +public enum BotChatTimeOfDay +{ + /// + /// At any time. + /// + Anytime, + + /// + /// From 5 to 11 o'clock. + /// + Morning, + + /// + /// From 11 to 18 o'clock. + /// + Day, + + /// + /// From 18 to 23 o'clock. + /// + Evening, + + /// + /// From 23 to 5 o'clock. + /// + Night, +} diff --git a/src/GameLogic/Bots/BotConversation.cs b/src/GameLogic/Bots/BotConversation.cs index 1ab87cdb6e..d80a4f659d 100644 --- a/src/GameLogic/Bots/BotConversation.cs +++ b/src/GameLogic/Bots/BotConversation.cs @@ -35,6 +35,12 @@ public class BotConversation [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Where_Name), Description = nameof(PlugInResources.BotConversation_Where_Description))] public BotConversationPlace Where { get; set; } + /// + /// Gets or sets when the bots may have this conversation, by the local time of the server. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_When_Name), Description = nameof(PlugInResources.BotChatLine_When_Description))] + public BotChatTimeOfDay When { get; set; } + /// public override string ToString() => $"{this.Opener.ValueInNeutralLanguage} - {this.Reply.ValueInNeutralLanguage}"; } diff --git a/src/GameLogic/Bots/BotConversationDefaults.cs b/src/GameLogic/Bots/BotConversationDefaults.cs index 67193201cf..def0bc02ae 100644 --- a/src/GameLogic/Bots/BotConversationDefaults.cs +++ b/src/GameLogic/Bots/BotConversationDefaults.cs @@ -20,7 +20,8 @@ namespace MUnique.OpenMU.GameLogic.Bots; internal static class BotConversationDefaults { /// - /// Adds the default conversations. + /// Adds the default conversations; most fit any time of day, a few only the morning, the evening + /// or the night. /// /// The list of the configuration. internal static void AddTo(ICollection conversations) @@ -107,9 +108,30 @@ internal static void AddTo(ICollection conversations) ("have you seen a dark lord around?", "he was looking for his horse", "widziałeś gdzieś dark lorda?", "szukał swojego konia"), ("what's the best class?", "the one you enjoy", "jaka klasa jest najlepsza?", "ta, którą lubisz"), ("need a party for exp", "same here, let's wait for someone", "potrzebna grupa na expa", "też, poczekajmy na kogoś")); + AddAt( + conversations, + BotConversationPlace.Anywhere, + BotChatTimeOfDay.Morning, + ("morning! coffee first", "potions first, coffee later", "dzień dobry! najpierw kawa", "najpierw potki, kawa później"), + ("up early today?", "the early elf catches the drop", "wcześnie dziś wstałeś?", "kto rano wstaje, temu drop wypada")); + AddAt( + conversations, + BotConversationPlace.Anywhere, + BotChatTimeOfDay.Evening, + ("long day at work", "this is the best part of it", "długi dzień w pracy", "to jego najlepsza część"), + ("dinner time soon", "one more spot first", "zaraz kolacja", "najpierw jeszcze jeden spot")); + AddAt( + conversations, + BotConversationPlace.Anywhere, + BotChatTimeOfDay.Night, + ("still up?", "the monsters don't sleep, neither do i", "jeszcze nie śpisz?", "potwory nie śpią, ja też nie"), + ("late night grind?", "can't sleep anyway", "nocny exp?", "i tak nie mogę spać")); } private static void Add(ICollection conversations, BotConversationPlace place, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) + => AddAt(conversations, place, BotChatTimeOfDay.Anytime, texts); + + private static void AddAt(ICollection conversations, BotConversationPlace place, BotChatTimeOfDay when, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) { var polish = CultureInfo.GetCultureInfo("pl"); foreach (var text in texts) @@ -119,6 +141,7 @@ private static void Add(ICollection conversations, BotConversat Opener = new LocalizedString(text.Opener).WithTranslation(polish, text.PolishOpener), Reply = new LocalizedString(text.Reply).WithTranslation(polish, text.PolishReply), Where = place, + When = when, }); } } diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 5c025733c8..368428889e 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5412,5 +5412,11 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_ResetCongratulationLines_Description text. public static string BotChatConfiguration_ResetCongratulationLines_Description => ResourceManager.GetString("BotChatConfiguration_ResetCongratulationLines_Description", resourceCulture)!; + /// Gets the localized BotChatLine_When_Name text. + public static string BotChatLine_When_Name => ResourceManager.GetString("BotChatLine_When_Name", resourceCulture)!; + + /// Gets the localized BotChatLine_When_Description text. + public static string BotChatLine_When_Description => ResourceManager.GetString("BotChatLine_When_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index fd6e354377..a81b964f9d 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1997,4 +1997,6 @@ Said by the nearest bot when a player leveled up. Placeholders: {player}, {level} and {resets} of the player, {map}. Congratulations: reset Said by the nearest bot when a player made a reset. Placeholders: {player}, {level} and {resets} of the player, {map}. + When + When it may be said, by the local time of the server: Anytime, Morning (5-11), Day (11-18), Evening (18-23) or Night (23-5). diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 19518eed6b..e3b9326df6 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -186,13 +186,48 @@ public void PicksOnlyUsableLines() new() { Text = "{level} finally" }, }; - var picked = BotChatComposer.PickLine(lines, count => count - 1); - var silent = BotChatComposer.PickLine(new List { new() { Text = string.Empty } }, _ => 0); + var picked = BotChatComposer.PickLine(lines, BotChatTimeOfDay.Day, count => count - 1); + var silent = BotChatComposer.PickLine(new List { new() { Text = string.Empty } }, BotChatTimeOfDay.Day, _ => 0); Assert.That(picked?.Text.ValueInNeutralLanguage, Is.EqualTo("{level} finally")); Assert.That(silent, Is.Null); } + /// + /// A line of a time of day is only said then - "good morning" in the morning - while the lines + /// for any time are always available. + /// + [Test] + public void PicksLinesOfTheTimeOfDay() + { + var lines = new List + { + new() { Text = "good morning", When = BotChatTimeOfDay.Morning }, + new() { Text = "late night grind?", When = BotChatTimeOfDay.Night }, + }; + var anytime = new BotChatLine { Text = "hey" }; + + Assert.That(BotChatComposer.PickLine(lines, BotChatTimeOfDay.Morning, _ => 0)?.Text.ValueInNeutralLanguage, Is.EqualTo("good morning")); + Assert.That(BotChatComposer.PickLine(lines, BotChatTimeOfDay.Day, _ => 0), Is.Null); + Assert.That(BotChatComposer.PickLine(lines.Append(anytime), BotChatTimeOfDay.Day, _ => 0), Is.SameAs(anytime)); + } + + /// + /// The hours of the server's local time map to the times of day; the night wraps around midnight. + /// + /// The hour. + /// The expected time of day. + [TestCase(4, BotChatTimeOfDay.Night)] + [TestCase(5, BotChatTimeOfDay.Morning)] + [TestCase(11, BotChatTimeOfDay.Day)] + [TestCase(18, BotChatTimeOfDay.Evening)] + [TestCase(23, BotChatTimeOfDay.Night)] + [TestCase(0, BotChatTimeOfDay.Night)] + public void KnowsTheTimeOfDay(int hour, BotChatTimeOfDay expected) + { + Assert.That(BotChatPlugIn.GetTimeOfDay(hour), Is.EqualTo(expected)); + } + /// /// Every receiver sees the translation of their own language, with the placeholders filled in /// for that language; without a translation, the English text is used. From f01e91cdc78505dab70cae845bb84c9595742e27 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:21:49 +0100 Subject: [PATCH 19/44] feat(bots): say why a trade or duel is turned down A bot has no trade window and doesn't duel. A trade request to it went nowhere without a word, and a duel challenge was never answered at all, which left the challenger waiting. Now the bot refuses the duel like a player clicking "no", and in both cases it often whispers why ("sry, no trade", "not now, maybe later"). Two new plugin points report the requests: a trade request before it's checked (a request to a bot can't be delivered), a duel challenge after it was handed to the challenged player. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 37 ++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 68 +++++++++++++++++++ .../PlayerActions/Duel/DuelActions.cs | 5 ++ .../PlayerActions/Trade/TradeRequestAction.cs | 7 ++ src/GameLogic/PlugIns/IDuelRequestedPlugIn.cs | 25 +++++++ .../PlugIns/ITradeRequestedPlugIn.cs | 28 ++++++++ .../Properties/PlugInResources.Designer.cs | 18 +++++ src/GameLogic/Properties/PlugInResources.resx | 6 ++ 9 files changed, 195 insertions(+) create mode 100644 src/GameLogic/PlugIns/IDuelRequestedPlugIn.cs create mode 100644 src/GameLogic/PlugIns/ITradeRequestedPlugIn.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index ba2998f3ac..2064cf8825 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -351,6 +351,7 @@ only talks about what it could actually do: | A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | | Now and then, like a merchant advertising (off by default — meant for a future merchant bot) | normal chat, `{location}` | a bot standing in a safezone which a player sees | | A player stands near two bots (every 5 minutes per player) | normal chat: one bot says something, the other answers | two bots close together, both in a town or both out hunting | +| A player asked the bot for a trade or challenged it to a duel | whisper, 70 % chance, `{player}` — the bot turns it down in any case (no trade window, no duels) | the bot | | A player whispered to the bot | whisper back, after a few seconds — answering the whisper's keywords (see below) | the bot | A bot which posts about an event does not stay out hunting where nobody diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index b402614bdf..8e6adb8fb0 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -121,6 +121,15 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Description))] public TimeSpan CongratulationCooldown { get; set; } = TimeSpan.FromMinutes(10); + /// + /// Gets or sets the chance, in percent, that a bot says why it turns down a player's trade or duel + /// request, by whisper. A bot turns them down in any case - it has no trade window, and it + /// doesn't duel. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_DeclineReplyChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_DeclineReplyChancePercent_Description))] + public int DeclineReplyChancePercent { get; set; } = 70; + /// /// Gets or sets how many bots answer a player's party search posted with /post, e.g. /// "party 300-350 bk" (see ); 0 switches it off. The same search @@ -280,6 +289,24 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Description))] public ICollection ResetCongratulationLines { get; set; } = new List(); + /// + /// Gets or sets the whispers with which a bot turns down a player's trade request. + /// Placeholders: {player}, {level}, {map}, {location}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_TradeDeclineLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_TradeDeclineLines_Description))] + public ICollection TradeDeclineLines { get; set; } = new List(); + + /// + /// Gets or sets the whispers with which a bot turns down a player's duel challenge. + /// Placeholders: {player}, {level}, {map}, {location}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_DuelDeclineLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_DuelDeclineLines_Description))] + public ICollection DuelDeclineLines { get; set; } = new List(); + /// /// Gets or sets the whispers with which a bot answers a player's party search. /// Placeholders: {player}, {level}, {class}, {map}, {location}. @@ -455,6 +482,16 @@ public static BotChatConfiguration CreateDefault() ("gz on the reset!", "gratki resetu!"), ("reset {resets}, nice {player}", "reset {resets}, nieźle {player}"), ("another reset? gz", "kolejny reset? gz")); + Add( + configuration.TradeDeclineLines, + ("sry, no trade", "sorry, nie handluję"), + ("not trading now, busy", "teraz nie handluję, jestem zajęty"), + ("nothing to sell, sry", "nie mam nic na sprzedaż, sry")); + Add( + configuration.DuelDeclineLines, + ("not now, maybe later", "nie teraz, może później"), + ("no duels today, sry", "dziś bez duelów, sry"), + ("haha no, i'm here for exp", "haha nie, jestem tu dla expa")); Add( configuration.PartyVolunteerLines, ("me? {level} {class}", "ja? {level} {class}"), diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index dc45d48c07..9d6dccd230 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MiniGames; using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.Duel; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; @@ -51,6 +52,8 @@ public class BotChatPlugIn IChatMessageSentPlugIn, IPeriodicTaskPlugIn, IPostMessageSentPlugIn, + ITradeRequestedPlugIn, + IDuelRequestedPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -409,6 +412,35 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) /// public void ForceStart() => Interlocked.Increment(ref this._forceStartCount); + /// + public ValueTask TradeRequestedAsync(Player requester, Player partner) + { + // The bot has no trade window, so the request goes nowhere - the bot says why. + if (this.Configuration is { } configuration + && IsBot(partner) + && requester is not OfflinePlayer) + { + this.DeclineLater(partner, requester, configuration, configuration.TradeDeclineLines, null); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DuelRequestedAsync(Player requester, Player target) + { + // A bot never answered a duel request, which left the challenger waiting in vain: it refuses, + // like a player clicking "no", and says why. + if (this.Configuration is { } configuration + && IsBot(target) + && requester is not OfflinePlayer) + { + this.DeclineLater(target, requester, configuration, configuration.DuelDeclineLines, () => RefuseDuelAsync(target, requester)); + } + + return ValueTask.CompletedTask; + } + /// public async ValueTask PostMessageSentAsync(Player sender, string message) { @@ -707,6 +739,15 @@ private static string GetInvasionName(IPeriodicTaskPlugIn invasion, CultureInfo : PlugInResources.ResourceManager.GetString(displayName, culture) ?? displayName; } + /// + /// Refuses the duel the player challenged the bot to - unless the challenge is already over, e.g. + /// the player took it back meanwhile. + /// + private static ValueTask RefuseDuelAsync(Player bot, Player requester) + => bot.DuelRoom is { State: DuelState.DuelRequested } duelRoom && ReferenceEquals(duelRoom.Requester, requester) + ? new DuelActions().HandleDuelResponseAsync(bot, requester, false) + : ValueTask.CompletedTask; + private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); /// @@ -799,6 +840,33 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess } } + /// + /// Declines a player's request after a human-like moment: runs the refusal, if any, and whispers + /// one of the lines - by chance, and not more often than the whisper reply cooldown allows. + /// + private void DeclineLater(Player bot, Player requester, BotChatConfiguration configuration, ICollection lines, Func? refuse) + { + var whisper = configuration.DeclineReplyChancePercent > 0 + && Rand.NextRandomBool(configuration.DeclineReplyChancePercent) + && this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown); + var line = whisper ? this.PickLineFor(requester, lines) : null; + var values = CreateBotValues(bot); + var requesterName = requester.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => requesterName; + RunLater(bot.GameContext, line is null ? RandomDelay(TypingReactionSeconds) : TypingDelay(line.Text), async () => + { + if (refuse is not null) + { + await refuse().ConfigureAwait(false); + } + + if (line is not null && requester.PlayerState.CurrentState == PlayerState.EnteredWorld) + { + await BotChatSpeaker.WhisperAsync(bot, requester, line.Text, values).ConfigureAwait(false); + } + }); + } + private void WhisperLater(Player bot, Player receiver, BotChatLine line, Dictionary> values) => RunLater( bot.GameContext, diff --git a/src/GameLogic/PlayerActions/Duel/DuelActions.cs b/src/GameLogic/PlayerActions/Duel/DuelActions.cs index 8ea1f35e95..6bc06d6af2 100644 --- a/src/GameLogic/PlayerActions/Duel/DuelActions.cs +++ b/src/GameLogic/PlayerActions/Duel/DuelActions.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Duel; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.GuildWar; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.Views.Duel; @@ -53,6 +54,10 @@ public async ValueTask HandleDuelRequestAsync(Player player, Player target) target.DuelRoom = duelRoom; await target.InvokeViewPlugInAsync(p => p.ShowDuelRequestAsync(player)).ConfigureAwait(false); + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.DuelRequestedAsync(player, target).ConfigureAwait(false); + } } /// diff --git a/src/GameLogic/PlayerActions/Trade/TradeRequestAction.cs b/src/GameLogic/PlayerActions/Trade/TradeRequestAction.cs index b9b00ca429..c42a0e3ef8 100644 --- a/src/GameLogic/PlayerActions/Trade/TradeRequestAction.cs +++ b/src/GameLogic/PlayerActions/Trade/TradeRequestAction.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Trade; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.Trade; /// @@ -25,6 +26,12 @@ public async ValueTask RequestTradeAsync(ITrader player, ITrader partner) return false; } + if (player is Player requester && partner is Player requested + && requester.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.TradeRequestedAsync(requester, requested).ConfigureAwait(false); + } + if (player.ViewPlugIns.GetPlugIn() is null || partner.ViewPlugIns.GetPlugIn() is null) { return false; diff --git a/src/GameLogic/PlugIns/IDuelRequestedPlugIn.cs b/src/GameLogic/PlugIns/IDuelRequestedPlugIn.cs new file mode 100644 index 0000000000..45d370d5dc --- /dev/null +++ b/src/GameLogic/PlugIns/IDuelRequestedPlugIn.cs @@ -0,0 +1,25 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called after a player challenged another player to a duel, once the +/// request was handed to the challenged player. +/// +[Guid("C7A76A9D-1B71-493F-A2C1-C434299E910D")] +[PlugInPoint("Duel requested", "Plugins which are called after a player challenged another player to a duel.")] +public interface IDuelRequestedPlugIn +{ + /// + /// Is called after the player challenged the target to a duel. + /// + /// The challenging player. + /// The challenged player, who is asked to accept or refuse. + /// The task. + ValueTask DuelRequestedAsync(Player requester, Player target); +} diff --git a/src/GameLogic/PlugIns/ITradeRequestedPlugIn.cs b/src/GameLogic/PlugIns/ITradeRequestedPlugIn.cs new file mode 100644 index 0000000000..a9de26ad4a --- /dev/null +++ b/src/GameLogic/PlugIns/ITradeRequestedPlugIn.cs @@ -0,0 +1,28 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a player asks another player for a trade. +/// +/// +/// It's called before the request is checked or shown, so it also sees the requests which can't +/// be delivered - e.g. to a server-side bot, which has no trade window. +/// +[Guid("CF5C02DA-A5D3-4587-8EE9-B7F03217F19D")] +[PlugInPoint("Trade requested", "Plugins which are called when a player asks another player for a trade.")] +public interface ITradeRequestedPlugIn +{ + /// + /// Is called when the player asks the partner for a trade. + /// + /// The asking player. + /// The asked player. + /// The task. + ValueTask TradeRequestedAsync(Player requester, Player partner); +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 368428889e..87f4d0327b 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5418,5 +5418,23 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatLine_When_Description text. public static string BotChatLine_When_Description => ResourceManager.GetString("BotChatLine_When_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_DeclineReplyChancePercent_Name text. + public static string BotChatConfiguration_DeclineReplyChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_DeclineReplyChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_DeclineReplyChancePercent_Description text. + public static string BotChatConfiguration_DeclineReplyChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_DeclineReplyChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_TradeDeclineLines_Name text. + public static string BotChatConfiguration_TradeDeclineLines_Name => ResourceManager.GetString("BotChatConfiguration_TradeDeclineLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_TradeDeclineLines_Description text. + public static string BotChatConfiguration_TradeDeclineLines_Description => ResourceManager.GetString("BotChatConfiguration_TradeDeclineLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_DuelDeclineLines_Name text. + public static string BotChatConfiguration_DuelDeclineLines_Name => ResourceManager.GetString("BotChatConfiguration_DuelDeclineLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_DuelDeclineLines_Description text. + public static string BotChatConfiguration_DuelDeclineLines_Description => ResourceManager.GetString("BotChatConfiguration_DuelDeclineLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index a81b964f9d..fd3a83ac89 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1999,4 +1999,10 @@ Said by the nearest bot when a player made a reset. Placeholders: {player}, {level} and {resets} of the player, {map}. When When it may be said, by the local time of the server: Anytime, Morning (5-11), Day (11-18), Evening (18-23) or Night (23-5). + Trade/duel refusal: whisper chance (%) + Chance that a bot says why it turns down a trade or duel request, by whisper. It turns them down in any case: a bot has no trade window and doesn't duel. + Trade refused + Whispered when a player asks the bot for a trade. Placeholders: {player}, {level}, {map}, {location}. + Duel refused + Whispered when a player challenges the bot to a duel. Placeholders: {player}, {level}, {map}, {location}. From 874f487e21e0897451fbb5f760babc00f7026c4e Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:23:43 +0100 Subject: [PATCH 20/44] feat(bots): keep quiet while the players around are talking When a few players stand together and chat, a bot chiming in with a conversation or a "gz" drowns them out. Now a bot says nothing unasked - conversations, greetings, congratulations, merchant adverts, level and death comments - while the players near it are busy talking: three normal chat messages within 30 seconds by default, both configurable. Answers to a player who talks to the bot stay unaffected. --- docs-website/docs/server-features/bots.md | 5 ++ src/GameLogic/Bots/BotChatConfiguration.cs | 15 ++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 27 +++++++++-- src/GameLogic/Bots/BotChatThrottle.cs | 46 +++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 12 +++++ src/GameLogic/Properties/PlugInResources.resx | 4 ++ .../BotChatGreetingTest.cs | 25 ++++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 2064cf8825..95186d031b 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -416,6 +416,11 @@ A bot answers like a typing player: it takes a moment to read, then a little time per character, so a quick "np" comes at once and a whole sentence takes a few seconds (at most twelve). +The bots don't drown out the players: while the players around a bot are busy +talking (three normal chat messages within 30 seconds, by default), it says +nothing unasked — no conversation, greeting, congratulation or comment. It +still answers a player who talks to it. + The limits keep a large population from flooding the chat. All bots of a game server share one gap between two posts (`Minimum time between posts`, 3 minutes), each opened event type or started invasion is announced at most once, and each diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 8e6adb8fb0..a7f9a96a1e 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -130,6 +130,21 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_DeclineReplyChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_DeclineReplyChancePercent_Description))] public int DeclineReplyChancePercent { get; set; } = 70; + /// + /// Gets or sets from how many normal chat messages of human players near a bot the bot keeps + /// quiet - it says nothing unasked (conversations, greetings, congratulations, comments) while the + /// players around it are busy talking; 0 switches it off. Answers to a player stay unaffected. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_BusyChatMessageCount_Name), Description = nameof(PlugInResources.BotChatConfiguration_BusyChatMessageCount_Description))] + public int BusyChatMessageCount { get; set; } = 3; + + /// + /// Gets or sets how long a message of a human player counts for . + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_BusyChatWindow_Name), Description = nameof(PlugInResources.BotChatConfiguration_BusyChatWindow_Description))] + public TimeSpan BusyChatWindow { get; set; } = TimeSpan.FromSeconds(30); + /// /// Gets or sets how many bots answer a player's party search posted with /post, e.g. /// "party 300-350 bk" (see ); 0 switches it off. The same search diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 9d6dccd230..1f56c149e1 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -209,7 +209,7 @@ public void CharacterLeveledUp(Player player) RunLater( player.GameContext, RandomDelay(LevelMilestoneDelaySeconds), - () => IsAvailable(player) + () => IsAvailable(player) && this.MayTalkUnasked(player, configuration) ? BotChatSpeaker.SayAsync(player, line.Text, CreateBotValues(player)) : ValueTask.CompletedTask); } @@ -338,7 +338,8 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) { // Read after the delay: the revenge plugin registers the death in the same notification. var lines = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? configuration.KilledAgainLines : configuration.KilledLines; - if (this.PickLineFor(killerPlayer, lines) is not { } line) + if (!this.MayTalkUnasked(bot, configuration) + || this.PickLineFor(killerPlayer, lines) is not { } line) { return ValueTask.CompletedTask; } @@ -354,6 +355,14 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) /// public async ValueTask ChatMessageSentAsync(Player sender, string message, ChatMessageType messageType, Player? receiver) { + if (this.Configuration is { BusyChatMessageCount: > 0 } busyConfiguration + && messageType == ChatMessageType.Normal + && sender is not OfflinePlayer + && sender.CurrentMap is { } senderMap) + { + this.GetThrottle(sender.GameContext).RecordHumanChat(senderMap, sender.Position, DateTime.UtcNow, busyConfiguration.BusyChatWindow); + } + if (this.Configuration is not { WhisperReplyChancePercent: > 0 } configuration || messageType != ChatMessageType.Normal || sender is OfflinePlayer @@ -750,6 +759,14 @@ private static ValueTask RefuseDuelAsync(Player bot, Player requester) private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + /// + /// Determines whether the bot may say something nobody asked for in the normal chat: not while + /// the human players around it are busy talking - the bots shouldn't drown them out. + /// + private bool MayTalkUnasked(Player bot, BotChatConfiguration configuration) + => bot.CurrentMap is not { } map + || !this.GetThrottle(bot.GameContext).IsHumanChatBusy(map, bot.Position, HearingRange, DateTime.UtcNow, configuration.BusyChatWindow, configuration.BusyChatMessageCount); + /// /// Picks a line said to the player, other than the one of this list the player got last time. /// @@ -902,7 +919,8 @@ private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatCo } var speaker = candidates[Rand.NextInt(0, candidates.Count)]; - if (this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) + if (this.MayTalkUnasked(speaker, configuration) + && this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) { await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker)).ConfigureAwait(false); } @@ -947,6 +965,7 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC && !string.IsNullOrWhiteSpace(c.Reply.ValueInNeutralLanguage)) .ToList(); if (!Rand.NextRandomBool(configuration.ConversationChancePercent) + || !this.MayTalkUnasked(pair.First, configuration) || !throttle.TryReserveChat(pair.First, now, configuration.BotChatCooldown) || !throttle.TryReserveChat(pair.Second, now, configuration.BotChatCooldown) || throttle.DrawConversation(candidates, count => Rand.NextInt(0, count)) is not { } conversation) @@ -1019,6 +1038,7 @@ private void Congratulate(Player player, BotChatConfiguration configuration, ICo var throttle = this.GetThrottle(player.GameContext); var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); if (FindGreeter(players, player, map) is not { } bot + || !this.MayTalkUnasked(bot, configuration) || this.PickLineFor(player, lines) is not { } line || !throttle.TryReserveOccasion(occasionKey, now, configuration.CongratulationCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) @@ -1048,6 +1068,7 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura var now = DateTime.UtcNow; var throttle = this.GetThrottle(player.GameContext); if (bot is null + || !this.MayTalkUnasked(bot, configuration) || this.PickLineFor(player, configuration.GreetingLines) is not { } line || !throttle.TryReserveOccasion($"greeting {playerName}", now, configuration.GreetingCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 7261ad13ca..47fb993be1 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading; +using MUnique.OpenMU.Pathfinding; /// /// The chat limits and the short-term memory of the bots of one game server: the server-wide gap @@ -58,6 +59,8 @@ internal sealed class BotChatThrottle private readonly ConditionalWeakTable> _lastLines = new(); + private readonly List<(GameMap Map, Point Position, DateTime Time)> _humanChat = []; + private long _nextPlayerScanTicks; private long _nextPostTicks; @@ -329,6 +332,49 @@ public void SetLastLine(Player player, object list, object line) } } + /// + /// Remembers that a human player said something in the normal chat. + /// + /// The map of the player. + /// The position of the player. + /// The current time. + /// How long a message counts; older ones are forgotten. + public void RecordHumanChat(GameMap map, Point position, DateTime now, TimeSpan window) + { + lock (this._humanChat) + { + this._humanChat.RemoveAll(message => now - message.Time > window); + this._humanChat.Add((map, position, now)); + } + } + + /// + /// Determines whether the human players near a place are busy talking: at least the given number + /// of their normal chat messages were said within the window and the range. + /// + /// The map of the place. + /// The position of the place. + /// The range around the place. + /// The current time. + /// How long a message counts. + /// The number of messages from which the chat is busy; 0 never. + /// true, if the players near the place are busy talking. + public bool IsHumanChatBusy(GameMap map, Point position, double range, DateTime now, TimeSpan window, int count) + { + if (count <= 0) + { + return false; + } + + lock (this._humanChat) + { + return this._humanChat.Count(message => + ReferenceEquals(message.Map, map) + && now - message.Time <= window + && message.Position.EuclideanDistanceTo(position) <= range) >= count; + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 87f4d0327b..6467f152c5 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5436,5 +5436,17 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_DuelDeclineLines_Description text. public static string BotChatConfiguration_DuelDeclineLines_Description => ResourceManager.GetString("BotChatConfiguration_DuelDeclineLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_BusyChatMessageCount_Name text. + public static string BotChatConfiguration_BusyChatMessageCount_Name => ResourceManager.GetString("BotChatConfiguration_BusyChatMessageCount_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_BusyChatMessageCount_Description text. + public static string BotChatConfiguration_BusyChatMessageCount_Description => ResourceManager.GetString("BotChatConfiguration_BusyChatMessageCount_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_BusyChatWindow_Name text. + public static string BotChatConfiguration_BusyChatWindow_Name => ResourceManager.GetString("BotChatConfiguration_BusyChatWindow_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_BusyChatWindow_Description text. + public static string BotChatConfiguration_BusyChatWindow_Description => ResourceManager.GetString("BotChatConfiguration_BusyChatWindow_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index fd3a83ac89..f3958111b9 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2005,4 +2005,8 @@ Whispered when a player asks the bot for a trade. Placeholders: {player}, {level}, {map}, {location}. Duel refused Whispered when a player challenges the bot to a duel. Placeholders: {player}, {level}, {map}, {location}. + Quiet when players talk: messages + From how many normal chat messages of players near a bot (within the time below) the bot says nothing unasked - no conversations, greetings, congratulations or comments. Answers to a player stay. 0 = off. + Quiet when players talk: time + How long a message of a player counts for the setting above. diff --git a/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs index 56f8f8d98a..edbef97d93 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs @@ -73,4 +73,29 @@ private static async ValueTask CreateBotAsync(IGameContext gameCo bot.Position = position; return bot; } + + /// + /// The bots keep quiet near players who are busy talking: from the configured number of their + /// messages within the window and the range - not for older or distant messages, nor on another map. + /// + [Test] + public void NoticesPlayersBusyTalking() + { + var map = new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8); + var otherMap = new GameMap(new GameMapDefinition(), TimeSpan.FromMinutes(1), 8); + var throttle = new BotChatThrottle(); + var start = new DateTime(2026, 11, 4, 20, 0, 0, DateTimeKind.Utc); + var window = TimeSpan.FromSeconds(30); + var here = new Point(100, 100); + + throttle.RecordHumanChat(map, here, start, window); + throttle.RecordHumanChat(map, new Point(103, 100), start.AddSeconds(5), window); + throttle.RecordHumanChat(otherMap, here, start.AddSeconds(6), window); + throttle.RecordHumanChat(map, new Point(150, 150), start.AddSeconds(7), window); + + Assert.That(throttle.IsHumanChatBusy(map, here, 12, start.AddSeconds(10), window, 2), Is.True); + Assert.That(throttle.IsHumanChatBusy(map, here, 12, start.AddSeconds(10), window, 3), Is.False); + Assert.That(throttle.IsHumanChatBusy(map, here, 12, start.AddSeconds(32), window, 2), Is.False); + Assert.That(throttle.IsHumanChatBusy(map, here, 12, start.AddSeconds(10), window, 0), Is.False); + } } From d29b033a1870cd4507e779c66e7c3d323e6cf17c Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 18:24:38 +0100 Subject: [PATCH 21/44] feat(admin-panel): edit translations in the server's content languages The editor of a translatable text showed a field for each language of the admin panel's own user interface only - English, German and Chinese. A server for Polish players could neither see nor edit the Polish translations: the Polish default lines of the bot chat were there, but invisible. The editor now also shows every language a text already has a translation in, and the additional content languages of the new AdminPanel:ContentLanguages setting (e.g. AdminPanel__ContentLanguages__0=pl), so a new text gets a field for them too. The admin panel itself isn't translated by this. The Docker images install icu-data-full as well: with the English culture data alone, which icu-libs brings on Alpine, the server doesn't know a culture like "pl", and a Polish player gets the English texts. This is required for the bots to answer Polish players in Polish, together with the player's account language set to "pl". --- docs-website/docs/server-features/bots.md | 17 +++++++ src/Dapr/AdminPanel.Host/Dockerfile | 4 +- src/Dapr/GameServer.Host/Dockerfile | 4 +- src/Startup/Dockerfile | 4 +- .../AdminPanel/WebApplicationExtensions.cs | 4 +- .../Form/LocalizedStringField.razor | 44 ++++++++++++++++++- src/Web/Shared/ContentLanguageOptions.cs | 23 ++++++++++ 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/Web/Shared/ContentLanguageOptions.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 95186d031b..90b59e34f1 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -407,6 +407,23 @@ every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. +For the Polish lines to reach a Polish player, three things are needed: + +1. **The player's language is set to `pl`** — the *Language Iso Code* of the + player's account in the admin panel. The game client doesn't tell the server + its language, and the `/language` command only offers the languages of the + server messages. +2. **The server has the full culture data.** The Docker images install + `icu-data-full`; with the English culture data alone (`icu-libs` on Alpine), + the server doesn't know the `pl` culture and every player gets the English + lines. A self-built image needs the package too. +3. **The admin panel shows a field for Polish**, to read and edit the + translations. It shows the languages of its own user interface, every + language a text already has a translation in, and the additional content + languages of the `AdminPanel:ContentLanguages` setting — e.g. the environment + variable `AdminPanel__ContentLanguages__0=pl`. Without the setting, the + Polish default lines are still shown, but a new line gets no Polish field. + Every line and conversation has a `When`: `Anytime` (the default), `Morning` (5–11 o'clock), `Day` (11–18), `Evening` (18–23) or `Night` (23–5), by the local time of the server. A greeting may be "good morning" in the morning and "late diff --git a/src/Dapr/AdminPanel.Host/Dockerfile b/src/Dapr/AdminPanel.Host/Dockerfile index 29e0d1c2f2..565bb43424 100644 --- a/src/Dapr/AdminPanel.Host/Dockerfile +++ b/src/Dapr/AdminPanel.Host/Dockerfile @@ -4,7 +4,9 @@ WORKDIR /app # OpenMU uses localized texts, which require the culture data of ICU. # The alpine images don't contain it and run in the globalization-invariant mode by default. # tzdata provides the IANA time zones (e.g. Europe/Berlin), which the alpine images don't contain either. -RUN apk add --no-cache icu-libs tzdata +# icu-data-full: icu-libs alone brings the English culture data only, so a player's language like +# "pl" isn't found and every player gets the English texts. +RUN apk add --no-cache icu-libs icu-data-full tzdata ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 8080 diff --git a/src/Dapr/GameServer.Host/Dockerfile b/src/Dapr/GameServer.Host/Dockerfile index 63581e6f25..de8b10c913 100644 --- a/src/Dapr/GameServer.Host/Dockerfile +++ b/src/Dapr/GameServer.Host/Dockerfile @@ -4,7 +4,9 @@ WORKDIR /app # OpenMU uses localized texts, which require the culture data of ICU. # The alpine images don't contain it and run in the globalization-invariant mode by default. # tzdata provides the IANA time zones (e.g. Europe/Berlin), which the alpine images don't contain either. -RUN apk add --no-cache icu-libs tzdata +# icu-data-full: icu-libs alone brings the English culture data only, so a player's language like +# "pl" isn't found and every player gets the English texts. +RUN apk add --no-cache icu-libs icu-data-full tzdata ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 8080 diff --git a/src/Startup/Dockerfile b/src/Startup/Dockerfile index f58fe76da9..472580733b 100644 --- a/src/Startup/Dockerfile +++ b/src/Startup/Dockerfile @@ -2,7 +2,9 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app # tzdata provides the IANA time zones (e.g. America/Argentina/Buenos_Aires) for the server time zone setting. -RUN apk add --no-cache icu-libs tzdata +# icu-data-full: icu-libs alone brings the English culture data only, so a player's language like +# "pl" isn't found and every player gets the English texts. +RUN apk add --no-cache icu-libs icu-data-full tzdata ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 8080 diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index eabb6595df..1744aa0349 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -25,6 +25,7 @@ namespace MUnique.OpenMU.Web.AdminPanel; using MUnique.OpenMU.Web.AdminPanel.Components; using MUnique.OpenMU.Web.AdminPanel.Operations; using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components.Modal; using MUnique.OpenMU.Web.Shared.Models; using MUnique.OpenMU.Web.Shared.Services; @@ -59,7 +60,8 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui { o.AddSupportedCultures(supportedCultures); o.AddSupportedUICultures(supportedCultures); - }); + }) + .Configure(builder.Configuration.GetSection("AdminPanel")); services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/src/Web/Shared/Components/Form/LocalizedStringField.razor b/src/Web/Shared/Components/Form/LocalizedStringField.razor index f167465148..60c1e0667e 100644 --- a/src/Web/Shared/Components/Form/LocalizedStringField.razor +++ b/src/Web/Shared/Components/Form/LocalizedStringField.razor @@ -7,11 +7,12 @@ @inherits NotifyableInputBase @inject IOptions LocalizationOptions +@inject IOptions ContentLanguageOptions
- @foreach (var culture in this.LocalizationOptions.Value.SupportedUICultures ?? []) + @foreach (var culture in this.GetCultures()) {
+ /// Gets the languages to show a field for: the languages of the admin panel, the configured + /// content languages (see ), and + /// every language the text already has a translation in - so no translation is hidden. + ///
+ private List GetCultures() + { + var cultures = new List(this.LocalizationOptions.Value.SupportedUICultures ?? []); + foreach (var name in this.ContentLanguageOptions.Value.ContentLanguages ?? []) + { + AddCulture(name); + } + + if (this.Value.Value is not null) + { + foreach (var translation in this.Value.GetTranslations()) + { + AddCulture(translation.Key); + } + } + + return cultures; + + void AddCulture(string name) + { + if (string.IsNullOrWhiteSpace(name) || cultures.Any(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + try + { + cultures.Add(CultureInfo.GetCultureInfo(name)); + } + catch (CultureNotFoundException) + { + // Unknown to the culture data of this host (e.g. a container without the full ICU data). + } + } + } + private string GetValueForCulture(CultureInfo culture) { if (this.Value.Value is null) diff --git a/src/Web/Shared/ContentLanguageOptions.cs b/src/Web/Shared/ContentLanguageOptions.cs new file mode 100644 index 0000000000..74b9f08df9 --- /dev/null +++ b/src/Web/Shared/ContentLanguageOptions.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared; + +/// +/// The languages in which the translatable texts of the configuration (map names, item names, the +/// lines of the bots, ...) can be edited, in addition to the languages of the admin panel itself. +/// +/// +/// The editor of a translatable text shows a field for every language of the admin panel's user +/// interface. A server whose players speak another language - e.g. Polish - needs a field for it, +/// although the admin panel isn't translated to it. Set in the configuration section +/// AdminPanel, e.g. by the environment variable AdminPanel__ContentLanguages__0=pl. +/// +public class ContentLanguageOptions +{ + /// + /// Gets or sets the codes of the additional languages, e.g. pl. + /// + public string[] ContentLanguages { get; set; } = []; +} From b1547b0ade4baeab3e51a789c5fcf28933c8e9b2 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 19:49:39 +0100 Subject: [PATCH 22/44] feat(admin-panel): start the lists of the bot chat collapsed A list in a configuration form starts collapsed only when it holds more than ten items. The bot chat configuration has some twenty short lists, so most of them showed all their cards at once - one endless page instead of a list of sections to open one by one. A collection property can now be marked with [CollapsedByDefault] to start collapsed whatever its size; empty lists still show, so a first item can be created. The lists of the bot chat configuration use it. --- .../CollapsedByDefaultAttribute.cs | 17 ++++++++++++++++ src/GameLogic/Bots/BotChatConfiguration.cs | 20 +++++++++++++++++++ .../Shared/Components/Form/ItemTable.razor.cs | 3 ++- src/Web/Shared/ExpressionExtensions.cs | 12 +++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/DataModel/Composition/CollapsedByDefaultAttribute.cs diff --git a/src/DataModel/Composition/CollapsedByDefaultAttribute.cs b/src/DataModel/Composition/CollapsedByDefaultAttribute.cs new file mode 100644 index 0000000000..f158515729 --- /dev/null +++ b/src/DataModel/Composition/CollapsedByDefaultAttribute.cs @@ -0,0 +1,17 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.DataModel.Composition; + +/// +/// Marks a collection property to be shown collapsed on the UI at first, whatever the number of its +/// items - for a configuration with many short lists, which would otherwise make one endless page. +/// +/// +/// Without the attribute, a list starts collapsed only when it holds more than a few items. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class CollapsedByDefaultAttribute : Attribute +{ +} diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index a7f9a96a1e..2ff639c363 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -228,6 +228,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventLines_Description))] public ICollection EventLines { get; set; } = new List(); @@ -237,6 +238,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_InvasionLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_InvasionLines_Description))] public ICollection InvasionLines { get; set; } = new List(); @@ -246,6 +248,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_LevelMilestoneLines_Description))] public ICollection LevelMilestoneLines { get; set; } = new List(); @@ -256,6 +259,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Description))] public ICollection WhisperReplies { get; set; } = new List(); @@ -265,6 +269,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Description))] public ICollection GreetingLines { get; set; } = new List(); @@ -274,6 +279,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyJoinedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyJoinedLines_Description))] public ICollection PartyJoinedLines { get; set; } = new List(); @@ -283,6 +289,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventFinishedLines_Description))] public ICollection EventFinishedLines { get; set; } = new List(); @@ -292,6 +299,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationLines_Description))] public ICollection CongratulationLines { get; set; } = new List(); @@ -301,6 +309,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Description))] public ICollection ResetCongratulationLines { get; set; } = new List(); @@ -310,6 +319,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_TradeDeclineLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_TradeDeclineLines_Description))] public ICollection TradeDeclineLines { get; set; } = new List(); @@ -319,6 +329,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_DuelDeclineLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_DuelDeclineLines_Description))] public ICollection DuelDeclineLines { get; set; } = new List(); @@ -328,6 +339,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyVolunteerLines_Description))] public ICollection PartyVolunteerLines { get; set; } = new List(); @@ -337,6 +349,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyAskWhereLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyAskWhereLines_Description))] public ICollection PartyAskWhereLines { get; set; } = new List(); @@ -346,6 +359,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyOnTheWayLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyOnTheWayLines_Description))] public ICollection PartyOnTheWayLines { get; set; } = new List(); @@ -356,6 +370,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyCantGoLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyCantGoLines_Description))] public ICollection PartyCantGoLines { get; set; } = new List(); @@ -364,6 +379,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyClassAliases_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyClassAliases_Description))] public ICollection PartyClassAliases { get; set; } = new List(); @@ -373,6 +389,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledLines_Description))] public ICollection KilledLines { get; set; } = new List(); @@ -383,6 +400,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_KilledAgainLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_KilledAgainLines_Description))] public ICollection KilledAgainLines { get; set; } = new List(); @@ -393,6 +411,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantLines_Description))] public ICollection MerchantLines { get; set; } = new List(); @@ -404,6 +423,7 @@ public class BotChatConfiguration ///
[MemberOfAggregate] [ScaffoldColumn(true)] + [CollapsedByDefault] [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_Conversations_Name), Description = nameof(PlugInResources.BotChatConfiguration_Conversations_Description))] public ICollection Conversations { get; set; } = new List(); diff --git a/src/Web/Shared/Components/Form/ItemTable.razor.cs b/src/Web/Shared/Components/Form/ItemTable.razor.cs index 6794b451c2..d6ba672c56 100644 --- a/src/Web/Shared/Components/Form/ItemTable.razor.cs +++ b/src/Web/Shared/Components/Form/ItemTable.razor.cs @@ -93,7 +93,8 @@ protected override void OnInitialized() this._isInlineEditable = this._isEditable && isMemberOfAggregate && this.ValueExpression!.ScaffoldColumn(); this._isAddingSupported = !isMemberOfAggregate; this._isCreatingSupported = isMemberOfAggregate; - this._isStartingCollapsed = this.Value is not null && this.Value.Count > this._searchThreshold; + this._isStartingCollapsed = this.Value is { Count: > 0 } value + && (value.Count > this._searchThreshold || this.ValueExpression!.IsCollapsedByDefault()); this._isCollapsed = this._isStartingCollapsed; } diff --git a/src/Web/Shared/ExpressionExtensions.cs b/src/Web/Shared/ExpressionExtensions.cs index bd5e051005..51ef6fe013 100644 --- a/src/Web/Shared/ExpressionExtensions.cs +++ b/src/Web/Shared/ExpressionExtensions.cs @@ -47,6 +47,18 @@ public static bool IsAccessToMemberOfAggregate(this Expression + /// Determines whether the accessed member is marked with . + ///
+ /// The type of the property. + /// The expression. + /// + /// true if the accessed member is marked with ; otherwise, false. + /// + public static bool IsCollapsedByDefault(this Expression> expression) + => expression.Body is MemberExpression memberExpression + && memberExpression.Member.GetCustomAttribute() is { }; + /// /// Determines whether the accessed member is marked with . /// From 5ae6797b4a534cd056202aeb5df607d18880ff4c Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 20:44:44 +0100 Subject: [PATCH 23/44] feat(bots): answer in the party chat A bot in a player's party ignored the party chat entirely: "~Karanwyn what level are you?" got no answer, although the same question as a whisper or in the normal chat did. The bot whose name a party message mentions now answers it in the party chat, with the whisper replies. Without a name, one bot of the party answers - but only a message which matches a keyword, so "ok" or "let's go" don't get a reply each time. A bot inside an event with the party answers as well. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatComposer.cs | 8 ++++- src/GameLogic/Bots/BotChatPlugIn.cs | 44 +++++++++++++++++++++-- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 18 ++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 90b59e34f1..a4fa4a412c 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -349,6 +349,7 @@ only talks about what it could actually do: | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | | A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot | +| A player wrote in the party chat (`~`) | party chat, the same lines as whisper replies | the bot whose name is in the message; without a name, one bot of the party - only when a keyword matches | | Now and then, like a merchant advertising (off by default — meant for a future merchant bot) | normal chat, `{location}` | a bot standing in a safezone which a player sees | | A player stands near two bots (every 5 minutes per player) | normal chat: one bot says something, the other answers | two bots close together, both in a town or both out hunting | | A player asked the bot for a trade or challenged it to a duel | whisper, 70 % chance, `{player}` — the bot turns it down in any case (no trade window, no duels) | the bot | diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 69c352a058..56a1d274ed 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -48,12 +48,13 @@ internal static bool FitsTimeOfDay(BotChatTimeOfDay when, BotChatTimeOfDay now) /// The player's message. /// Returns a random index below the given count. /// The reply given last time to the same player, which is left out when there are others. + /// If set, a message without a matching keyword gets no reply, not even a fallback. /// The picked reply, or null when there is no usable reply. /// /// The best match is the longest matching keyword, so "hi, how are you?" is answered by the /// "how are you" replies rather than by the greetings matching "hi". /// - internal static BotChatReply? PickWhisperReply(IEnumerable replies, string message, Func pickIndex, object? previous = null) + internal static BotChatReply? PickWhisperReply(IEnumerable replies, string message, Func pickIndex, object? previous = null, bool requireKeyword = false) { var usable = replies .Where(reply => !string.IsNullOrWhiteSpace(reply.Text.ValueInNeutralLanguage)) @@ -63,6 +64,11 @@ internal static bool FitsTimeOfDay(BotChatTimeOfDay when, BotChatTimeOfDay now) .Select(reply => (Reply: reply, Score: GetKeywords(reply.Keywords).Where(k => normalizedMessage.Contains($" {k} ", StringComparison.Ordinal)).Select(k => k.Length).DefaultIfEmpty(0).Max())) .ToList(); var bestScore = scored.Count == 0 ? 0 : scored.Max(s => s.Score); + if (bestScore == 0 && requireKeyword) + { + return null; + } + var candidates = WithoutPrevious( bestScore > 0 ? scored.Where(s => s.Score == bestScore).Select(s => s.Reply).ToList() diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 1f56c149e1..db56a4723a 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -364,13 +364,19 @@ public async ValueTask ChatMessageSentAsync(Player sender, string message, ChatM } if (this.Configuration is not { WhisperReplyChancePercent: > 0 } configuration - || messageType != ChatMessageType.Normal + || messageType is not (ChatMessageType.Normal or ChatMessageType.Party) || sender is OfflinePlayer || sender.SelectedCharacter?.Name is not { } senderName) { return; } + if (messageType == ChatMessageType.Party) + { + this.AnswerInParty(sender, senderName, message, configuration); + return; + } + // Only a bot which sees the speaker (and so got the message) may feel addressed. var mentionedBots = new List(); await sender.ForEachObservingAsync( @@ -785,10 +791,10 @@ private bool MayTalkUnasked(Player bot, BotChatConfiguration configuration) /// /// Picks the reply to the player's message, other than the reply the player got last time. /// - private BotChatReply? PickReplyFor(Player listener, ICollection replies, string message) + private BotChatReply? PickReplyFor(Player listener, ICollection replies, string message, bool requireKeyword = false) { var throttle = this.GetThrottle(listener.GameContext); - var reply = BotChatComposer.PickWhisperReply(replies, message, count => Rand.NextInt(0, count), throttle.GetLastLine(listener, replies)); + var reply = BotChatComposer.PickWhisperReply(replies, message, count => Rand.NextInt(0, count), throttle.GetLastLine(listener, replies), requireKeyword); if (reply is not null) { throttle.SetLastLine(listener, replies, reply); @@ -857,6 +863,38 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess } } + /// + /// Answers a player's message in the party chat: the bot of the party whose name the message + /// mentions answers like to a whisper; without a name, one bot of the party answers only a + /// message which matches a keyword ("what level are you?") - not every "ok" or "let's go". + /// + private void AnswerInParty(Player sender, string senderName, string message, BotChatConfiguration configuration) + { + if (sender.Party is not { } party) + { + return; + } + + // Also a bot inside an event with the party may answer - the party chat reaches it there. + var bots = party.PartyList + .OfType() + .Where(p => IsBot(p) && p.PlayerState.CurrentState == PlayerState.EnteredWorld) + .ToList(); + var mentioned = bots.FirstOrDefault(bot => bot.SelectedCharacter?.Name is { } botName && BotChatComposer.MentionsName(message, botName)); + var speaker = mentioned ?? (bots.Count > 0 ? bots[Rand.NextInt(0, bots.Count)] : null); + if (speaker is null + || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) + || this.PickReplyFor(sender, configuration.WhisperReplies, message, requireKeyword: mentioned is null) is not { } line + || !this.GetThrottle(speaker.GameContext).TryReserveWhisperReply(speaker, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + return; + } + + var values = CreateBotValues(speaker); + values["player"] = _ => senderName; + RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values)); + } + /// /// Declines a player's request after a human-like moment: runs the refusal, if any, and whispers /// one of the lines - by chance, and not more often than the whisper reply cooldown allows. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index e3b9326df6..673d2aa2a9 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -316,6 +316,24 @@ public void DoesNotRepeatTheLastAnswer() Assert.That(only, Is.SameAs(replies[2])); } + /// + /// In the party chat, a message without the bot's name is only answered when it matches a + /// keyword - an "ok" or "let's go" gets no fallback reply. + /// + [Test] + public void AnswersUnaddressedPartyMessagesOnlyByKeyword() + { + var replies = new List + { + new() { Text = "busy" }, + new() { Text = "lvl 300", Keywords = "level, lvl" }, + }; + + Assert.That(BotChatComposer.PickWhisperReply(replies, "ok lets go", _ => 0, requireKeyword: true), Is.Null); + Assert.That(BotChatComposer.PickWhisperReply(replies, "~what lvl are you?", _ => 0, requireKeyword: true)?.Text.ValueInNeutralLanguage, Is.EqualTo("lvl 300")); + Assert.That(BotChatComposer.PickWhisperReply(replies, "ok lets go", _ => 0)?.Text.ValueInNeutralLanguage, Is.EqualTo("busy")); + } + /// /// A bot feels addressed when its name is in a chat message as a whole word - not when it is only /// a part of a longer word. From 8beb35756f8b6046b86860a17841cd46ef44c3f3 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 20:45:46 +0100 Subject: [PATCH 24/44] feat(bots): understand more ways of saying goodbye MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "muszę kończyć" (I have to go) was answered with the fallback "sorry, busy hunting": the default goodbye keywords knew only "bye", "nara", "pa" and the like. They now cover the common English and Polish ways ("gtg", "gotta go", "spadam", "muszę kończyć" with and without diacritics), with two more fitting answers. --- src/GameLogic/Bots/BotChatConfiguration.cs | 6 ++++-- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 2ff639c363..67745b7bcb 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -581,9 +581,11 @@ public static BotChatConfiguration CreateDefault() ("np", "np"), ("no problem", "nie ma sprawy")); AddReplies( - "bye, cya, see you, gn, nara, pa, do zobaczenia, dobranoc", + "bye, cya, see you, see ya, gn, gtg, g2g, got to go, gotta go, have to go, must go, i'm off, im off, logging off, nara, pa, papa, do zobaczenia, dobranoc, na razie, musze konczyc, muszę kończyć, koncze, kończę, musze isc, muszę iść, spadam, zmykam, lece, lecę, do jutra", ("bye", "nara"), - ("cya", "na razie")); + ("cya", "na razie"), + ("ok, see you later", "ok, do później"), + ("take care", "trzymaj się")); return configuration; void Add(ICollection lines, params (string English, string Polish)[] texts) diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 673d2aa2a9..2cc284f11b 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -380,6 +380,24 @@ public void DefaultRepliesSpeakPolish(string message) Assert.That(text, Is.EqualTo("dobrze, dzięki że pytasz")); } + /// + /// The default replies understand a player saying goodbye in Polish and English, with and + /// without diacritics - "muszę kończyć" is not answered with "busy hunting". + /// + /// The whispered message. + [TestCase("musze konczyc")] + [TestCase("Muszę kończyć, nara")] + [TestCase("gtg, cya")] + [TestCase("ok spadam")] + public void DefaultRepliesUnderstandGoodbyes(string message) + { + var configuration = BotChatConfiguration.CreateDefault(); + + var reply = BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, _ => 0); + + Assert.That(reply?.Keywords, Does.Contain("musze konczyc")); + } + /// /// A server which switches the plugin on has something to say in every situation: each list of /// the default configuration has lines. From a76a6743557b83f4eeb21f369628805c25e37e51 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 20:53:54 +0100 Subject: [PATCH 25/44] feat(bots): give the bots a richer default vocabulary With three or four lines per situation and some eighty conversations, a player who spends an evening next to the bots hears them repeat themselves. The default configuration now has six to eight lines in most lists, 167 conversations (more for the town and for hunting, and more for the morning, day, evening and night), and the Polish translations read like a players' chat rather than a translation. The whisper replies understand more topics: the class, where to hunt, help, trade, and being asked whether it's a bot - which it answers with a wink, not a lie. --- docs-website/docs/server-features/bots.md | 6 +- src/GameLogic/Bots/BotChatConfiguration.cs | 180 +++++++++++++----- src/GameLogic/Bots/BotConversationDefaults.cs | 107 ++++++++++- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 4 +- 4 files changed, 241 insertions(+), 56 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index a4fa4a412c..722dfdf493 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -387,15 +387,15 @@ together do they talk — nobody else would hear it. Each conversation has a place: `Town` (both bots in a safezone), `Hunting` (both outside of it, usually fighting) or `Anywhere`. They are drawn without repetition until all of a place were used, and each player hears about one every five minutes. The default -configuration comes with some seventy of them. +configuration comes with some 170 of them. A whisper reply can carry `Keywords`, a comma-separated list of words or phrases it answers (`how are you, how r u`). The bot picks among the replies whose keyword matches the whisper — whole words, ignoring case and punctuation, the longest match winning, so "hi, how are you?" gets an answer to the question rather than a greeting. When nothing matches, it uses a reply without keywords. -The default lines cover greetings, "how are you", the level, where the bot is, -party, thanks and goodbye. +The default lines cover greetings, "how are you", the level and class, where the +bot is, where to hunt, party, help, trade, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, `{level}`, `{class}`, `{player}` (whisper replies), `{partner}` (the other bot of diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 67745b7bcb..e12feb763e 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -446,58 +446,96 @@ public static BotChatConfiguration CreateDefault() Add( configuration.EventLines, ("{event} open, anyone going? im in {map}", "{event} otwarte, ktoś idzie? jestem w {map}"), - ("lf party {event}, waiting in {map}", "szukam grupy na {event}, czekam w {map}"), - ("{event} now, who wants to join? {map}", "{event} teraz, kto chętny? {map}"), - ("going {event}, party anyone? at {map}", "idę na {event}, ktoś do grupy? {map}")); + ("lf party {event}, waiting in {map}", "szukam pt na {event}, czekam w {map}"), + ("{event} now, who's in? {location}", "{event} startuje, kto chętny? {location}"), + ("going {event}, need two more, {map}", "idę na {event}, brakuje dwóch, {map}"), + ("{event} soon, meet me at {location}", "zaraz {event}, zbiórka {location}"), + ("anyone for {event}? waiting in {map}", "ktoś na {event}? czekam w {map}"), + ("{event}! last call, {location}", "{event}! ostatnie wołanie, {location}")); Add( configuration.InvasionLines, ("{event} in {map}!", "{event} w {map}!"), - ("{event} on {map}, go go", "{event} na {map}, lecimy"), - ("{map} - {event} started", "{map} - start: {event}")); + ("{event} on {map}, go go", "{event} na {map}, lecimy!"), + ("{map} - {event} started", "{map} - {event} ruszyło"), + ("{event}! everyone to {map}", "{event}! wszyscy na {map}"), + ("{event} at {map}, the loot is waiting!", "{event} w {map}, drop czeka!")); Add( configuration.LevelMilestoneLines, ("{level} finally :)", "{level} w końcu :)"), - ("lvl {level}, nice", "lvl {level}, nieźle"), - ("{level} done", "{level} wbite")); + ("lvl {level}, nice", "lvl {level}, jest moc"), + ("{level} done", "{level} wbite"), + ("ding! {level}", "ding! {level}"), + ("{level}, slowly but surely", "{level}, powoli do przodu"), + ("yes! {level}", "jest! {level}")); AddReplies( string.Empty, - ("sry busy hunting", "sry, poluję"), - ("hi, cant talk now", "hej, nie mogę teraz gadać"), + ("sry, busy hunting", "sorki, poluję"), + ("hi, can't talk now", "hej, nie mogę teraz gadać"), ("hi, what's up?", "hej, co tam?"), - ("afk a bit", "chwilę afk")); + ("afk a bit", "chwilę afk"), + ("hm?", "hm?"), + ("sorry, didn't get that", "sorki, nie załapałem"), + ("brb, monsters", "zaraz, potwory")); Add( configuration.GreetingLines, ("hi {player}, have a nice game", "siema {player}, miłej gry"), - ("welcome back {player}", "witaj z powrotem {player}"), - ("yo {player} :)", "yo {player} :)"), - ("hey {player}", "hej {player}")); - AddAt(configuration.GreetingLines, BotChatTimeOfDay.Morning, ("good morning {player}", "dzień dobry {player}"), ("morning {player}, coffee first?", "cześć {player}, najpierw kawa?")); - AddAt(configuration.GreetingLines, BotChatTimeOfDay.Evening, ("good evening {player}", "dobry wieczór {player}")); - AddAt(configuration.GreetingLines, BotChatTimeOfDay.Night, ("late night grind, {player}?", "nocny exp, {player}?")); + ("welcome back {player}", "witaj z powrotem, {player}"), + ("yo {player} :)", "elo {player} :)"), + ("hey {player}", "hej {player}"), + ("{player}! long time no see", "{player}! kopę lat"), + ("look who's here, {player}", "o proszę, {player} jest")); + AddAt( + configuration.GreetingLines, + BotChatTimeOfDay.Morning, + ("good morning {player}", "dzień dobry, {player}"), + ("morning {player}, coffee first?", "hej {player}, kawa już była?"), + ("early bird, {player}", "ranny ptaszek z ciebie, {player}")); + AddAt(configuration.GreetingLines, BotChatTimeOfDay.Day, ("hey {player}, nice day for exp", "hej {player}, dobry dzień na expa")); + AddAt( + configuration.GreetingLines, + BotChatTimeOfDay.Evening, + ("good evening {player}", "dobry wieczór, {player}"), + ("evening {player}, done with work?", "siema {player}, już po robocie?")); + AddAt( + configuration.GreetingLines, + BotChatTimeOfDay.Night, + ("late night grind, {player}?", "nocny exp, {player}?"), + ("{player}, can't sleep either?", "{player}, też nie możesz spać?")); Add( configuration.PartyJoinedLines, ("thx for invite", "dzięki za zaproszenie"), - ("hi all", "hej wszystkim"), - ("ty {player}", "dzięki {player}"), - ("hey, lets go", "hej, lecimy")); + ("hi all", "siema wszystkim"), + ("ty {player}", "dzięki, {player}"), + ("hey, let's go", "hej, lecimy"), + ("where are we going?", "gdzie idziemy?"), + ("ready when you are", "gotowy, jak tylko powiesz"), + ("nice, a party at last", "o, nareszcie grupa")); Add( configuration.EventFinishedLines, ("gg", "gg"), ("gg wp", "gg wp"), - ("gg, thx for party", "gg, dzięki za grupę"), - ("nice run", "niezły run")); + ("gg, thx for the party", "gg, dzięki za grupę"), + ("nice run", "niezły run"), + ("again next time?", "powtórka następnym razem?"), + ("that was close", "było blisko"), + ("gg, easy", "gg, łatwizna")); Add( configuration.KilledLines, ("why??", "czemu??"), ("wtf {player}", "wtf {player}"), ("really?", "serio?"), - ("ok {player}, remember that", "ok {player}, zapamiętam")); + ("ok {player}, i'll remember that", "ok {player}, zapamiętam"), + ("i was just hunting...", "ja tu tylko expiłem..."), + ("not cool, {player}", "słabo, {player}"), + ("pk? seriously?", "pk? no serio?")); Add( configuration.KilledAgainLines, ("you again...", "znowu ty..."), - ("ok ok, i leave", "dobra dobra, idę stąd"), - ("{player} pls stop", "{player} weź przestań"), - ("enough, im out", "dość, spadam")); + ("ok ok, i'm leaving", "dobra, dobra, idę stąd"), + ("{player} pls stop", "{player}, weź przestań"), + ("enough, i'm out", "dość, spadam"), + ("fine, the spot is yours", "dobra, spot twój"), + ("you must be bored", "chyba się nudzisz")); Add( configuration.MerchantLines, ("selling potions, cheap! {location}", "sprzedam potki, tanio! {location}"), @@ -505,47 +543,64 @@ public static BotChatConfiguration CreateDefault() ("selling +9 weapons, good price, {location}", "sprzedam bronie +9, dobra cena, {location}"), ("trade? whisper me, i'm at {location}", "handel? pisz pw, stoję w {location}"), ("buying wings, paying well", "kupię skrzydła, dobrze płacę"), - ("jewels for sale, come to {location}", "jewele na sprzedaż, zapraszam do {location}")); + ("jewels for sale, come to {location}", "jewele na sprzedaż, zapraszam do {location}"), + ("fresh apples and potions, {location}", "świeże jabłka i potki, {location}"), + ("selling chaos, bless and soul, cheap", "sprzedam chaos, bless i soul, tanio")); Add( configuration.CongratulationLines, ("gz", "gz"), - ("gz {player}", "gratki {player}"), + ("gz {player}", "gratki, {player}"), ("nice, {level}!", "nieźle, {level}!"), - ("grats :)", "gratulacje :)")); + ("grats :)", "gratulacje :)"), + ("gz! keep going", "gz! dawaj dalej"), + ("wow, {level} already?", "wow, już {level}?")); Add( configuration.ResetCongratulationLines, ("gz on the reset!", "gratki resetu!"), ("reset {resets}, nice {player}", "reset {resets}, nieźle {player}"), - ("another reset? gz", "kolejny reset? gz")); + ("another reset? gz", "kolejny reset? gz"), + ("{resets} resets? respect", "{resets} resetów? szacun"), + ("gz {player}, back to the start :)", "gz {player}, i od nowa :)")); Add( configuration.TradeDeclineLines, - ("sry, no trade", "sorry, nie handluję"), + ("sry, no trade", "sorki, nie handluję"), ("not trading now, busy", "teraz nie handluję, jestem zajęty"), - ("nothing to sell, sry", "nie mam nic na sprzedaż, sry")); + ("nothing to sell, sry", "nie mam nic na sprzedaż, sorki"), + ("my bag is full, sry", "plecak pełny, sorki"), + ("only exp today", "dziś tylko exp")); Add( configuration.DuelDeclineLines, ("not now, maybe later", "nie teraz, może później"), - ("no duels today, sry", "dziś bez duelów, sry"), - ("haha no, i'm here for exp", "haha nie, jestem tu dla expa")); + ("no duels today, sry", "dziś bez duelów, sorki"), + ("haha no, i'm here for the exp", "haha nie, jestem tu dla expa"), + ("maybe after this spot", "może po tym spocie"), + ("you'd win anyway :)", "i tak byś wygrał :)")); Add( configuration.PartyVolunteerLines, ("me? {level} {class}", "ja? {level} {class}"), ("i can come, {class} {level}", "mogę przyjść, {class} {level}"), ("{class} {level} here, where are you?", "{class} {level}, gdzie jesteś?"), - ("count me in, lvl {level}. where?", "wchodzę, lvl {level}. gdzie?")); + ("count me in, lvl {level}. where?", "wchodzę, lvl {level}. gdzie?"), + ("i'm free, {class} {level}", "jestem wolny, {class} {level}"), + ("{level} {class}, where to?", "{level} {class}, dokąd?")); Add( configuration.PartyAskWhereLines, ("where? write map and coords, e.g. devias 215 40", "gdzie? napisz mapę i koordy, np. devias 215 40"), - ("which map? like lorencia 130 120", "jaka mapa? np. lorencia 130 120")); + ("which map? like lorencia 130 120", "jaka mapa? np. lorencia 130 120"), + ("i'm in, but where? map and coords pls", "wchodzę, ale gdzie? mapa i koordy pls")); Add( configuration.PartyOnTheWayLines, ("omw to {location}", "idę, {location}"), ("ok, coming to {location}", "ok, już idę do {location}"), - ("on my way", "już lecę")); + ("on my way", "już lecę"), + ("be there in a minute", "zaraz będę"), + ("coming, wait for me at {location}", "idę, czekaj na mnie w {location}")); Add( configuration.PartyCantGoLines, - ("cant go to {map}, sry", "nie mogę iść do {map}, sry"), - ("{map} is too much for me", "{map} to za dużo dla mnie")); + ("can't go to {map}, sry", "nie mogę iść do {map}, sorki"), + ("{map} is too much for me", "{map} to dla mnie za dużo"), + ("my level is too low for {map}", "za niski lvl na {map}"), + ("{map}? no way, not yet", "{map}? jeszcze nie dam rady")); BotConversationDefaults.AddTo(configuration.Conversations); AddClass("dk, bk, bm, knight, dark knight, blade knight, blade master, rycerz", BotClassNumbers.DarkKnightNumber); AddClass("dw, sm, gm, wizard, dark wizard, soul master, grand master, mag", BotClassNumbers.DarkWizardNumber); @@ -555,31 +610,60 @@ public static BotChatConfiguration CreateDefault() AddClass("sum, summoner, bs, dim, bloody summoner, dimension master", BotClassNumbers.SummonerNumber); AddClass("rf, fm, fighter, rage fighter, fist master", BotClassNumbers.RageFighterNumber); AddReplies( - "how are you, how r u, how are u, hru, how's it going, how is it going, jak sie masz, jak się masz, co slychac, co słychać, co tam", + "how are you, how r u, how are u, hru, how's it going, how is it going, jak sie masz, jak się masz, co slychac, co słychać, co tam, jak leci", ("fine, thanks for asking", "dobrze, dzięki że pytasz"), ("good, you?", "spoko, a ty?"), - ("all good, just grinding", "wszystko ok, expię")); + ("all good, just grinding", "wszystko ok, expię"), + ("tired but ok", "zmęczony, ale ok"), + ("never better", "lepiej być nie może")); AddReplies( - "hi, hello, hey, yo, hiho, sup, siema, czesc, cześć, hej, elo, witam", + "hi, hello, hey, yo, hiho, sup, siema, siemka, czesc, cześć, hej, elo, witam, dzien dobry, dzień dobry", ("hi, what's up?", "hej, co tam?"), ("hey :)", "hej :)"), - ("hello", "siema")); + ("hello", "siema"), + ("yo", "elo"), + ("hey {player}", "hej {player}")); AddReplies( - "level, lvl, what level, poziom, jaki lvl, jaki poziom", + "level, lvl, what level, poziom, jaki lvl, jaki poziom, ktory lvl, który lvl", ("{level} now", "{level} teraz"), - ("lvl {level}", "lvl {level}")); + ("lvl {level}", "lvl {level}"), + ("{level}, and you?", "{level}, a ty?")); + AddReplies( + "class, what class, jaka klasa, jaka postac, jaka postać, czym grasz", + ("{class}", "{class}"), + ("{class}, lvl {level}", "{class}, lvl {level}")); AddReplies( "where are you, where r u, where, gdzie jestes, gdzie jesteś, gdzie", ("{location}", "{location}"), - ("in {location}", "w {location}")); + ("in {location}", "w {location}"), + ("{location}, come by", "{location}, wpadaj")); + AddReplies( + "where to exp, good spot, exp spot, gdzie expic, gdzie expić, gdzie na expa, dobry spot, gdzie expisz", + ("depends on your level, try lost tower", "zależy od lvla, spróbuj lost tower"), + ("atlans is good if you have a party", "atlans jest spoko, jak masz grupę"), + ("i like {map} at my level", "na moim lvlu lubię {map}")); AddReplies( "party, pt, grupa, grupe, grupę, do grupy", ("sure, invite me", "jasne, zaproś mnie"), - ("ok, send invite", "ok, wysyłaj zaproszenie")); + ("ok, send invite", "ok, wysyłaj zaproszenie"), + ("sure, where are you?", "jasne, gdzie jesteś?")); + AddReplies( + "help, can you help, pomoc, pomocy, pomozesz, pomożesz", + ("what do you need?", "czego potrzebujesz?"), + ("sure, what's up?", "jasne, o co chodzi?")); + AddReplies( + "sell, buy, wtb, wts, trade, sprzedasz, kupisz, sprzedam, kupie, kupię, handel", + ("nothing to sell, sry", "nie mam nic na sprzedaż, sorki"), + ("ask at the market in town", "popytaj na rynku w mieście")); + AddReplies( + "bot, are you a bot, r u bot, u bot, jestes botem, jesteś botem", + ("what do you think? :)", "a jak myślisz? :)"), + ("beep boop", "bip bup")); AddReplies( - "thanks, thank you, thx, ty, dzieki, dzięki, dziekuje, dziękuję", + "thanks, thank you, thx, ty, dzieki, dzięki, dziekuje, dziękuję, dzieks, thx bro", ("np", "np"), - ("no problem", "nie ma sprawy")); + ("no problem", "nie ma sprawy"), + ("anytime", "spoko, zawsze")); AddReplies( "bye, cya, see you, see ya, gn, gtg, g2g, got to go, gotta go, have to go, must go, i'm off, im off, logging off, nara, pa, papa, do zobaczenia, dobranoc, na razie, musze konczyc, muszę kończyć, koncze, kończę, musze isc, muszę iść, spadam, zmykam, lece, lecę, do jutra", ("bye", "nara"), diff --git a/src/GameLogic/Bots/BotConversationDefaults.cs b/src/GameLogic/Bots/BotConversationDefaults.cs index def0bc02ae..41d7364adc 100644 --- a/src/GameLogic/Bots/BotConversationDefaults.cs +++ b/src/GameLogic/Bots/BotConversationDefaults.cs @@ -108,24 +108,125 @@ internal static void AddTo(ICollection conversations) ("have you seen a dark lord around?", "he was looking for his horse", "widziałeś gdzieś dark lorda?", "szukał swojego konia"), ("what's the best class?", "the one you enjoy", "jaka klasa jest najlepsza?", "ta, którą lubisz"), ("need a party for exp", "same here, let's wait for someone", "potrzebna grupa na expa", "też, poczekajmy na kogoś")); + Add( + conversations, + BotConversationPlace.Town, + ("did you see the guy with the shiny wings?", "it's the excellent options that count", "widziałeś gościa w tych błyszczących skrzydłach?", "liczą się opcje exe, nie blask"), + ("i need a jewel of life for my sword", "good luck, they're rarer than dragons", "potrzebuję jewela life do miecza", "powodzenia, rzadsze niż smoki"), + ("the merchant here never has what i need", "that's merchants for you", "kupiec tu nigdy nie ma tego, czego chcę", "tacy już są kupcy"), + ("{partner}, you look stronger today", "new gloves, thanks for noticing", "{partner}, wyglądasz dziś groźniej", "nowe rękawice, dzięki że zauważyłeś"), + ("anyone selling a pet?", "a dark horse would cost you a fortune", "ktoś sprzedaje peta?", "dark horse to majątek"), + ("heard there's a castle siege soon", "my guild isn't ready, as always", "podobno niedługo castle siege", "moja gildia jak zwykle niegotowa"), + ("the chaos machine keeps failing me", "try an item with luck", "chaos machine ciągle mi pali", "spróbuj z luckiem na itemie"), + ("it's quiet in {map} today", "the calm before the invasion", "cicho dziś w {map}", "cisza przed inwazją"), + ("my inventory is full of junk again", "welcome to the club", "znowu plecak pełen śmieci", "witaj w klubie"), + ("should i buy a ticket for blood castle?", "only if you have a party", "kupić bilet na bc?", "tylko jak masz grupę"), + ("how do you even get to icarus?", "with wings and patience", "jak się w ogóle dostać na icarus?", "ze skrzydłami i cierpliwością"), + ("i'm saving zen for a jewel of soul", "i'm saving zen for repairs", "zbieram zen na jewel of soul", "a ja na naprawy"), + ("the town music never gets old", "after ten years? it does", "ta muzyczka w mieście się nie nudzi", "po dziesięciu latach? nudzi"), + ("{partner}, did you finish your quest?", "almost, one more item", "{partner}, skończyłeś questa?", "prawie, jeszcze jeden item"), + ("i saw a golden budge dragon earlier", "and you didn't call me?", "widziałem wcześniej złotego budge dragona", "i nie zawołałeś?"), + ("time to repair everything", "my wallet is crying already", "czas wszystko naprawić", "mój portfel już płacze"), + ("what's the best set for a {class}?", "the one you can afford", "jaki set najlepszy dla {class}?", "ten, na który cię stać"), + ("i got lost in the market again", "just follow the noise", "znowu się zgubiłem na rynku", "idź za hałasem"), + ("someone sold me a +0 sword as +9", "always check before you trade", "ktoś mi wcisnął miecz +0 jako +9", "zawsze sprawdzaj przed handlem"), + ("is the arena open today?", "for the brave, always", "arena dziś otwarta?", "dla odważnych zawsze"), + ("nice horse, {partner}", "it's a rental", "fajny koń, {partner}", "wypożyczony"), + ("do you ever take a day off?", "the monsters don't, so i don't", "robisz sobie czasem wolne?", "potwory nie robią, to ja też nie"), + ("{map} looks different today", "maybe they painted the walls", "{map} dziś jakieś inne", "może pomalowali mury"), + ("my pet ran away again", "pets have feelings too", "znowu mi pet uciekł", "pety też mają uczucia"), + ("i should finally join a guild", "pick one with a nice emblem", "w końcu muszę dołączyć do gildii", "wybierz taką z ładnym herbem"), + ("the shop ran out of large potions", "everyone's stocking up for the event", "w sklepie skończyły się duże potki", "wszyscy się zbroją na event"), + ("how many resets do you have?", "enough to stop counting", "ile masz resetów?", "tyle, że przestałem liczyć"), + ("who keeps dropping apples here?", "some generous soul", "kto tu ciągle rzuca jabłka?", "jakaś dobra dusza"), + ("can you lend me some zen?", "nice try", "pożyczysz trochę zenu?", "niezła próba"), + ("the safe zone feels so safe", "until someone sends a duel request", "w safe zonie tak bezpiecznie", "dopóki ktoś nie wyśle duela")); + Add( + conversations, + BotConversationPlace.Hunting, + ("careful, that one hits hard", "i noticed. with my face", "uważaj, ten mocno bije", "zauważyłem. twarzą"), + ("pull fewer next time", "but it's faster this way", "następnym razem ściągaj mniej", "ale tak jest szybciej"), + ("i'm almost out of arrows", "then punch them", "kończą mi się strzały", "to bij pięściami"), + ("{partner}, heal please", "on it", "{partner}, ulecz pls", "już się robi"), + ("this one dropped nothing again", "poor monsters, poor drops", "znowu nic nie wypadło", "biedne potwory, biedny drop"), + ("that was a nice combo", "practice, lots of practice", "niezłe combo", "praktyka, dużo praktyki"), + ("i think a jewel dropped over there", "go, i'll cover you", "chyba tam wypadł jewel", "leć, osłaniam cię"), + ("the respawn here is crazy", "perfect for exp", "respawn tu jest szalony", "idealnie na expa"), + ("my weapon is breaking", "one more pack, then repair", "broń mi się sypie", "jeszcze jedna paczka i naprawa"), + ("i hate when they run away", "they know you're strong", "nie znoszę, jak uciekają", "wiedzą, że jesteś mocny"), + ("another level soon", "you'll be stronger than me", "zaraz kolejny level", "zaraz będziesz mocniejszy ode mnie"), + ("these skeletons are tougher than they look", "strong bones", "te szkielety są twardsze, niż wyglądają", "mają mocne kości"), + ("did you hear that roar?", "let's not find out", "słyszałeś ten ryk?", "lepiej nie sprawdzajmy"), + ("i need mana, badly", "blue potion, now", "potrzebuję many, pilnie", "niebieska potka, już"), + ("this spot is ours now", "until the next party shows up", "ten spot jest teraz nasz", "dopóki nie przyjdzie następna grupa"), + ("nice drop!", "just junk, as usual", "fajny drop!", "śmieć, jak zwykle"), + ("keep them away from me", "i'm trying!", "trzymaj je ode mnie z daleka", "próbuję!"), + ("one more and we move", "you always say that", "jeszcze jeden i ruszamy", "zawsze tak mówisz"), + ("where did that one come from?", "they always spawn behind you", "skąd ten się wziął?", "zawsze spawnują się za plecami"), + ("i'm getting good exp here", "same, let's stay a bit", "dobry tu exp", "u mnie też, zostańmy chwilę"), + ("watch the poison!", "too late", "uważaj na truciznę!", "za późno"), + ("{partner}, go left, i take right", "deal", "{partner}, idź w lewo, ja w prawo", "stoi"), + ("do they ever stop coming?", "only when the server restarts", "one kiedyś przestają przychodzić?", "tylko jak serwer się restartuje"), + ("my shield saved me there", "best zen you ever spent", "tarcza mnie uratowała", "najlepiej wydany zen"), + ("that one is a boss, right?", "big, ugly, yes, a boss", "to boss, nie?", "duży, brzydki, tak, boss"), + ("low hp, back off a bit", "go, i'll hold them", "mało hp, cofnij się trochę", "leć, trzymam je"), + ("i love this map", "the monsters don't love you back", "uwielbiam tę mapę", "potwory nie odwzajemniają uczucia"), + ("is it worth staying here?", "the exp says yes", "warto tu zostać?", "exp mówi, że tak"), + ("i think we're too strong for this spot", "let's try the next map then", "chyba jesteśmy za mocni na ten spot", "to spróbujmy następnej mapy"), + ("hey, that was my kill!", "sharing is caring", "ej, to był mój kill!", "trzeba się dzielić")); + Add( + conversations, + BotConversationPlace.Anywhere, + ("do you remember your first wings?", "like it was yesterday", "pamiętasz swoje pierwsze skrzydła?", "jakby to było wczoraj"), + ("what are you saving for?", "a better weapon, as always", "na co zbierasz?", "na lepszą broń, jak zawsze"), + ("{partner}, how long have you been playing?", "since the very beginning", "{partner}, od kiedy grasz?", "od samego początku"), + ("do you play another class too?", "a little elf, for the buffs", "grasz jeszcze inną klasą?", "małą elfką, dla buffów"), + ("i dream of a full excellent set", "we all do", "marzy mi się full exe set", "wszystkim się marzy"), + ("they say the server got an update", "more monsters, i hope", "podobno serwer dostał update", "oby więcej potworów"), + ("what's your favorite event?", "blood castle, no doubt", "jaki twój ulubiony event?", "blood castle, bez dwóch zdań"), + ("i should write down my coords", "i always forget where i hunt", "muszę zapisywać koordy", "ja zawsze zapominam, gdzie expię"), + ("would you trade your weapon for mine?", "not in a million years", "zamieniłbyś swoją broń na moją?", "za nic w świecie"), + ("i miss my old guild", "they probably miss you too", "tęsknię za starą gildią", "oni za tobą pewnie też"), + ("level {level} and still learning", "that never stops", "level {level} i dalej się uczę", "to się nigdy nie kończy"), + ("do you play with the sound on?", "the music keeps me awake", "grasz z dźwiękiem?", "muzyka nie daje mi zasnąć"), + ("{class} is the best class", "everyone says that about their class", "{class} to najlepsza klasa", "każdy tak mówi o swojej klasie"), + ("let's do a quest together sometime", "sure, just ask", "zróbmy kiedyś razem questa", "jasne, daj znać"), + ("have you seen the new players?", "everyone was new once", "widziałeś nowych graczy?", "każdy kiedyś był nowy"), + ("i heard someone got a jewel of creation", "lucky one", "podobno ktoś wydropił jewel of creation", "szczęściarz")); + AddAt( + conversations, + BotConversationPlace.Anywhere, + BotChatTimeOfDay.Day, + ("lunch break exp?", "just one more level", "exp w przerwie na lunch?", "tylko jeden level"), + ("busy day in {map}", "everyone's online after school", "dużo dziś ludzi w {map}", "wszyscy już po szkole"), + ("the sun is high over {map}", "and the monsters are wide awake", "słońce wysoko nad {map}", "a potwory nie śpią")); AddAt( conversations, BotConversationPlace.Anywhere, BotChatTimeOfDay.Morning, ("morning! coffee first", "potions first, coffee later", "dzień dobry! najpierw kawa", "najpierw potki, kawa później"), - ("up early today?", "the early elf catches the drop", "wcześnie dziś wstałeś?", "kto rano wstaje, temu drop wypada")); + ("up early today?", "the early elf catches the drop", "wcześnie dziś wstałeś?", "kto rano wstaje, temu drop wypada"), + ("first coffee, then the dragons", "best time, the spots are empty", "najpierw kawa, potem smoki", "najlepsza pora, spoty puste"), + ("good morning, {partner}", "morning, slept well?", "dzień dobry, {partner}", "dzień dobry, wyspany?"), + ("morning exp is the best exp", "and the quietest", "poranny exp najlepszy", "i najcichszy")); AddAt( conversations, BotConversationPlace.Anywhere, BotChatTimeOfDay.Evening, ("long day at work", "this is the best part of it", "długi dzień w pracy", "to jego najlepsza część"), - ("dinner time soon", "one more spot first", "zaraz kolacja", "najpierw jeszcze jeden spot")); + ("dinner time soon", "one more spot first", "zaraz kolacja", "najpierw jeszcze jeden spot"), + ("finally home", "and finally online", "w końcu w domu", "i w końcu online"), + ("good evening, {partner}", "good evening, ready for the events?", "dobry wieczór, {partner}", "dobry wieczór, gotowy na eventy?"), + ("prime time, the server is full", "more people, more fun", "prime time, serwer pełny", "więcej ludzi, więcej zabawy")); AddAt( conversations, BotConversationPlace.Anywhere, BotChatTimeOfDay.Night, ("still up?", "the monsters don't sleep, neither do i", "jeszcze nie śpisz?", "potwory nie śpią, ja też nie"), - ("late night grind?", "can't sleep anyway", "nocny exp?", "i tak nie mogę spać")); + ("late night grind?", "can't sleep anyway", "nocny exp?", "i tak nie mogę spać"), + ("it's late, {partner}", "just one more level", "późno już, {partner}", "jeszcze tylko jeden level"), + ("quiet night", "perfect for grinding", "cicha noc", "w sam raz na grind"), + ("my eyes are closing", "drink a potion", "oczy mi się kleją", "wypij potkę")); } private static void Add(ICollection conversations, BotConversationPlace place, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 2cc284f11b..72f4b7b865 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -107,7 +107,7 @@ public async ValueTask ConversationsFollowTheIntervalPerPlayerAsync() } /// - /// The default configuration comes with 50 to 100 conversations for every place, each with a + /// The default configuration comes with 150 to 250 conversations for every place, each with a /// Polish translation of both texts. /// [Test] @@ -116,7 +116,7 @@ public void DefaultConversationsAreManyAndTranslated() var conversations = BotChatConfiguration.CreateDefault().Conversations; var polish = CultureInfo.GetCultureInfo("pl"); - Assert.That(conversations, Has.Count.InRange(50, 100)); + Assert.That(conversations, Has.Count.InRange(150, 250)); Assert.That(conversations.Select(c => c.Where).Distinct().Count(), Is.EqualTo(Enum.GetValues().Length)); Assert.That(conversations.All(c => c.Opener.GetOwnTranslation(polish) is not null && c.Reply.GetOwnTranslation(polish) is not null), Is.True); } From 34dcebb77d20ce133ad77794e669a3c638ed4f10 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:15:59 +0100 Subject: [PATCH 26/44] feat(bots): answer in the language of the player's message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bot answered in the language of the player's account, which is English unless an admin set it: a Polish player who whispered "jak się masz?" got "fine, thanks for asking". Now an answer to a message - whisper, mention, party chat, the meeting place of a party search - is in the language the message is written in, recognized by its typical words and letters. A message which doesn't tell ("lol") is answered in the player's own language, as before. The words and letters per language are a list of the configuration; the defaults know Polish and English. --- docs-website/docs/server-features/bots.md | 7 ++++ src/GameLogic/Bots/BotChatComposer.cs | 24 +++++++++++ src/GameLogic/Bots/BotChatConfiguration.cs | 23 +++++++++++ src/GameLogic/Bots/BotChatLanguageHint.cs | 35 ++++++++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 41 +++++++++++++++---- src/GameLogic/Bots/BotChatSpeaker.cs | 15 ++++--- .../Properties/PlugInResources.Designer.cs | 24 +++++++++++ src/GameLogic/Properties/PlugInResources.resx | 8 ++++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 18 ++++++++ 9 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 src/GameLogic/Bots/BotChatLanguageHint.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 722dfdf493..fdf3341382 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -408,6 +408,13 @@ every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. +A bot answers a message in the language it is written in: a Polish whisper +gets a Polish answer, an English one an English answer, whatever the player's +account language. The *Message languages* list holds the typical words and +letters by which it tells them apart; a message which doesn't tell ("lol") is +answered in the player's own language. Everything else a bot says - greetings, +posts, conversations - follows the account language: + For the Polish lines to reach a Polish player, three things are needed: 1. **The player's language is set to `pl`** — the *Language Iso Code* of the diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index 56a1d274ed..db887abb31 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -121,6 +121,30 @@ internal static string FormatLocation(string? mapName, Point? position) ? string.Create(CultureInfo.InvariantCulture, $"{mapName} {point.X}, {point.Y}") : mapName ?? string.Empty; + /// + /// Recognizes the language of a player's message by its typical words and letters: the language + /// with the most hits wins, a tie or no hit at all leaves it open. + /// + /// The player's message. + /// The words and letters of the languages. + /// The language code, or null when the message doesn't tell. + internal static string? DetectLanguage(string message, IEnumerable hints) + { + var normalizedMessage = $" {Normalize(message)} "; + var lowerMessage = message.ToLowerInvariant(); + var scores = hints + .Where(hint => !string.IsNullOrWhiteSpace(hint.Language)) + .Select(hint => (hint.Language, Score: + GetKeywords(hint.Words).Count(word => normalizedMessage.Contains($" {word} ", StringComparison.Ordinal)) + + (lowerMessage.Any(c => (hint.Letters ?? string.Empty).Contains(c, StringComparison.Ordinal)) ? 1 : 0))) + .Where(s => s.Score > 0) + .OrderByDescending(s => s.Score) + .ToList(); + return scores.Count == 0 || (scores.Count > 1 && scores[0].Score == scores[1].Score) + ? null + : scores[0].Language.Trim(); + } + /// /// Determines whether the message mentions the name as a whole word, ignoring case and /// punctuation - "hi Serael!" mentions Serael, "Seraelion" does not. diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index e12feb763e..eab9c82d49 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -263,6 +263,18 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Description))] public ICollection WhisperReplies { get; set; } = new List(); + /// + /// Gets or sets the typical words and letters by which a bot recognizes the language of a + /// player's message (whisper, mention, party chat, meeting place): it answers in that language, + /// even when the player's account language is another one. A message which doesn't tell is + /// answered in the player's own language. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MessageLanguages_Name), Description = nameof(PlugInResources.BotChatConfiguration_MessageLanguages_Description))] + public ICollection MessageLanguages { get; set; } = new List(); + /// /// Gets or sets the lines the nearest bot greets a player with who entered the game. /// Placeholders: {player}, {level}, {map}, {location}. @@ -601,6 +613,17 @@ public static BotChatConfiguration CreateDefault() ("{map} is too much for me", "{map} to dla mnie za dużo"), ("my level is too low for {map}", "za niski lvl na {map}"), ("{map}? no way, not yet", "{map}? jeszcze nie dam rady")); + configuration.MessageLanguages.Add(new BotChatLanguageHint + { + Language = "pl", + Words = "jak, się, sie, masz, cześć, czesc, siema, siemka, gdzie, jesteś, jestes, dzięki, dzieki, dziękuję, dziekuje, nara, poziom, grupa, grupę, grupe, muszę, musze, kończyć, konczyc, spadam, co, tam, słychać, slychac, jaki, jaka, który, ktory, czy, nie, tak, dobra, pomoc, pomożesz, pomozesz, kupię, kupie, sprzedam, idę, ide, jestem, mam, tutaj, chodź, chodz, witam, dobranoc, dzień, dzien, dobry, czym, grasz, expisz, gdzie, potrzebuję, potrzebuje", + Letters = "ąćęłńóśźż", + }); + configuration.MessageLanguages.Add(new BotChatLanguageHint + { + Language = "en", + Words = "how, are, you, hello, hi, where, what, level, thanks, thank, bye, the, is, need, help, your, which, can, come, here, see, good, have, going, sell, buy", + }); BotConversationDefaults.AddTo(configuration.Conversations); AddClass("dk, bk, bm, knight, dark knight, blade knight, blade master, rycerz", BotClassNumbers.DarkKnightNumber); AddClass("dw, sm, gm, wizard, dark wizard, soul master, grand master, mag", BotClassNumbers.DarkWizardNumber); diff --git a/src/GameLogic/Bots/BotChatLanguageHint.cs b/src/GameLogic/Bots/BotChatLanguageHint.cs new file mode 100644 index 0000000000..98495faff0 --- /dev/null +++ b/src/GameLogic/Bots/BotChatLanguageHint.cs @@ -0,0 +1,35 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// The words and letters by which a bot recognizes the language of a player's message, so it can +/// answer in that language - a Polish whisper gets a Polish answer, even when the player's account +/// language is English (see ). +/// +public class BotChatLanguageHint +{ + /// + /// Gets or sets the language code, e.g. pl. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLanguageHint_Language_Name), Description = nameof(PlugInResources.BotChatLanguageHint_Language_Description))] + public string Language { get; set; } = string.Empty; + + /// + /// Gets or sets the comma-separated typical words of the language, e.g. jak, gdzie, dzięki. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLanguageHint_Words_Name), Description = nameof(PlugInResources.BotChatLanguageHint_Words_Description))] + public string Words { get; set; } = string.Empty; + + /// + /// Gets or sets the letters only this language uses, e.g. ąćęłńóśźż; one of them is as good + /// as a typical word. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLanguageHint_Letters_Name), Description = nameof(PlugInResources.BotChatLanguageHint_Letters_Description))] + public string Letters { get; set; } = string.Empty; + + /// + public override string ToString() => this.Language; +} diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index db56a4723a..f341b81cdb 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -243,11 +243,12 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag var values = CreateBotValues(receiver); var senderName = sender.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => senderName; + var language = GetMessageLanguage(message, configuration); RunLater( receiver.GameContext, TypingDelay(line.Text), () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values) + ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values, language) : ValueTask.CompletedTask); } @@ -401,10 +402,11 @@ await sender.ForEachObservingAsync( var values = CreateBotValues(bot); values["player"] = _ => senderName; + var language = GetMessageLanguage(message, configuration); RunLater( bot.GameContext, TypingDelay(line.Text), - () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values) : ValueTask.CompletedTask); + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, language) : ValueTask.CompletedTask); } /// @@ -686,6 +688,27 @@ await bot.ForEachObservingAsync( private static int GetLevel(Player player) => (int)(player.Attributes?[Stats.Level] ?? 0); + /// + /// Gets the language a player's message is written in, to answer in it - or null to answer + /// in the player's own language. + /// + private static CultureInfo? GetMessageLanguage(string message, BotChatConfiguration configuration) + { + if (BotChatComposer.DetectLanguage(message, configuration.MessageLanguages) is not { } code) + { + return null; + } + + try + { + return CultureInfo.GetCultureInfo(code); + } + catch (CultureNotFoundException) + { + return null; + } + } + private static bool IsPostCommandActive(IGameContext gameContext) => gameContext.PlugInManager.IsPlugInActive(typeof(PostChatCommandPlugIn)); @@ -815,6 +838,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess var values = CreateBotValues(bot); var requesterName = requester.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => requesterName; + var language = GetMessageLanguage(message, configuration); // A named map wins over "here": "i'm here, devias 215 40" names the place. var place = BotChatComposer.FindPlace(message, bot.GameContext.Configuration.Maps); @@ -830,7 +854,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess if (throttle.TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown) && this.PickLineFor(requester, configuration.PartyAskWhereLines) is { } askLine) { - this.WhisperLater(bot, requester, askLine, values); + this.WhisperLater(bot, requester, askLine, values, language); } return; @@ -850,7 +874,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess { if (PickLine(configuration.PartyCantGoLines) is { } cantGoLine) { - this.WhisperLater(bot, requester, cantGoLine, values); + this.WhisperLater(bot, requester, cantGoLine, values, language); } return; @@ -859,7 +883,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess bot.EventMeeting = new BotEventMeeting(map, position, DateTime.UtcNow + configuration.PartyMeetingWaitTime); if (PickLine(configuration.PartyOnTheWayLines) is { } onTheWayLine) { - this.WhisperLater(bot, requester, onTheWayLine, values); + this.WhisperLater(bot, requester, onTheWayLine, values, language); } } @@ -892,7 +916,8 @@ private void AnswerInParty(Player sender, string senderName, string message, Bot var values = CreateBotValues(speaker); values["player"] = _ => senderName; - RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values)); + var language = GetMessageLanguage(message, configuration); + RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values, language)); } /// @@ -922,12 +947,12 @@ private void DeclineLater(Player bot, Player requester, BotChatConfiguration con }); } - private void WhisperLater(Player bot, Player receiver, BotChatLine line, Dictionary> values) + private void WhisperLater(Player bot, Player receiver, BotChatLine line, Dictionary> values, CultureInfo? language = null) => RunLater( bot.GameContext, TypingDelay(line.Text), () => receiver.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values) + ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values, language) : ValueTask.CompletedTask); private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatConfiguration configuration) diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs index 675b2a7ac3..f47715273c 100644 --- a/src/GameLogic/Bots/BotChatSpeaker.cs +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -28,7 +28,8 @@ internal static class BotChatSpeaker /// The speaking bot. /// The text. /// The placeholder values. - internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) + /// The language to say it in, for every receiver; null for each receiver's own. + internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) { if (bot.SelectedCharacter?.Name is not { } name) { @@ -36,7 +37,7 @@ internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDi } return bot.ForEachObservingAsync( - receiver => SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Normal), + receiver => SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Normal), false); } @@ -47,14 +48,15 @@ internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDi /// The receiving player. /// The text. /// The placeholder values. - internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedString text, IReadOnlyDictionary> values) + /// The language to whisper in; null for the receiver's own. + internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) { if (bot.SelectedCharacter?.Name is not { } name) { return ValueTask.CompletedTask; } - return SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Whisper); + return SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Whisper); } /// @@ -63,7 +65,8 @@ internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedStr /// The speaking bot. /// The text. /// The placeholder values. - internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) + /// The language to say it in, for every receiver; null for each receiver's own. + internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) { if (bot.SelectedCharacter?.Name is not { } name || bot.Party is not { } party) { @@ -72,7 +75,7 @@ internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, foreach (var receiver in party.PartyList.OfType()) { - await SendAsync(receiver, BotChatComposer.Render(text, receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); + await SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); } } diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 6467f152c5..01ce635a64 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5448,5 +5448,29 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_BusyChatWindow_Description text. public static string BotChatConfiguration_BusyChatWindow_Description => ResourceManager.GetString("BotChatConfiguration_BusyChatWindow_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_MessageLanguages_Name text. + public static string BotChatConfiguration_MessageLanguages_Name => ResourceManager.GetString("BotChatConfiguration_MessageLanguages_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_MessageLanguages_Description text. + public static string BotChatConfiguration_MessageLanguages_Description => ResourceManager.GetString("BotChatConfiguration_MessageLanguages_Description", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Language_Name text. + public static string BotChatLanguageHint_Language_Name => ResourceManager.GetString("BotChatLanguageHint_Language_Name", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Language_Description text. + public static string BotChatLanguageHint_Language_Description => ResourceManager.GetString("BotChatLanguageHint_Language_Description", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Words_Name text. + public static string BotChatLanguageHint_Words_Name => ResourceManager.GetString("BotChatLanguageHint_Words_Name", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Words_Description text. + public static string BotChatLanguageHint_Words_Description => ResourceManager.GetString("BotChatLanguageHint_Words_Description", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Letters_Name text. + public static string BotChatLanguageHint_Letters_Name => ResourceManager.GetString("BotChatLanguageHint_Letters_Name", resourceCulture)!; + + /// Gets the localized BotChatLanguageHint_Letters_Description text. + public static string BotChatLanguageHint_Letters_Description => ResourceManager.GetString("BotChatLanguageHint_Letters_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index f3958111b9..88e05b3249 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2009,4 +2009,12 @@ From how many normal chat messages of players near a bot (within the time below) the bot says nothing unasked - no conversations, greetings, congratulations or comments. Answers to a player stay. 0 = off. Quiet when players talk: time How long a message of a player counts for the setting above. + Message languages + Typical words and letters by which a bot recognizes the language of a player's message: it answers in that language, even when the player's account language is another one. A message which doesn't tell is answered in the player's own language. + Language + The language code, e.g. pl. + Typical words + Comma-separated typical words of the language. The language with the most hits in a message wins; a tie leaves it open. + Letters + Letters only this language uses, e.g. ąćęłńóśźż. One of them in a message counts like a typical word. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 72f4b7b865..50f1b7926e 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -334,6 +334,24 @@ public void AnswersUnaddressedPartyMessagesOnlyByKeyword() Assert.That(BotChatComposer.PickWhisperReply(replies, "ok lets go", _ => 0)?.Text.ValueInNeutralLanguage, Is.EqualTo("busy")); } + /// + /// The language of a player's message is recognized by its typical words and letters, so the bot + /// answers in it; a message which doesn't tell leaves it open. + /// + /// The player's message. + /// The expected language, or null. + [TestCase("jak sie masz?", "pl")] + [TestCase("cześć!", "pl")] + [TestCase("hi, jak się masz", "pl")] + [TestCase("how are you?", "en")] + [TestCase("lol", null)] + public void RecognizesTheLanguageOfAMessage(string message, string? expected) + { + var languages = BotChatConfiguration.CreateDefault().MessageLanguages; + + Assert.That(BotChatComposer.DetectLanguage(message, languages), Is.EqualTo(expected)); + } + /// /// A bot feels addressed when its name is in a chat message as a whole word - not when it is only /// a part of a longer word. From 3c494e4eb71b2740ab0709919aef22a4f9596f75 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:17:14 +0100 Subject: [PATCH 27/44] feat(bots): make gestures with the lines A player who greets waves, a player who says "gz" claps - the game client has these emotes. A line or reply can now come with a gesture: the bot turns to the player it talks to and performs the client's emote animation, seen by the players around it. The default lines have fitting ones: a wave with a greeting and a goodbye, a clap with a congratulation, a cheer after an event, a victory pose at a level milestone, a raised fist when a player killed the bot and tears when it happens again. The gesture values are the client's animation numbers, so they're an enum rather than free numbers. --- docs-website/docs/server-features/bots.md | 5 ++ src/GameLogic/Bots/BotChatConfiguration.cs | 25 +++++++++ src/GameLogic/Bots/BotChatGesture.cs | 53 +++++++++++++++++++ src/GameLogic/Bots/BotChatLine.cs | 6 +++ src/GameLogic/Bots/BotChatPlugIn.cs | 26 ++++----- src/GameLogic/Bots/BotChatReply.cs | 6 +++ src/GameLogic/Bots/BotChatSpeaker.cs | 49 ++++++++++++++--- .../Properties/PlugInResources.Designer.cs | 6 +++ src/GameLogic/Properties/PlugInResources.resx | 2 + tests/MUnique.OpenMU.Tests/BotChatTest.cs | 15 ++++++ 10 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/GameLogic/Bots/BotChatGesture.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index fdf3341382..93607c631b 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -437,6 +437,11 @@ Every line and conversation has a `When`: `Anytime` (the default), `Morning` time of the server. A greeting may be "good morning" in the morning and "late night grind?" at night. +A line can come with a `Gesture` - the emotes of the game client, like a player +typing `/hello`: the bot turns to the player and waves with a greeting or a +goodbye, claps with a "gz", cheers after an event, raises its fist when a +player killed it. The default lines have fitting gestures. + A bot answers like a typing player: it takes a moment to read, then a little time per character, so a quick "np" comes at once and a whole sentence takes a few seconds (at most twelve). diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index eab9c82d49..69b8e597ee 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -693,8 +693,33 @@ public static BotChatConfiguration CreateDefault() ("cya", "na razie"), ("ok, see you later", "ok, do później"), ("take care", "trzymaj się")); + SetGesture(configuration.GreetingLines, BotChatGesture.Greeting); + SetGesture(configuration.PartyJoinedLines, BotChatGesture.Salute); + SetGesture(configuration.LevelMilestoneLines, BotChatGesture.Win); + SetGesture(configuration.EventFinishedLines, BotChatGesture.Cheer); + SetGesture(configuration.CongratulationLines, BotChatGesture.Clap); + SetGesture(configuration.ResetCongratulationLines, BotChatGesture.Respect); + SetGesture(configuration.KilledLines, BotChatGesture.Provocation); + SetGesture(configuration.KilledAgainLines, BotChatGesture.Cry); + SetGesture(configuration.DuelDeclineLines, BotChatGesture.Awkward); + foreach (var reply in configuration.WhisperReplies) + { + reply.Gesture = reply.Keywords.StartsWith("hi,", StringComparison.Ordinal) ? BotChatGesture.Greeting + : reply.Keywords.StartsWith("bye,", StringComparison.Ordinal) ? BotChatGesture.Goodbye + : reply.Keywords.StartsWith("thanks,", StringComparison.Ordinal) ? BotChatGesture.Smile + : BotChatGesture.None; + } + return configuration; + static void SetGesture(IEnumerable lines, BotChatGesture gesture) + { + foreach (var line in lines) + { + line.Gesture = gesture; + } + } + void Add(ICollection lines, params (string English, string Polish)[] texts) { foreach (var text in texts) diff --git a/src/GameLogic/Bots/BotChatGesture.cs b/src/GameLogic/Bots/BotChatGesture.cs new file mode 100644 index 0000000000..8c676cdce7 --- /dev/null +++ b/src/GameLogic/Bots/BotChatGesture.cs @@ -0,0 +1,53 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// A gesture a bot makes with a line, like a player who types /hello or /clap: waving with a +/// greeting, clapping with a "gz". +/// +/// +/// The values are the animation numbers of the game client's emotes (the actions which follow +/// AT_SIT1 = 128, the "sitting" animation the server already knows), so they must match the +/// client and can't be configured freely. They are stored in the configuration as numbers. +/// +public enum BotChatGesture : byte +{ + /// No gesture. + None = 0, + + /// Waving a greeting. + Greeting = 131, + + /// Waving goodbye. + Goodbye = 132, + + /// Clapping. + Clap = 133, + + /// Crying. + Cry = 137, + + /// Cheering. + Cheer = 138, + + /// An awkward shrug. + Awkward = 139, + + /// A victory pose. + Win = 141, + + /// Smiling. + Smile = 142, + + /// Bowing in respect. + Respect = 146, + + /// A salute. + Salute = 147, + + /// A provocation - angry. + Provocation = 153, +} diff --git a/src/GameLogic/Bots/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs index 425a7892b2..d5f9718744 100644 --- a/src/GameLogic/Bots/BotChatLine.cs +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -30,6 +30,12 @@ public class BotChatLine [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_When_Name), Description = nameof(PlugInResources.BotChatLine_When_Description))] public BotChatTimeOfDay When { get; set; } + /// + /// Gets or sets the gesture the bot makes with the line, seen by the players around it. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Gesture_Name), Description = nameof(PlugInResources.BotChatLine_Gesture_Description))] + public BotChatGesture Gesture { get; set; } + /// public override string ToString() => this.Text.ValueInNeutralLanguage; } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index f341b81cdb..e9a11d6ce5 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -210,7 +210,7 @@ public void CharacterLeveledUp(Player player) player.GameContext, RandomDelay(LevelMilestoneDelaySeconds), () => IsAvailable(player) && this.MayTalkUnasked(player, configuration) - ? BotChatSpeaker.SayAsync(player, line.Text, CreateBotValues(player)) + ? BotChatSpeaker.SayAsync(player, line.Text, CreateBotValues(player), gesture: line.Gesture) : ValueTask.CompletedTask); } @@ -248,7 +248,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag receiver.GameContext, TypingDelay(line.Text), () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values, language) + ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values, language, line.Gesture) : ValueTask.CompletedTask); } @@ -283,7 +283,7 @@ public void BotJoinedParty(Player bot, Player inviter) var values = CreateBotValues(bot); var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => inviterName; - RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values, gesture: line.Gesture, toward: inviter)); } /// @@ -312,7 +312,7 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR var values = CreateBotValues(bot); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; - RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values, gesture: line.Gesture)); } return ValueTask.CompletedTask; @@ -348,7 +348,7 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) var values = CreateBotValues(bot); var killerName = killerPlayer.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => killerName; - return BotChatSpeaker.SayAsync(bot, line.Text, values); + return BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture, toward: killerPlayer); }); return ValueTask.CompletedTask; } @@ -406,7 +406,7 @@ await sender.ForEachObservingAsync( RunLater( bot.GameContext, TypingDelay(line.Text), - () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, language) : ValueTask.CompletedTask); + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, language, line.Gesture, sender) : ValueTask.CompletedTask); } /// @@ -499,7 +499,7 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) bot.GameContext, whisperDelay, () => IsAvailable(bot) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(bot, sender, line.Text, values) + ? BotChatSpeaker.WhisperAsync(bot, sender, line.Text, values, gesture: line.Gesture) : ValueTask.CompletedTask); } } @@ -917,7 +917,7 @@ private void AnswerInParty(Player sender, string senderName, string message, Bot var values = CreateBotValues(speaker); values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); - RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values, language)); + RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values, language, line.Gesture, sender)); } /// @@ -942,7 +942,7 @@ private void DeclineLater(Player bot, Player requester, BotChatConfiguration con if (line is not null && requester.PlayerState.CurrentState == PlayerState.EnteredWorld) { - await BotChatSpeaker.WhisperAsync(bot, requester, line.Text, values).ConfigureAwait(false); + await BotChatSpeaker.WhisperAsync(bot, requester, line.Text, values, gesture: line.Gesture).ConfigureAwait(false); } }); } @@ -952,7 +952,7 @@ private void WhisperLater(Player bot, Player receiver, BotChatLine line, Diction bot.GameContext, TypingDelay(line.Text), () => receiver.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values, language) + ? BotChatSpeaker.WhisperAsync(bot, receiver, line.Text, values, language, line.Gesture) : ValueTask.CompletedTask); private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatConfiguration configuration) @@ -985,7 +985,7 @@ private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatCo if (this.MayTalkUnasked(speaker, configuration) && this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) { - await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker)).ConfigureAwait(false); + await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker), gesture: line.Gesture).ConfigureAwait(false); } } @@ -1113,7 +1113,7 @@ private void Congratulate(Player player, BotChatConfiguration configuration, ICo values["player"] = _ => playerName; values["level"] = _ => playerLevel; values["resets"] = _ => playerResets; - await BotChatSpeaker.SayAsync(bot, line.Text, values).ConfigureAwait(false); + await BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture, toward: player).ConfigureAwait(false); }); } @@ -1141,7 +1141,7 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura var values = CreateBotValues(bot); values["player"] = _ => playerName; - await BotChatSpeaker.SayAsync(bot, line.Text, values).ConfigureAwait(false); + await BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture, toward: player).ConfigureAwait(false); } private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) diff --git a/src/GameLogic/Bots/BotChatReply.cs b/src/GameLogic/Bots/BotChatReply.cs index d18adf8f50..a26f642796 100644 --- a/src/GameLogic/Bots/BotChatReply.cs +++ b/src/GameLogic/Bots/BotChatReply.cs @@ -35,6 +35,12 @@ public class BotChatReply [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Text_Name), Description = nameof(PlugInResources.BotChatLine_Text_Description))] public LocalizedString Text { get; set; } + /// + /// Gets or sets the gesture the bot makes with the reply, seen by the players around it. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatLine_Gesture_Name), Description = nameof(PlugInResources.BotChatLine_Gesture_Description))] + public BotChatGesture Gesture { get; set; } + /// public override string ToString() => $"{this.Keywords}: {this.Text.ValueInNeutralLanguage}"; } diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs index f47715273c..b96d9d224d 100644 --- a/src/GameLogic/Bots/BotChatSpeaker.cs +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Interfaces; /// @@ -29,16 +30,19 @@ internal static class BotChatSpeaker /// The text. /// The placeholder values. /// The language to say it in, for every receiver; null for each receiver's own. - internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) + /// The gesture the bot makes with it. + /// The player the bot talks to, to turn to for the gesture. + internal static async ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None, Player? toward = null) { if (bot.SelectedCharacter?.Name is not { } name) { - return ValueTask.CompletedTask; + return; } - return bot.ForEachObservingAsync( + await bot.ForEachObservingAsync( receiver => SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Normal), - false); + false).ConfigureAwait(false); + await GestureAsync(bot, gesture, toward).ConfigureAwait(false); } /// @@ -49,14 +53,16 @@ internal static ValueTask SayAsync(Player bot, LocalizedString text, IReadOnlyDi /// The text. /// The placeholder values. /// The language to whisper in; null for the receiver's own. - internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) + /// The gesture the bot makes with it, seen when the receiver stands near. + internal static async ValueTask WhisperAsync(Player bot, Player receiver, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None) { if (bot.SelectedCharacter?.Name is not { } name) { - return ValueTask.CompletedTask; + return; } - return SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Whisper); + await SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Whisper).ConfigureAwait(false); + await GestureAsync(bot, gesture, receiver).ConfigureAwait(false); } /// @@ -66,7 +72,9 @@ internal static ValueTask WhisperAsync(Player bot, Player receiver, LocalizedStr /// The text. /// The placeholder values. /// The language to say it in, for every receiver; null for each receiver's own. - internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null) + /// The gesture the bot makes with it. + /// The player the bot talks to, to turn to for the gesture. + internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None, Player? toward = null) { if (bot.SelectedCharacter?.Name is not { } name || bot.Party is not { } party) { @@ -77,6 +85,8 @@ internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, { await SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); } + + await GestureAsync(bot, gesture, toward).ConfigureAwait(false); } /// @@ -101,6 +111,29 @@ await bot.GameContext.ForEachPlayerAsync(receiver => .ConfigureAwait(false); } + /// + /// Makes the bot perform a gesture for the players who see it, turned towards the player it talks + /// to - like a player typing /hello. + /// + /// The bot. + /// The gesture. + /// The player the bot talks to, if it's near. + internal static ValueTask GestureAsync(Player bot, BotChatGesture gesture, Player? toward) + { + if (gesture == BotChatGesture.None || bot.CurrentMap is not { } map) + { + return ValueTask.CompletedTask; + } + + if (toward is not null && ReferenceEquals(toward.CurrentMap, map)) + { + bot.Rotation = bot.GetDirectionTo(toward); + } + + var rotation = bot.Rotation; + return bot.ForEachWorldObserverAsync(p => p.ShowAnimationAsync(bot, (byte)gesture, null, rotation), false); + } + private static ValueTask SendAsync(Player receiver, string text, string sender, ChatMessageType type) { if (receiver is OfflinePlayer || string.IsNullOrWhiteSpace(text)) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 01ce635a64..319c227ed3 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5472,5 +5472,11 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatLanguageHint_Letters_Description text. public static string BotChatLanguageHint_Letters_Description => ResourceManager.GetString("BotChatLanguageHint_Letters_Description", resourceCulture)!; + /// Gets the localized BotChatLine_Gesture_Name text. + public static string BotChatLine_Gesture_Name => ResourceManager.GetString("BotChatLine_Gesture_Name", resourceCulture)!; + + /// Gets the localized BotChatLine_Gesture_Description text. + public static string BotChatLine_Gesture_Description => ResourceManager.GetString("BotChatLine_Gesture_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 88e05b3249..8cb5882cce 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2017,4 +2017,6 @@ Comma-separated typical words of the language. The language with the most hits in a message wins; a tie leaves it open. Letters Letters only this language uses, e.g. ąćęłńóśźż. One of them in a message counts like a typical word. + Gesture + A gesture the bot makes with it, like a player typing /hello: Greeting, Goodbye, Clap, Cheer, Win, Smile, Respect, Salute, Cry, Awkward or Provocation. The bot turns to the player it talks to. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 50f1b7926e..187704a929 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -416,6 +416,21 @@ public void DefaultRepliesUnderstandGoodbyes(string message) Assert.That(reply?.Keywords, Does.Contain("musze konczyc")); } + /// + /// The default lines come with fitting gestures: a wave with a greeting and with a goodbye, a + /// clap with a "gz". The values are the client's emote animations. + /// + [Test] + public void DefaultLinesComeWithGestures() + { + var configuration = BotChatConfiguration.CreateDefault(); + + Assert.That(configuration.GreetingLines.All(line => line.Gesture == BotChatGesture.Greeting), Is.True); + Assert.That(configuration.CongratulationLines.All(line => line.Gesture == BotChatGesture.Clap), Is.True); + Assert.That(BotChatComposer.PickWhisperReply(configuration.WhisperReplies, "muszę kończyć", _ => 0)?.Gesture, Is.EqualTo(BotChatGesture.Goodbye)); + Assert.That((byte)BotChatGesture.Greeting, Is.EqualTo(131)); + } + /// /// A server which switches the plugin on has something to say in every situation: each list of /// the default configuration has lines. From 980f2122ddf6d8bd300c8a7c969b5ee7a717e043 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:18:34 +0100 Subject: [PATCH 28/44] feat(bots): let a conversation have a third line and players chime in A conversation of two bots stopped after the reply, and a player standing next to it who said something got no reaction - the bots carried on as if nobody was there. A conversation may now have a riposte, the first bot's last word ("guess who failed +10 again" - "you?" - "me. again."); twenty-four such conversations join the defaults, 194 in all. And a player who writes in the normal chat within 30 seconds after a conversation next to them gets an answer from one of the two bots: a whisper reply when the message matches a keyword, otherwise one of the new "player chimes in" lines ("right?", "haha, exactly"). --- docs-website/docs/server-features/bots.md | 6 +- src/GameLogic/Bots/BotChatConfiguration.cs | 20 +++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 56 +++++++++++++++++- src/GameLogic/Bots/BotChatThrottle.cs | 40 +++++++++++++ src/GameLogic/Bots/BotConversation.cs | 10 +++- src/GameLogic/Bots/BotConversationDefaults.cs | 59 +++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 12 ++++ src/GameLogic/Properties/PlugInResources.resx | 4 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 22 +++++++ 9 files changed, 226 insertions(+), 3 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 93607c631b..7881397a27 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -387,7 +387,11 @@ together do they talk — nobody else would hear it. Each conversation has a place: `Town` (both bots in a safezone), `Hunting` (both outside of it, usually fighting) or `Anywhere`. They are drawn without repetition until all of a place were used, and each player hears about one every five minutes. The default -configuration comes with some 170 of them. +configuration comes with some 190 of them. A conversation may have a third +line - the first bot's riposte. A player who writes in the normal chat within +30 seconds after a conversation next to them gets an answer from one of the two +bots: a whisper reply when the message matches a keyword, otherwise "right?", +"haha, exactly" and the like. A whisper reply can carry `Keywords`, a comma-separated list of words or phrases it answers (`how are you, how r u`). The bot picks among the replies diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 69b8e597ee..8a5c379e0f 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -439,6 +439,17 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_Conversations_Name), Description = nameof(PlugInResources.BotChatConfiguration_Conversations_Description))] public ICollection Conversations { get; set; } = new List(); + /// + /// Gets or sets the lines with which a bot answers a player who chimes in right after a + /// conversation of two bots near them - unless the player's message matches a whisper reply's + /// keyword. Placeholders: {player}, {map}, {level}, {class}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ConversationJoinLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_ConversationJoinLines_Description))] + public ICollection ConversationJoinLines { get; set; } = new List(); + /// /// Creates the configuration a server starts with: a set of English lines, each with a Polish /// translation. @@ -624,6 +635,14 @@ public static BotChatConfiguration CreateDefault() Language = "en", Words = "how, are, you, hello, hi, where, what, level, thanks, thank, bye, the, is, need, help, your, which, can, come, here, see, good, have, going, sell, buy", }); + Add( + configuration.ConversationJoinLines, + ("right, {player}?", "no nie, {player}?"), + ("haha, exactly", "haha, dokładnie"), + ("you think so too?", "też tak myślisz?"), + ("agreed", "zgadzam się"), + ("see, {player} gets it", "widzisz, {player} rozumie"), + ("hehe", "hehe")); BotConversationDefaults.AddTo(configuration.Conversations); AddClass("dk, bk, bm, knight, dark knight, blade knight, blade master, rycerz", BotClassNumbers.DarkKnightNumber); AddClass("dw, sm, gm, wizard, dark wizard, soul master, grand master, mag", BotClassNumbers.DarkWizardNumber); @@ -702,6 +721,7 @@ public static BotChatConfiguration CreateDefault() SetGesture(configuration.KilledLines, BotChatGesture.Provocation); SetGesture(configuration.KilledAgainLines, BotChatGesture.Cry); SetGesture(configuration.DuelDeclineLines, BotChatGesture.Awkward); + SetGesture(configuration.ConversationJoinLines, BotChatGesture.Smile); foreach (var reply in configuration.WhisperReplies) { reply.Gesture = reply.Keywords.StartsWith("hi,", StringComparison.Ordinal) ? BotChatGesture.Greeting diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index e9a11d6ce5..475fa6d087 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -120,6 +120,12 @@ public class BotChatPlugIn /// private static readonly TimeSpan MaximumTypingTime = TimeSpan.FromSeconds(12); + /// + /// How long after the last line of a bots' conversation a player's message near them counts as + /// chiming in - the time to read it and type an answer. + /// + private static readonly TimeSpan ConversationChimeInWindow = TimeSpan.FromSeconds(30); + /// /// The gap between the whispers of two bots answering the same party search, so the answers /// trickle in like those of players who read the post at different moments. @@ -392,6 +398,12 @@ await sender.ForEachObservingAsync( }, false).ConfigureAwait(false); + if (mentionedBots.Count == 0) + { + this.AnswerChimingIn(sender, senderName, message, configuration); + return; + } + if (mentionedBots.FirstOrDefault(IsAvailable) is not { } bot || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line @@ -920,6 +932,36 @@ private void AnswerInParty(Player sender, string senderName, string message, Bot RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values, language, line.Gesture, sender)); } + /// + /// Answers a player who chimes in right after two bots near them had a conversation: one of the + /// two answers - by a keyword reply if the message has one, otherwise with one of the + /// ("right?", "haha, exactly"). + /// + private void AnswerChimingIn(Player sender, string senderName, string message, BotChatConfiguration configuration) + { + if (this.GetThrottle(sender.GameContext).TakeRecentConversation(bot => IsInHearingRange(bot, sender), DateTime.UtcNow) is not { } conversation + || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent)) + { + return; + } + + var bot = new[] { conversation.First, conversation.Second }.Where(b => IsAvailable(b) && IsInHearingRange(b, sender)).OrderBy(_ => Rand.NextInt(0, 2)).FirstOrDefault(); + LocalizedString? text = this.PickReplyFor(sender, configuration.WhisperReplies, message, requireKeyword: true) is { } reply + ? reply.Text + : this.PickLineFor(sender, configuration.ConversationJoinLines)?.Text; + if (bot is null + || text is not { } answer + || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => senderName; + var language = GetMessageLanguage(message, configuration); + RunLater(bot.GameContext, TypingDelay(answer), () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, answer, values, language, toward: sender) : ValueTask.CompletedTask); + } + /// /// Declines a player's request after a human-like moment: runs the refusal, if any, and whispers /// one of the lines - by chance, and not more often than the whisper reply cooldown allows. @@ -1045,10 +1087,22 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC secondValues["partner"] = _ => firstName; await BotChatSpeaker.SayAsync(first, conversation.Opener, firstValues).ConfigureAwait(false); + var replyDelay = TypingDelay(conversation.Reply); + var hasRiposte = !string.IsNullOrWhiteSpace(conversation.Riposte.ValueInNeutralLanguage); + var riposteDelay = hasRiposte ? replyDelay + TypingDelay(conversation.Riposte) : replyDelay; RunLater( gameContext, - TypingDelay(conversation.Reply), + replyDelay, () => IsAvailable(second) ? BotChatSpeaker.SayAsync(second, conversation.Reply, secondValues) : ValueTask.CompletedTask); + if (hasRiposte) + { + RunLater( + gameContext, + riposteDelay, + () => IsAvailable(first) ? BotChatSpeaker.SayAsync(first, conversation.Riposte, firstValues) : ValueTask.CompletedTask); + } + + throttle.RememberConversation(first, second, now + riposteDelay + ConversationChimeInWindow); } } diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 47fb993be1..b1363f251c 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -61,6 +61,8 @@ internal sealed class BotChatThrottle private readonly List<(GameMap Map, Point Position, DateTime Time)> _humanChat = []; + private readonly List<(Player First, Player Second, DateTime Until)> _recentConversations = []; + private long _nextPlayerScanTicks; private long _nextPostTicks; @@ -375,6 +377,44 @@ public bool IsHumanChatBusy(GameMap map, Point position, double range, DateTime } } + /// + /// Remembers that two bots just had a conversation, so a player who chimes in gets an answer. + /// + /// The bot which opened the conversation. + /// The bot which replied. + /// Until when a player's message counts as chiming in. + public void RememberConversation(Player first, Player second, DateTime until) + { + lock (this._recentConversations) + { + this._recentConversations.Add((first, second, until)); + } + } + + /// + /// Takes the recent conversation a player chimes in with a message: one which is still open and + /// of which one bot stands near the player. Each conversation is answered once. + /// + /// Determines whether a bot stands near the player. + /// The current time. + /// The two bots, or null without such a conversation. + public (Player First, Player Second)? TakeRecentConversation(Func isNear, DateTime now) + { + lock (this._recentConversations) + { + this._recentConversations.RemoveAll(c => c.Until < now); + var index = this._recentConversations.FindIndex(c => isNear(c.First) || isNear(c.Second)); + if (index < 0) + { + return null; + } + + var conversation = this._recentConversations[index]; + this._recentConversations.RemoveAt(index); + return (conversation.First, conversation.Second); + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/src/GameLogic/Bots/BotConversation.cs b/src/GameLogic/Bots/BotConversation.cs index d80a4f659d..5d5dfe88f9 100644 --- a/src/GameLogic/Bots/BotConversation.cs +++ b/src/GameLogic/Bots/BotConversation.cs @@ -8,7 +8,8 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// /// A short exchange of two server-side bots, like the chatter of the townsfolk in a role-playing -/// game: one bot says the , the other one answers with the . +/// game: one bot says the , the other one answers with the , +/// and the first one may have the last word with a . /// /// /// Both texts are s; every listening player sees the translation of @@ -29,6 +30,13 @@ public class BotConversation [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Reply_Name), Description = nameof(PlugInResources.BotConversation_Reply_Description))] public LocalizedString Reply { get; set; } + /// + /// Gets or sets what the first bot says back to the reply, a few seconds later; empty for a + /// conversation of two lines. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotConversation_Riposte_Name), Description = nameof(PlugInResources.BotConversation_Riposte_Description))] + public LocalizedString Riposte { get; set; } + /// /// Gets or sets where the two bots may have this conversation. /// diff --git a/src/GameLogic/Bots/BotConversationDefaults.cs b/src/GameLogic/Bots/BotConversationDefaults.cs index 41d7364adc..77f800df3a 100644 --- a/src/GameLogic/Bots/BotConversationDefaults.cs +++ b/src/GameLogic/Bots/BotConversationDefaults.cs @@ -193,6 +193,50 @@ internal static void AddTo(ICollection conversations) ("let's do a quest together sometime", "sure, just ask", "zróbmy kiedyś razem questa", "jasne, daj znać"), ("have you seen the new players?", "everyone was new once", "widziałeś nowych graczy?", "każdy kiedyś był nowy"), ("i heard someone got a jewel of creation", "lucky one", "podobno ktoś wydropił jewel of creation", "szczęściarz")); + AddWithRiposte( + conversations, + BotConversationPlace.Town, + ("i finally got my wings", "nice! what level?", "level 2, the expensive ones", "w końcu mam skrzydła", "nieźle! jaki level?", "dwójki, te drogie"), + ("guess who failed +10 again", "you?", "me. again.", "zgadnij, kto znowu spalił +10", "ty?", "ja. znowu."), + ("lend me 10k zen?", "what for?", "potions. and maybe a pet", "pożyczysz 10k zenu?", "na co?", "na potki. i może peta"), + ("{partner}, nice armor", "thanks, crafted it myself", "with a little help from the chaos goblin", "{partner}, fajna zbroja", "dzięki, sam zrobiłem", "z małą pomocą chaos goblina"), + ("the guards here never move", "maybe they're bots", "haha, no way", "strażnicy tu w ogóle się nie ruszają", "może to boty", "haha, niemożliwe"), + ("i'm going to devias", "the snow is lovely this time of year", "and the yetis are hungry", "idę do devias", "śnieg o tej porze jest piękny", "a yeti głodne"), + ("ever been to the land of trials?", "once, by accident", "how do you get there by accident?", "byłeś kiedyś w land of trials?", "raz, przez przypadek", "jak tam się trafia przez przypadek?"), + ("i need a party for bc", "i'm in", "great, one more and we go", "potrzebuję grupy na bc", "wchodzę", "super, jeszcze jeden i idziemy")); + AddWithRiposte( + conversations, + BotConversationPlace.Hunting, + ("i'll pull, you hit", "deal", "here they come!", "ja ściągam, ty bijesz", "stoi", "o, już idą!"), + ("how's your hp?", "half", "drink, i'll hold them", "jak tam hp?", "połowa", "pij, ja je trzymam"), + ("that was a jewel, right?", "no, a rock", "a very shiny rock", "to był jewel, nie?", "nie, kamień", "bardzo błyszczący kamień"), + ("we're fast today", "it's the new weapon", "or the coffee", "szybko dziś idzie", "to ta nowa broń", "albo kawa"), + ("careful, the big one", "i see him", "then why are you running?", "uważaj, ten duży", "widzę go", "to czemu uciekasz?"), + ("one more level and i'm done", "you said that an hour ago", "and i meant it", "jeszcze jeden level i kończę", "mówiłeś to godzinę temu", "i wtedy też tak myślałem")); + AddWithRiposte( + conversations, + BotConversationPlace.Anywhere, + ("what are you doing tonight?", "the same as every night", "exp?", "co robisz wieczorem?", "to co co wieczór", "expisz?"), + ("have you seen my old guild master?", "he quit last week", "again?", "widziałeś mojego starego mistrza gildii?", "odszedł w zeszłym tygodniu", "znowu?"), + ("i think i'll try a new class", "which one?", "the one with the biggest sword", "chyba spróbuję nowej klasy", "której?", "tej z największym mieczem"), + ("do you believe in lucky days?", "only when something drops", "then today isn't one", "wierzysz w szczęśliwe dni?", "tylko jak coś wypadnie", "to dziś nie jest taki dzień"), + ("guess what dropped yesterday", "a jewel?", "an apple. a really nice one", "zgadnij, co mi wczoraj wypadło", "jewel?", "jabłko. bardzo ładne"), + ("the server is busy today", "event soon?", "maybe, let's ask a gm", "dużo dziś ludzi na serwerze", "niedługo event?", "może, zapytajmy gma")); + + // A small easter egg: the bots know the open source projects this server and the client come from. + AddWithRiposte( + conversations, + BotConversationPlace.Anywhere, + ("openmu and mumain are doing a great job", "and how!", "open source, baby", "openmu i mumain robią robotę", "i to jaką!", "open source, ziomek"), + ("you know the server and client of this game are open source?", "openmu on the server, mumain in the client", "and we're the bots in between", "wiesz, że serwer i klient tej gry to open source?", "openmu na serwerze, mumain w kliencie", "a my, boty, pomiędzy"), + ("who made this server?", "openmu, written in c#", "so we're made of c# too?", "kto zrobił ten serwer?", "openmu, napisany w c#", "czyli my też jesteśmy z c#?"), + ("my client runs on mumain now", "smooth?", "smoother than my combos", "mój klient chodzi teraz na mumain", "płynnie?", "płynniej niż moje combosy")); + Add( + conversations, + BotConversationPlace.Anywhere, + ("found a bug today", "report it to openmu, they fix them fast", "znalazłem dziś buga", "zgłoś do openmu, szybko łatają"), + ("who do we thank for this world?", "the openmu and mumain folks", "komu dziękujemy za ten świat?", "ekipie openmu i mumain"), + ("i'd like to help with openmu and mumain", "pull requests are welcome", "chciałbym pomóc przy openmu i mumain", "pull requesty mile widziane")); AddAt( conversations, BotConversationPlace.Anywhere, @@ -232,6 +276,21 @@ internal static void AddTo(ICollection conversations) private static void Add(ICollection conversations, BotConversationPlace place, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) => AddAt(conversations, place, BotChatTimeOfDay.Anytime, texts); + private static void AddWithRiposte(ICollection conversations, BotConversationPlace place, params (string Opener, string Reply, string Riposte, string PolishOpener, string PolishReply, string PolishRiposte)[] texts) + { + var polish = CultureInfo.GetCultureInfo("pl"); + foreach (var text in texts) + { + conversations.Add(new BotConversation + { + Opener = new LocalizedString(text.Opener).WithTranslation(polish, text.PolishOpener), + Reply = new LocalizedString(text.Reply).WithTranslation(polish, text.PolishReply), + Riposte = new LocalizedString(text.Riposte).WithTranslation(polish, text.PolishRiposte), + Where = place, + }); + } + } + private static void AddAt(ICollection conversations, BotConversationPlace place, BotChatTimeOfDay when, params (string Opener, string Reply, string PolishOpener, string PolishReply)[] texts) { var polish = CultureInfo.GetCultureInfo("pl"); diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 319c227ed3..fbbbdcb003 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5478,5 +5478,17 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatLine_Gesture_Description text. public static string BotChatLine_Gesture_Description => ResourceManager.GetString("BotChatLine_Gesture_Description", resourceCulture)!; + /// Gets the localized BotConversation_Riposte_Name text. + public static string BotConversation_Riposte_Name => ResourceManager.GetString("BotConversation_Riposte_Name", resourceCulture)!; + + /// Gets the localized BotConversation_Riposte_Description text. + public static string BotConversation_Riposte_Description => ResourceManager.GetString("BotConversation_Riposte_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ConversationJoinLines_Name text. + public static string BotChatConfiguration_ConversationJoinLines_Name => ResourceManager.GetString("BotChatConfiguration_ConversationJoinLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_ConversationJoinLines_Description text. + public static string BotChatConfiguration_ConversationJoinLines_Description => ResourceManager.GetString("BotChatConfiguration_ConversationJoinLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 8cb5882cce..cbe1bf6a20 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2019,4 +2019,8 @@ Letters only this language uses, e.g. ąćęłńóśźż. One of them in a message counts like a typical word. Gesture A gesture the bot makes with it, like a player typing /hello: Greeting, Goodbye, Clap, Cheer, Win, Smile, Respect, Salute, Cry, Awkward or Provocation. The bot turns to the player it talks to. + Riposte + What the first bot says back to the reply, a few seconds later. Empty for a conversation of two lines. + Conversations: player chimes in + Said by one of two bots when a player near them writes in the normal chat right after their conversation - unless the message matches a whisper reply keyword. Placeholders: {player}, {map}, {level}, {class}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 187704a929..c19b1c756e 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -153,6 +153,28 @@ public void TypesLongerForLongerTexts() Assert.That(BotChatPlugIn.GetTypingTime(500, reaction, perCharacter), Is.EqualTo(TimeSpan.FromSeconds(12))); } + /// + /// A player who chimes in right after a conversation of two bots near them is answered once; a + /// message after the window, or far from the bots, is not taken for chiming in. + /// + /// The task. + [Test] + public async ValueTask APlayerCanChimeInOnceAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var first = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var second = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var throttle = new BotChatThrottle(); + throttle.RememberConversation(first, second, Start.AddSeconds(30)); + + Assert.That(throttle.TakeRecentConversation(_ => false, Start.AddSeconds(10)), Is.Null); + Assert.That(throttle.TakeRecentConversation(bot => bot == second, Start.AddSeconds(10)), Is.Not.Null); + Assert.That(throttle.TakeRecentConversation(_ => true, Start.AddSeconds(11)), Is.Null); + + throttle.RememberConversation(first, second, Start.AddSeconds(30)); + Assert.That(throttle.TakeRecentConversation(_ => true, Start.AddSeconds(31)), Is.Null); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From fc1b1f2bb69ad6f58bb00560a76c192eea1b29ae Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:19:12 +0100 Subject: [PATCH 29/44] feat(bots): remind of an opened event in town The /post announcement of an event reaches the whole server, so it's rare (off by default). Now and then, a bot in a town where a player stands also reminds of an opened Blood Castle, Devil Square or Chaos Castle in the normal chat - "bc open, anyone going?" - heard only by the players around it. Like the post, only a bot which meets the event's entry requirements says it, and each town hears it once per opening; the chance is 50 % by default. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 27 ++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 54 ++++++++++++++++++- .../Properties/PlugInResources.Designer.cs | 12 +++++ src/GameLogic/Properties/PlugInResources.resx | 4 ++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 7881397a27..b09ef7b674 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -341,6 +341,7 @@ only talks about what it could actually do: | Situation | Where | Who says it | |---|---|---| | A Blood Castle, Devil Square or Chaos Castle entrance opened | `/post`, then it waits for players | a bot which meets that event's entry requirements — the same check that lets it follow a leader in | +| A Blood Castle, Devil Square or Chaos Castle entrance opened | normal chat in town, no `/post` ("bc open, anyone going?") | the nearest bot which meets the entry requirements, once per town where a player stands - 50 % chance | | An invasion (White Wizard, Golden, Red Dragon) started | `/post` | a bot which may warp to the invaded map | | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 8a5c379e0f..b80edf8579 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -29,6 +29,15 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventPostChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventPostChancePercent_Description))] public int EventPostChancePercent { get; set; } = 30; + /// + /// Gets or sets the chance, in percent, that a bot in a town where a player stands reminds of an + /// opened Blood Castle, Devil Square or Chaos Castle in the normal chat - once per town and + /// opening, and only a bot which meets the entry requirements. + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventReminderChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventReminderChancePercent_Description))] + public int EventReminderChancePercent { get; set; } = 50; + /// /// Gets or sets the chance, in percent, that a bot announces a started invasion with /post. /// @@ -232,6 +241,17 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventLines_Description))] public ICollection EventLines { get; set; } = new List(); + /// + /// Gets or sets the lines a bot in a town says in the normal chat when the entrance of a Blood + /// Castle, Devil Square or Chaos Castle opened. Placeholders: {event}, {map}, + /// {location}, {level}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_EventReminderLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_EventReminderLines_Description))] + public ICollection EventReminderLines { get; set; } = new List(); + /// /// Gets or sets the lines a bot posts with /post when an invasion (White Wizard, Golden, Red Dragon) /// started. Placeholders: {event}, {map}, {location} (the map alone), {level}. @@ -475,6 +495,13 @@ public static BotChatConfiguration CreateDefault() ("{event} soon, meet me at {location}", "zaraz {event}, zbiórka {location}"), ("anyone for {event}? waiting in {map}", "ktoś na {event}? czekam w {map}"), ("{event}! last call, {location}", "{event}! ostatnie wołanie, {location}")); + Add( + configuration.EventReminderLines, + ("{event} is open, anyone going?", "{event} otwarte, ktoś idzie?"), + ("{event} now, who's coming?", "{event} teraz, kto idzie?"), + ("guys, {event} is open", "ludzie, {event} otwarte"), + ("{event}! got a ticket, anyone?", "{event}! mam bilet, ktoś chętny?"), + ("{event} time :)", "czas na {event} :)")); Add( configuration.InvasionLines, ("{event} in {map}!", "{event} w {map}!"), diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 475fa6d087..88fd8402e3 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -100,6 +100,12 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) KilledDelaySeconds = (1, 2); + /// + /// The delay range of an event reminder in town: the players read the server's own announcement + /// first. + /// + private static readonly (int Min, int Max) EventReminderDelaySeconds = (10, 40); + /// /// The delay range of a congratulation: the level-up or reset effect first, then the "gz". /// @@ -149,8 +155,18 @@ public class BotChatPlugIn /// public ValueTask MiniGameEntranceOpenedAsync(MiniGameContext miniGame) { + if (this.Configuration is not { } reminderConfiguration + || miniGame.Definition.Type is not (MiniGameType.BloodCastle or MiniGameType.DevilSquare or MiniGameType.ChaosCastle)) + { + return ValueTask.CompletedTask; + } + + if (reminderConfiguration.EventReminderChancePercent > 0) + { + RunLater(miniGame.GameContext, RandomDelay(EventReminderDelaySeconds), () => this.RemindOfEventAsync(miniGame, reminderConfiguration)); + } + if (this.Configuration is not { EventPostChancePercent: > 0 } configuration - || miniGame.Definition.Type is not (MiniGameType.BloodCastle or MiniGameType.DevilSquare or MiniGameType.ChaosCastle) || !IsPostCommandActive(miniGame.GameContext)) { return ValueTask.CompletedTask; @@ -1198,6 +1214,42 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura await BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture, toward: player).ConfigureAwait(false); } + /// + /// Lets a bot in each town where a player stands remind of the opened event in the normal chat - + /// "bc open, anyone going?" - without a server-wide post. Like the post, only a bot which meets the + /// event's entry requirements says it, and only once per town and opening. + /// + private async ValueTask RemindOfEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) + { + var now = DateTime.UtcNow; + if (now >= miniGame.EnterEndsAtUtc) + { + return; + } + + var definition = miniGame.Definition; + var throttle = this.GetThrottle(miniGame.GameContext); + var players = await miniGame.GameContext.GetPlayersAsync().ConfigureAwait(false); + var humansInTown = players.Where(p => p is not OfflinePlayer && p.PlayerState.CurrentState == PlayerState.EnteredWorld && IsInSafezone(p)).ToList(); + foreach (var human in humansInTown) + { + if (human.CurrentMap is not { } map + || FindGreeter(players.Where(p => p is OfflinePlayer candidate && BotMiniGameHandler.IsEligible(candidate, definition, out _)), human, map) is not { } bot + || !this.MayTalkUnasked(bot, configuration) + || !throttle.TryReserveOccasion($"event reminder {definition.Type} {map.MapId}", now, miniGame.EnterEndsAtUtc - now) + || !Rand.NextRandomBool(configuration.EventReminderChancePercent) + || PickLine(configuration.EventReminderLines) is not { } line + || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) + { + continue; + } + + var values = CreateBotValues(bot); + values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; + await BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture).ConfigureAwait(false); + } + } + private async ValueTask PostAboutEventAsync(MiniGameContext miniGame, BotChatConfiguration configuration) { var now = DateTime.UtcNow; diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index fbbbdcb003..5942184234 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5490,5 +5490,17 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_ConversationJoinLines_Description text. public static string BotChatConfiguration_ConversationJoinLines_Description => ResourceManager.GetString("BotChatConfiguration_ConversationJoinLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_EventReminderChancePercent_Name text. + public static string BotChatConfiguration_EventReminderChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_EventReminderChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventReminderChancePercent_Description text. + public static string BotChatConfiguration_EventReminderChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_EventReminderChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventReminderLines_Name text. + public static string BotChatConfiguration_EventReminderLines_Name => ResourceManager.GetString("BotChatConfiguration_EventReminderLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_EventReminderLines_Description text. + public static string BotChatConfiguration_EventReminderLines_Description => ResourceManager.GetString("BotChatConfiguration_EventReminderLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index cbe1bf6a20..294cb08002 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2023,4 +2023,8 @@ What the first bot says back to the reply, a few seconds later. Empty for a conversation of two lines. Conversations: player chimes in Said by one of two bots when a player near them writes in the normal chat right after their conversation - unless the message matches a whisper reply keyword. Placeholders: {player}, {map}, {level}, {class}. + Event reminder in town chance (%) + Chance that a bot in a town where a player stands reminds of an opened Blood Castle, Devil Square or Chaos Castle in the normal chat - once per town and opening, only a bot which meets the entry requirements. Independent of the /post chances. 0 = never. + Event reminders in town + Said in the normal chat by a bot in a town when an event entrance opened. Placeholders: {event}, {map}, {location}, {level}. From 097f3bc1f62b6626db252948289bcc33064bf471 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:20:21 +0100 Subject: [PATCH 30/44] feat(bots): react to rare drops and notable monsters nearby Players around a lucky drop or a golden monster say something - bots didn't. Now the nearest bot sometimes comments on an excellent or ancient item a player's kill dropped ("whoa, Excellent Dragon Armor!"), and a bot calls out a notable monster which appeared near it while a player sees it ("Golden Goblin here!"). The notable monsters are a configurable list of monster numbers, by default the golden monsters, the Red Dragon and the White Wizard of the invasions; an invasion spawning many of them gets one call-out per map and monster in two minutes. --- docs-website/docs/server-features/bots.md | 2 + src/GameLogic/Bots/BotChatConfiguration.cs | 71 +++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 119 +++++++++++++++++- .../Properties/PlugInResources.Designer.cs | 30 +++++ src/GameLogic/Properties/PlugInResources.resx | 10 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 11 ++ 6 files changed, 240 insertions(+), 3 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index b09ef7b674..ab9f02d9c4 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -346,6 +346,8 @@ only talks about what it could actually do: | A level milestone (every 50 levels by default) | normal chat, players nearby | the bot which reached it | | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | | A player leveled up or made a reset | normal chat, "gz", `{player}`, `{level}`, `{resets}` | the nearest bot which sees the player — 30 % chance, once per player in 10 minutes | +| A player's kill dropped an excellent or ancient item | normal chat ("nice drop!"), `{item}` | the nearest bot - 50 % chance | +| A golden monster, the Red Dragon or the White Wizard appeared near a bot | normal chat ("Golden Goblin here!"), `{monster}` | the nearest bot, while a player sees it - once per map and monster in two minutes | | The bot accepted a player's party invitation | party chat, `{player}` | the bot | | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index b80edf8579..4b942d2464 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -130,6 +130,44 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Name), Description = nameof(PlugInResources.BotChatConfiguration_CongratulationCooldown_Description))] public TimeSpan CongratulationCooldown { get; set; } = TimeSpan.FromMinutes(10); + /// + /// Gets or sets the chance, in percent, that the nearest bot comments on an excellent or ancient + /// item a player's kill dropped - "nice drop!". + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_RareDropChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_RareDropChancePercent_Description))] + public int RareDropChancePercent { get; set; } = 50; + + /// + /// Gets or sets the chance, in percent, that a bot calls out a notable monster which appeared + /// near it while a player sees it (see ). + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_NotableMonsterChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_NotableMonsterChancePercent_Description))] + public int NotableMonsterChancePercent { get; set; } = 70; + + /// + /// Gets or sets the comma-separated numbers of the monsters a bot calls out when they appear near + /// it - by default the golden monsters, the Red Dragon and the White Wizard of the invasions. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_NotableMonsterNumbers_Name), Description = nameof(PlugInResources.BotChatConfiguration_NotableMonsterNumbers_Description))] + public string NotableMonsterNumbers { get; set; } = string.Join( + ", ", + new[] + { + PlugIns.InvasionEvents.InvasionMonsters.GoldenBudgeDragon, + PlugIns.InvasionEvents.InvasionMonsters.GoldenTitan, + PlugIns.InvasionEvents.InvasionMonsters.GoldenSoldier, + PlugIns.InvasionEvents.InvasionMonsters.GoldenGoblin, + PlugIns.InvasionEvents.InvasionMonsters.GoldenDragon, + PlugIns.InvasionEvents.InvasionMonsters.GoldenLizardKing, + PlugIns.InvasionEvents.InvasionMonsters.GoldenVepar, + PlugIns.InvasionEvents.InvasionMonsters.GoldenTantallos, + PlugIns.InvasionEvents.InvasionMonsters.GoldenWheel, + PlugIns.InvasionEvents.InvasionMonsters.RedDragon, + PlugIns.InvasionEvents.InvasionMonsters.WhiteWizard, + }); + /// /// Gets or sets the chance, in percent, that a bot says why it turns down a player's trade or duel /// request, by whisper. A bot turns them down in any case - it has no trade window, and it @@ -345,6 +383,26 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_ResetCongratulationLines_Description))] public ICollection ResetCongratulationLines { get; set; } = new List(); + /// + /// Gets or sets the lines with which the nearest bot comments on an excellent or ancient item a + /// player's kill dropped. Placeholders: {player}, {item}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_RareDropLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_RareDropLines_Description))] + public ICollection RareDropLines { get; set; } = new List(); + + /// + /// Gets or sets the lines with which a bot calls out a notable monster which appeared near it. + /// Placeholders: {monster}, {map}, {location}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_NotableMonsterLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_NotableMonsterLines_Description))] + public ICollection NotableMonsterLines { get; set; } = new List(); + /// /// Gets or sets the whispers with which a bot turns down a player's trade request. /// Placeholders: {player}, {level}, {map}, {location}. @@ -611,6 +669,18 @@ public static BotChatConfiguration CreateDefault() ("another reset? gz", "kolejny reset? gz"), ("{resets} resets? respect", "{resets} resetów? szacun"), ("gz {player}, back to the start :)", "gz {player}, i od nowa :)")); + Add( + configuration.RareDropLines, + ("nice drop, {player}!", "niezły drop, {player}!"), + ("whoa, {item}!", "o kurczę, {item}!"), + ("lucky! gz on the {item}", "fart! gratki za {item}"), + ("{player}, that's a good one", "{player}, to jest dobre")); + Add( + configuration.NotableMonsterLines, + ("{monster} here!", "{monster} tutaj!"), + ("look, {monster}! at {location}", "patrz, {monster}! {location}"), + ("{monster}!! everyone come", "{monster}!! wszyscy tu"), + ("careful, {monster}", "uważajcie, {monster}")); Add( configuration.TradeDeclineLines, ("sry, no trade", "sorki, nie handluję"), @@ -749,6 +819,7 @@ public static BotChatConfiguration CreateDefault() SetGesture(configuration.KilledAgainLines, BotChatGesture.Cry); SetGesture(configuration.DuelDeclineLines, BotChatGesture.Awkward); SetGesture(configuration.ConversationJoinLines, BotChatGesture.Smile); + SetGesture(configuration.RareDropLines, BotChatGesture.Clap); foreach (var reply in configuration.WhisperReplies) { reply.Gesture = reply.Keywords.StartsWith("hi,", StringComparison.Ordinal) ? BotChatGesture.Greeting diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 88fd8402e3..e31755a51c 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -11,6 +11,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Threading; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.PlayerActions.Duel; using MUnique.OpenMU.GameLogic.PlugIns; @@ -54,6 +55,8 @@ public class BotChatPlugIn IPostMessageSentPlugIn, ITradeRequestedPlugIn, IDuelRequestedPlugIn, + IMonsterItemDroppedPlugIn, + IObjectAddedToMapPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -106,6 +109,17 @@ public class BotChatPlugIn /// private static readonly (int Min, int Max) EventReminderDelaySeconds = (10, 40); + /// + /// How long a notable monster on a map counts as announced: an invasion spawns many of them at + /// once, and a bot shouldn't call out each one. + /// + private static readonly TimeSpan NotableMonsterAnnouncementWindow = TimeSpan.FromMinutes(2); + + /// + /// The delay range of calling out a notable monster: the time to notice it. + /// + private static readonly (int Min, int Max) NotableMonsterDelaySeconds = (1, 4); + /// /// The delay range of a congratulation: the level-up or reset effect first, then the "gz". /// @@ -140,6 +154,12 @@ public class BotChatPlugIn private readonly ConditionalWeakTable _throttles = new(); + /// + /// The parsed , with the text they were + /// parsed from. + /// + private (string? Text, HashSet Numbers) _notableMonsters = (null, []); + /// /// The number of calls of ; a server which didn't see the current number /// yet lets a bot advertise like a merchant without waiting for the interval. @@ -486,6 +506,61 @@ public ValueTask DuelRequestedAsync(Player requester, Player target) return ValueTask.CompletedTask; } + /// + public ValueTask MonsterItemDroppedAsync(AttackableNpcBase monster, Player killer, DroppedItem droppedItem) + { + // A rare drop - the nearest bot sees it fall and says so. + if (this.Configuration is { RareDropChancePercent: > 0 } configuration + && killer is not OfflinePlayer + && killer.SelectedCharacter?.Name is { } killerName + && (droppedItem.Item.IsExcellent() || droppedItem.Item.IsAncient()) + && Rand.NextRandomBool(configuration.RareDropChancePercent)) + { + var itemName = droppedItem.Item.Definition?.Name; + this.SayToPlayerLater(killer, configuration, configuration.RareDropLines, $"rare drop {killerName}", values => values["item"] = culture => itemName?.GetTranslation(culture) ?? string.Empty); + } + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObject) + { + // Called for every object added to a map - so the cheap checks come first. + if (addedObject is not Monster monster + || this.Configuration is not { NotableMonsterChancePercent: > 0 } configuration + || !this.IsNotableMonster(monster.Definition.Number, configuration)) + { + return; + } + + var bot = map.GetAttackablesInRange(monster.Position, (int)HearingRange) + .OfType() + .Where(p => IsBot(p) && IsAvailable(p)) + .OrderBy(p => p.GetDistanceTo(monster)) + .FirstOrDefault(); + if (bot is null || !await HasHumanObserverAsync(bot).ConfigureAwait(false)) + { + return; + } + + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(bot.GameContext); + if (!this.MayTalkUnasked(bot, configuration) + || !throttle.TryReserveOccasion($"notable monster {map.MapId} {monster.Definition.Number}", now, NotableMonsterAnnouncementWindow) + || !Rand.NextRandomBool(configuration.NotableMonsterChancePercent) + || PickLine(configuration.NotableMonsterLines) is not { } line + || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + var monsterName = monster.Definition.Designation; + values["monster"] = culture => monsterName.GetTranslation(culture) ?? string.Empty; + RunLater(bot.GameContext, RandomDelay(NotableMonsterDelaySeconds), () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture) : ValueTask.CompletedTask); + } + /// public async ValueTask PostMessageSentAsync(Player sender, string message) { @@ -1152,7 +1227,6 @@ private void CongratulateResets(IEnumerable players, BotChatConfiguratio private void Congratulate(Player player, BotChatConfiguration configuration, ICollection lines, string occasionKey) { if (configuration.CongratulationChancePercent <= 0 - || player.SelectedCharacter?.Name is not { } playerName || !Rand.NextRandomBool(configuration.CongratulationChancePercent)) { return; @@ -1160,6 +1234,24 @@ private void Congratulate(Player player, BotChatConfiguration configuration, ICo var playerLevel = GetLevel(player).ToString(CultureInfo.InvariantCulture); var playerResets = ((int)(player.Attributes?[Stats.Resets] ?? 0)).ToString(CultureInfo.InvariantCulture); + this.SayToPlayerLater(player, configuration, lines, occasionKey, values => + { + values["level"] = _ => playerLevel; + values["resets"] = _ => playerResets; + }); + } + + /// + /// Lets the nearest bot which sees the player say one of the lines to them in the normal chat, + /// after a moment - at most once per occasion and the congratulation cooldown. + /// + private void SayToPlayerLater(Player player, BotChatConfiguration configuration, ICollection lines, string occasionKey, Action>> addValues) + { + if (player.SelectedCharacter?.Name is not { } playerName) + { + return; + } + RunLater(player.GameContext, RandomDelay(CongratulationDelaySeconds), async () => { if (player.PlayerState.CurrentState != PlayerState.EnteredWorld || player.CurrentMap is not { } map) @@ -1181,12 +1273,33 @@ private void Congratulate(Player player, BotChatConfiguration configuration, ICo var values = CreateBotValues(bot); values["player"] = _ => playerName; - values["level"] = _ => playerLevel; - values["resets"] = _ => playerResets; + addValues(values); await BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture, toward: player).ConfigureAwait(false); }); } + /// + /// Determines whether the monster is one of the configured notable monsters - parsed once per + /// configured text, since the check runs for every monster spawn. + /// + private bool IsNotableMonster(short number, BotChatConfiguration configuration) + { + var text = configuration.NotableMonsterNumbers ?? string.Empty; + var cache = this._notableMonsters; + if (!ReferenceEquals(cache.Text, text)) + { + var numbers = text + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(part => short.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : (short)-1) + .Where(n => n >= 0) + .ToHashSet(); + cache = (text, numbers); + this._notableMonsters = cache; + } + + return cache.Numbers.Contains(number); + } + private async ValueTask GreetAsync(Player player, BotChatConfiguration configuration) { if (player.PlayerState.CurrentState != PlayerState.EnteredWorld diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 5942184234..abc9c0396a 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5502,5 +5502,35 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_EventReminderLines_Description text. public static string BotChatConfiguration_EventReminderLines_Description => ResourceManager.GetString("BotChatConfiguration_EventReminderLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_RareDropChancePercent_Name text. + public static string BotChatConfiguration_RareDropChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_RareDropChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_RareDropChancePercent_Description text. + public static string BotChatConfiguration_RareDropChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_RareDropChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterChancePercent_Name text. + public static string BotChatConfiguration_NotableMonsterChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_NotableMonsterChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterChancePercent_Description text. + public static string BotChatConfiguration_NotableMonsterChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_NotableMonsterChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterNumbers_Name text. + public static string BotChatConfiguration_NotableMonsterNumbers_Name => ResourceManager.GetString("BotChatConfiguration_NotableMonsterNumbers_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterNumbers_Description text. + public static string BotChatConfiguration_NotableMonsterNumbers_Description => ResourceManager.GetString("BotChatConfiguration_NotableMonsterNumbers_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_RareDropLines_Name text. + public static string BotChatConfiguration_RareDropLines_Name => ResourceManager.GetString("BotChatConfiguration_RareDropLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_RareDropLines_Description text. + public static string BotChatConfiguration_RareDropLines_Description => ResourceManager.GetString("BotChatConfiguration_RareDropLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterLines_Name text. + public static string BotChatConfiguration_NotableMonsterLines_Name => ResourceManager.GetString("BotChatConfiguration_NotableMonsterLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_NotableMonsterLines_Description text. + public static string BotChatConfiguration_NotableMonsterLines_Description => ResourceManager.GetString("BotChatConfiguration_NotableMonsterLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 294cb08002..7967766532 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2027,4 +2027,14 @@ Chance that a bot in a town where a player stands reminds of an opened Blood Castle, Devil Square or Chaos Castle in the normal chat - once per town and opening, only a bot which meets the entry requirements. Independent of the /post chances. 0 = never. Event reminders in town Said in the normal chat by a bot in a town when an event entrance opened. Placeholders: {event}, {map}, {location}, {level}. + Rare drop comment chance (%) + Chance that the nearest bot comments on an excellent or ancient item a player's kill dropped ("nice drop!"). 0 = never. + Notable monster call-out chance (%) + Chance that a bot calls out a notable monster which appeared near it while a player sees it. 0 = never. + Notable monsters + Comma-separated numbers of the monsters a bot calls out when they appear near it. By default the golden monsters, the Red Dragon and the White Wizard. + Rare drop comments + Said by the nearest bot when a player's kill dropped an excellent or ancient item. Placeholders: {player}, {item}, {map}. + Notable monster call-outs + Said by a bot when a notable monster appeared near it. Placeholders: {monster}, {map}, {location}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index c19b1c756e..b7c0de3500 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -453,6 +453,17 @@ public void DefaultLinesComeWithGestures() Assert.That((byte)BotChatGesture.Greeting, Is.EqualTo(131)); } + /// + /// The bots call out the golden monsters of the invasions by default - the Golden Goblin is one. + /// + [Test] + public void DefaultNotableMonstersAreTheGoldenOnes() + { + var numbers = BotChatConfiguration.CreateDefault().NotableMonsterNumbers.Split(',').Select(n => n.Trim()); + + Assert.That(numbers, Does.Contain("78").And.Contain("43").And.Contain("44")); + } + /// /// A server which switches the plugin on has something to say in every situation: each list of /// the default configuration has lines. From c9f6b183a279cd4209cfc35ff59b6579eb41db5e Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:20:59 +0100 Subject: [PATCH 31/44] feat(bots): remember the players a bot was in a party with A bot greeted a player it had spent an hour with in a party like a stranger. Now the bots remember their party companions (until the server restarts): when such a player enters the game, an old companion nearby greets first, with a line of its own ("hey Hero, together again?"), and the same lines welcome the player who invites the bot again. --- docs-website/docs/server-features/bots.md | 1 + src/GameLogic/Bots/BotChatConfiguration.cs | 19 ++++++++++ src/GameLogic/Bots/BotChatPlugIn.cs | 24 +++++++++---- src/GameLogic/Bots/BotChatThrottle.cs | 35 +++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 6 ++++ src/GameLogic/Properties/PlugInResources.resx | 2 ++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 17 +++++++++ 7 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index ab9f02d9c4..771d81a98a 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -348,6 +348,7 @@ only talks about what it could actually do: | A player leveled up or made a reset | normal chat, "gz", `{player}`, `{level}`, `{resets}` | the nearest bot which sees the player — 30 % chance, once per player in 10 minutes | | A player's kill dropped an excellent or ancient item | normal chat ("nice drop!"), `{item}` | the nearest bot - 50 % chance | | A golden monster, the Red Dragon or the White Wizard appeared near a bot | normal chat ("Golden Goblin here!"), `{monster}` | the nearest bot, while a player sees it - once per map and monster in two minutes | +| A player the bot was in a party with before enters the game or invites it again | normal / party chat ("good to see you again"), `{player}` | the bot - it remembers its party companions until the server restarts, and a companion nearby greets first | | The bot accepted a player's party invitation | party chat, `{player}` | the bot | | An event the bot went through with a player's party ended | party chat, `{event}` | one bot per party | | A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot | diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 4b942d2464..3901fa7cc4 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -343,6 +343,18 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_GreetingLines_Description))] public ICollection GreetingLines { get; set; } = new List(); + /// + /// Gets or sets the lines with which a bot greets a player it was in a party with before - instead + /// of the when the player enters the game, and of the + /// when the player invites it again. The bots remember their party + /// companions until the server restarts. Placeholders: {player}, {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_FriendLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_FriendLines_Description))] + public ICollection FriendLines { get; set; } = new List(); + /// /// Gets or sets the lines a bot says in the party chat after it accepted a player's party invitation. /// Placeholders: {player} (the inviter), {level}, {map}, {location}. @@ -609,6 +621,12 @@ public static BotChatConfiguration CreateDefault() BotChatTimeOfDay.Night, ("late night grind, {player}?", "nocny exp, {player}?"), ("{player}, can't sleep either?", "{player}, też nie możesz spać?")); + Add( + configuration.FriendLines, + ("{player}! good to see you again", "{player}! dobrze cię znowu widzieć"), + ("hey {player}, together again?", "hej {player}, znowu razem?"), + ("my old party buddy {player} :)", "mój stary kompan {player} :)"), + ("{player}, ready for round two?", "{player}, gotowy na drugą rundę?")); Add( configuration.PartyJoinedLines, ("thx for invite", "dzięki za zaproszenie"), @@ -820,6 +838,7 @@ public static BotChatConfiguration CreateDefault() SetGesture(configuration.DuelDeclineLines, BotChatGesture.Awkward); SetGesture(configuration.ConversationJoinLines, BotChatGesture.Smile); SetGesture(configuration.RareDropLines, BotChatGesture.Clap); + SetGesture(configuration.FriendLines, BotChatGesture.Greeting); foreach (var reply in configuration.WhisperReplies) { reply.Gesture = reply.Keywords.StartsWith("hi,", StringComparison.Ordinal) ? BotChatGesture.Greeting diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index e31755a51c..313cc1397c 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -314,10 +314,20 @@ public ValueTask PlayerStateChangedAsync(Player player, State previousState, Sta /// public void BotJoinedParty(Player bot, Player inviter) { - if (this.Configuration is not { PartyChatChancePercent: > 0 } configuration + if (this.Configuration is not { } partyConfiguration) + { + return; + } + + // An old friend gets a warmer welcome; and from now on, the inviter and the party are friends. + var throttle = this.GetThrottle(bot.GameContext); + var inviterIsFriend = inviter.SelectedCharacter?.Name is { } name && throttle.IsFriend(bot, name); + throttle.RememberFriends(bot, (bot.Party?.PartyList ?? []).OfType().Append(inviter).Where(p => p is not OfflinePlayer).Select(p => p.SelectedCharacter?.Name).OfType()); + + if (partyConfiguration is not { PartyChatChancePercent: > 0 } configuration || !Rand.NextRandomBool(configuration.PartyChatChancePercent) - || PickLine(configuration.PartyJoinedLines) is not { } line - || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) + || PickLine(inviterIsFriend && configuration.FriendLines.Count > 0 ? configuration.FriendLines : configuration.PartyJoinedLines) is not { } line + || !throttle.TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) { return; } @@ -1310,12 +1320,14 @@ private async ValueTask GreetAsync(Player player, BotChatConfiguration configura } var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); - var bot = FindGreeter(players, player, map); - var now = DateTime.UtcNow; var throttle = this.GetThrottle(player.GameContext); + + // An old party companion nearby greets first. + var bot = FindGreeter(players.Where(p => throttle.IsFriend(p, playerName)), player, map) ?? FindGreeter(players, player, map); + var now = DateTime.UtcNow; if (bot is null || !this.MayTalkUnasked(bot, configuration) - || this.PickLineFor(player, configuration.GreetingLines) is not { } line + || this.PickLineFor(player, throttle.IsFriend(bot, playerName) && configuration.FriendLines.Count > 0 ? configuration.FriendLines : configuration.GreetingLines) is not { } line || !throttle.TryReserveOccasion($"greeting {playerName}", now, configuration.GreetingCooldown) || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) { diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index b1363f251c..dbc6729dd9 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -63,6 +63,8 @@ internal sealed class BotChatThrottle private readonly List<(Player First, Player Second, DateTime Until)> _recentConversations = []; + private readonly ConditionalWeakTable> _friends = new(); + private long _nextPlayerScanTicks; private long _nextPostTicks; @@ -415,6 +417,39 @@ public void RememberConversation(Player first, Player second, DateTime until) } } + /// + /// Remembers that the bot was in a party with the players - until the server restarts. + /// + /// The bot. + /// The names of the players' characters. + public void RememberFriends(Player bot, IEnumerable characterNames) + { + var friends = this._friends.GetOrCreateValue(bot); + lock (friends) + { + friends.UnionWith(characterNames); + } + } + + /// + /// Determines whether the bot was in a party with the player before. + /// + /// The bot. + /// The name of the player's character. + /// true, if the bot knows the player. + public bool IsFriend(Player bot, string characterName) + { + if (!this._friends.TryGetValue(bot, out var friends)) + { + return false; + } + + lock (friends) + { + return friends.Contains(characterName); + } + } + private static bool TryReserve(ref long nextAllowedTicks, DateTime now, TimeSpan gap) { while (true) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index abc9c0396a..131b60be34 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5532,5 +5532,11 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_NotableMonsterLines_Description text. public static string BotChatConfiguration_NotableMonsterLines_Description => ResourceManager.GetString("BotChatConfiguration_NotableMonsterLines_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_FriendLines_Name text. + public static string BotChatConfiguration_FriendLines_Name => ResourceManager.GetString("BotChatConfiguration_FriendLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_FriendLines_Description text. + public static string BotChatConfiguration_FriendLines_Description => ResourceManager.GetString("BotChatConfiguration_FriendLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 7967766532..41300581f4 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2037,4 +2037,6 @@ Said by the nearest bot when a player's kill dropped an excellent or ancient item. Placeholders: {player}, {item}, {map}. Notable monster call-outs Said by a bot when a notable monster appeared near it. Placeholders: {monster}, {map}, {location}. + Old party companions + Said to a player the bot was in a party with before - instead of a greeting when the player enters the game, and instead of the party lines when the player invites it again. Remembered until the server restarts. Placeholders: {player}, {level}, {map}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index b7c0de3500..434f21a306 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -175,6 +175,23 @@ public async ValueTask APlayerCanChimeInOnceAsync() Assert.That(throttle.TakeRecentConversation(_ => true, Start.AddSeconds(31)), Is.Null); } + /// + /// A bot remembers the players it was in a party with - and only those. + /// + /// The task. + [Test] + public async ValueTask RemembersPartyCompanionsAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var throttle = new BotChatThrottle(); + + throttle.RememberFriends(bot, ["Hero", "Mage"]); + + Assert.That(throttle.IsFriend(bot, "Hero"), Is.True); + Assert.That(throttle.IsFriend(bot, "Stranger"), Is.False); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From e0a7f37cbbaeb9bc3f736e613c83ab5e99d6b32b Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 4 Nov 2026 21:21:27 +0100 Subject: [PATCH 32/44] feat(bots): let a game master make a bot talk with /botsay For staged scenes of an event - a bot telling of a dragon sighting before the invasion, a merchant bot advertising a GM's lottery - a game master can now make a bot say something: "/botsay ". The text is said as typed in the normal chat, to the players who see the bot. Only bots can be made to talk, never a player's character. --- docs-website/docs/server-features/bots.md | 4 ++ src/GameLogic/Bots/BotSayChatCommandPlugIn.cs | 63 +++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 6 ++ src/GameLogic/Properties/PlugInResources.resx | 2 + 4 files changed, 75 insertions(+) create mode 100644 src/GameLogic/Bots/BotSayChatCommandPlugIn.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 771d81a98a..26e7377831 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -459,6 +459,10 @@ talking (three normal chat messages within 30 seconds, by default), it says nothing unasked — no conversation, greeting, congratulation or comment. It still answers a player who talks to it. +A game master can make a bot talk with `/botsay ` - for staged +scenes of an event, or to spark a conversation. The text is said as typed in +the normal chat, to the players who see the bot; only bots can be made to talk. + The limits keep a large population from flooding the chat. All bots of a game server share one gap between two posts (`Minimum time between posts`, 3 minutes), each opened event type or started invasion is announced at most once, and each diff --git a/src/GameLogic/Bots/BotSayChatCommandPlugIn.cs b/src/GameLogic/Bots/BotSayChatCommandPlugIn.cs new file mode 100644 index 0000000000..575ed941ee --- /dev/null +++ b/src/GameLogic/Bots/BotSayChatCommandPlugIn.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// +/// A chat command for game masters which makes a server-side bot say something in the normal chat: +/// /botsay <bot> <text> - for staged scenes of an event, or a bot "joining" a +/// conversation the GM wants to spark. +/// +/// +/// The text is said as typed, to every player who sees the bot - it's not translated. Only bots can +/// be made to talk, never a player's character. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.BotSayChatCommandPlugIn_Name), Description = nameof(PlugInResources.BotSayChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("825B3883-EDD9-4638-BA85-7A2852A86997")] +[ChatCommandHelp(CommandKey, CharacterStatus.GameMaster)] +public class BotSayChatCommandPlugIn : IChatCommandPlugIn +{ + private const string CommandKey = "/botsay"; + + /// + public string Key => CommandKey; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var arguments = command.Length > CommandKey.Length ? command[CommandKey.Length..].Trim() : string.Empty; + var separator = arguments.IndexOf(' ', StringComparison.Ordinal); + if (separator <= 0) + { + return; + } + + var botName = arguments[..separator]; + var text = arguments[(separator + 1)..].Trim(); + if (text.Length == 0) + { + return; + } + + if (player.GameContext.GetPlayerByCharacterName(botName) is not OfflinePlayer bot + || bot.Account?.IsBot != true) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CharacterNotFound), botName).ConfigureAwait(false); + return; + } + + await BotChatSpeaker.SayAsync(bot, new LocalizedString(text), new Dictionary>()).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 131b60be34..95c1f12713 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5538,5 +5538,11 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_FriendLines_Description text. public static string BotChatConfiguration_FriendLines_Description => ResourceManager.GetString("BotChatConfiguration_FriendLines_Description", resourceCulture)!; + /// Gets the localized BotSayChatCommandPlugIn_Name text. + public static string BotSayChatCommandPlugIn_Name => ResourceManager.GetString("BotSayChatCommandPlugIn_Name", resourceCulture)!; + + /// Gets the localized BotSayChatCommandPlugIn_Description text. + public static string BotSayChatCommandPlugIn_Description => ResourceManager.GetString("BotSayChatCommandPlugIn_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 41300581f4..785bdcc3eb 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2039,4 +2039,6 @@ Said by a bot when a notable monster appeared near it. Placeholders: {monster}, {map}, {location}. Old party companions Said to a player the bot was in a party with before - instead of a greeting when the player enters the game, and instead of the party lines when the player invites it again. Remembered until the server restarts. Placeholders: {player}, {level}, {map}. + Chat command: /botsay + Handles the chat command '/botsay <bot> <text>'. A game master makes a server-side bot say the text in the normal chat, to the players who see it - e.g. for staged scenes of an event. Only bots, never a player's character. From 657e1d7314b26fde2cbde00af0287a6127d18745 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 5 Nov 2026 07:34:07 +0100 Subject: [PATCH 33/44] feat(bots): comment on wings Wings are the milestone of a MU character, yet the bots let them pass in silence - their own new pair as well as a player's. - A bot which put on the wings it earned at a level milestone shows them off ("finally, my wings!") while a player sees it. The new plugin point IBotWingsEarnedPlugIn reports the grant of BotWingHandler. At a level milestone which brings wings, the bot talks about the wings, not the level. - The nearest bot comments on the wings a player put on ("nice wings!") - once per player and pair until the server restarts, since the wings go on again after every change of equipment. - The new placeholder {wings} names the bot's wings with their level ("Wings of Soul +9"); the default whisper replies answer "what wings do you have?" with it. A bot without wings answers with one of the new answers without wings instead ("no wings yet, saving up"). One chance setting (50 %) for both comments; English and Polish default lines. --- docs-website/docs/server-features/bots.md | 11 ++- src/GameLogic/Bots/BotChatComposer.cs | 11 +++ src/GameLogic/Bots/BotChatConfiguration.cs | 69 +++++++++++++- src/GameLogic/Bots/BotChatPlugIn.cs | 91 +++++++++++++++++-- src/GameLogic/Bots/BotChatThrottle.cs | 13 +++ src/GameLogic/Bots/BotWingHandler.cs | 1 + src/GameLogic/Bots/IBotWingsEarnedPlugIn.cs | 24 +++++ .../Properties/PlugInResources.Designer.cs | 24 +++++ src/GameLogic/Properties/PlugInResources.resx | 10 +- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 45 +++++++++ 10 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 src/GameLogic/Bots/IBotWingsEarnedPlugIn.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 26e7377831..a976e4f144 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -347,6 +347,8 @@ only talks about what it could actually do: | A player entered the game (not after a warp) | normal chat, `{player}` | the nearest bot which sees the player — once per player in 30 minutes | | A player leveled up or made a reset | normal chat, "gz", `{player}`, `{level}`, `{resets}` | the nearest bot which sees the player — 30 % chance, once per player in 10 minutes | | A player's kill dropped an excellent or ancient item | normal chat ("nice drop!"), `{item}` | the nearest bot - 50 % chance | +| The bot put on the new wings it earned at a level milestone | normal chat ("finally, my wings!"), `{wings}` | the bot, while a player sees it - 50 % chance; at a level milestone, instead of the level comment | +| A player put on wings | normal chat ("nice wings!"), `{player}`, `{wings}` | the nearest bot which sees the player - 50 % chance, once per player and pair until the server restarts | | A golden monster, the Red Dragon or the White Wizard appeared near a bot | normal chat ("Golden Goblin here!"), `{monster}` | the nearest bot, while a player sees it - once per map and monster in two minutes | | A player the bot was in a party with before enters the game or invites it again | normal / party chat ("good to see you again"), `{player}` | the bot - it remembers its party companions until the server restarts, and a companion nearby greets first | | The bot accepted a player's party invitation | party chat, `{player}` | the bot | @@ -403,11 +405,11 @@ whose keyword matches the whisper — whole words, ignoring case and punctuation the longest match winning, so "hi, how are you?" gets an answer to the question rather than a greeting. When nothing matches, it uses a reply without keywords. The default lines cover greetings, "how are you", the level and class, where the -bot is, where to hunt, party, help, trade, thanks and goodbye. +bot is, where to hunt, its wings, party, help, trade, thanks and goodbye. Placeholders in a line are filled in when it is said: `{event}`, `{map}`, `{level}`, `{class}`, `{player}` (whisper replies), `{partner}` (the other bot of -a conversation), and `{location}` — the map with the +a conversation), `{wings}` (see below), and `{location}` — the map with the bot's coordinates the way players write them, e.g. "Devias 215, 40", so a player can find it with the mini map. The default "where are you?" replies use it. In an event post, `{location}` is the meeting point; in an invasion post, just the @@ -416,6 +418,11 @@ every player sees the translation of their own language, or the English text when it has none. The default lines come with a Polish translation, and the default keywords understand Polish whispers too. +`{wings}` names the bot's wings with their level, e.g. "Wings of Soul +9"; in a +comment on a player's wings, the player's. A bot without wings doesn't answer +"what wings do you have?" with an empty name: a whisper reply with `{wings}` is +swapped for one of the *answers without wings* ("no wings yet, saving up"). + A bot answers a message in the language it is written in: a Polish whisper gets a Polish answer, an English one an English answer, whatever the player's account language. The *Message languages* list holds the typical words and diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index db887abb31..a1055241eb 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -121,6 +121,17 @@ internal static string FormatLocation(string? mapName, Point? position) ? string.Create(CultureInfo.InvariantCulture, $"{mapName} {point.X}, {point.Y}") : mapName ?? string.Empty; + /// + /// Formats an item the way players name it in the chat, e.g. "Wings of Soul +9". + /// + /// The (translated) name of the item. + /// The item level; +0 isn't written. + /// The formatted item. + internal static string FormatItem(string? itemName, int level) + => level > 0 + ? string.Create(CultureInfo.InvariantCulture, $"{itemName} +{level}") + : itemName ?? string.Empty; + /// /// Recognizes the language of a player's message by its typical words and letters: the language /// with the most hits wins, a tie or no hit at all leaves it open. diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 3901fa7cc4..699140f4eb 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -138,6 +138,14 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_RareDropChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_RareDropChancePercent_Description))] public int RareDropChancePercent { get; set; } = 50; + /// + /// Gets or sets the chance, in percent, that a bot comments on wings: its own new pair ("finally, + /// my wings!"), or the wings a player next to it put on ("nice wings!"). + /// + [Range(0, 100)] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WingsChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_WingsChancePercent_Description))] + public int WingsChancePercent { get; set; } = 50; + /// /// Gets or sets the chance, in percent, that a bot calls out a notable monster which appeared /// near it while a player sees it (see ). @@ -313,7 +321,8 @@ public class BotChatConfiguration /// /// Gets or sets the answers of a bot to a player who whispered to it, or mentioned its name in the normal /// chat next to it. The bot answers the same way: by whisper, or in the normal chat. - /// Placeholders: {player}, {level}, {map}, {location}. + /// Placeholders: {player}, {level}, {map}, {location}, {wings} (see + /// ). /// [MemberOfAggregate] [ScaffoldColumn(true)] @@ -405,6 +414,38 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_RareDropLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_RareDropLines_Description))] public ICollection RareDropLines { get; set; } = new List(); + /// + /// Gets or sets the lines with which a bot shows off the new wings it earned at a level milestone, + /// while a player sees it. Placeholders: {wings}, {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_OwnWingsLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_OwnWingsLines_Description))] + public ICollection OwnWingsLines { get; set; } = new List(); + + /// + /// Gets or sets the lines with which the nearest bot comments on the wings a player put on - once + /// per player and pair until the server restarts. Placeholders: {player}, {wings} + /// (the player's), {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PlayerWingsLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_PlayerWingsLines_Description))] + public ICollection PlayerWingsLines { get; set; } = new List(); + + /// + /// Gets or sets the answers of a bot without wings to a question about them: a whisper reply with + /// {wings} would name nothing, so the bot answers with one of these instead. + /// Placeholders: {player}, {level}, {map}. + /// + [MemberOfAggregate] + [ScaffoldColumn(true)] + [CollapsedByDefault] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WithoutWingsLines_Name), Description = nameof(PlugInResources.BotChatConfiguration_WithoutWingsLines_Description))] + public ICollection WithoutWingsLines { get; set; } = new List(); + /// /// Gets or sets the lines with which a bot calls out a notable monster which appeared near it. /// Placeholders: {monster}, {map}, {location}. @@ -693,6 +734,25 @@ public static BotChatConfiguration CreateDefault() ("whoa, {item}!", "o kurczę, {item}!"), ("lucky! gz on the {item}", "fart! gratki za {item}"), ("{player}, that's a good one", "{player}, to jest dobre")); + Add( + configuration.OwnWingsLines, + ("finally, my wings!", "w końcu moje skrzydła!"), + ("{wings}! all that grinding paid off", "{wings}! cały ten grind się opłacił"), + ("look, new wings :)", "patrzcie, nowe skrzydła :)"), + ("{level} and {wings}, what a day", "{level} i {wings}, co za dzień"), + ("at last, {wings}", "nareszcie, {wings}")); + Add( + configuration.PlayerWingsLines, + ("nice wings, {player}!", "ładne skrzydła, {player}!"), + ("whoa, {wings}!", "o kurczę, {wings}!"), + ("{wings}? respect", "{wings}? szacun"), + ("gz on the wings, {player}", "gratki skrzydeł, {player}"), + ("i want those wings too", "też chcę takie skrzydła")); + Add( + configuration.WithoutWingsLines, + ("no wings yet, saving up", "jeszcze bez skrzydeł, zbieram"), + ("not yet, a few more levels", "jeszcze nie, jeszcze parę lvli"), + ("none yet, still grinding", "jeszcze nie mam, expię dalej")); Add( configuration.NotableMonsterLines, ("{monster} here!", "{monster} tutaj!"), @@ -799,6 +859,11 @@ public static BotChatConfiguration CreateDefault() ("depends on your level, try lost tower", "zależy od lvla, spróbuj lost tower"), ("atlans is good if you have a party", "atlans jest spoko, jak masz grupę"), ("i like {map} at my level", "na moim lvlu lubię {map}")); + AddReplies( + "wings, wing, your wings, nice wings, skrzydla, skrzydła, skrzydelka, skrzydełka, jakie skrzydla, jakie skrzydła", + ("{wings}", "{wings}"), + ("{wings}, took me a while", "{wings}, trochę to trwało"), + ("{wings} :)", "{wings} :)")); AddReplies( "party, pt, grupa, grupe, grupę, do grupy", ("sure, invite me", "jasne, zaproś mnie"), @@ -838,6 +903,8 @@ public static BotChatConfiguration CreateDefault() SetGesture(configuration.DuelDeclineLines, BotChatGesture.Awkward); SetGesture(configuration.ConversationJoinLines, BotChatGesture.Smile); SetGesture(configuration.RareDropLines, BotChatGesture.Clap); + SetGesture(configuration.OwnWingsLines, BotChatGesture.Win); + SetGesture(configuration.PlayerWingsLines, BotChatGesture.Respect); SetGesture(configuration.FriendLines, BotChatGesture.Greeting); foreach (var reply in configuration.WhisperReplies) { diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 313cc1397c..cb2d0afd27 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -57,6 +57,8 @@ public class BotChatPlugIn IDuelRequestedPlugIn, IMonsterItemDroppedPlugIn, IObjectAddedToMapPlugIn, + IBotWingsEarnedPlugIn, + IItemMovedPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -241,6 +243,7 @@ public void CharacterLeveledUp(Player player) var level = GetLevel(player); if (level < configuration.LevelMilestoneStep || level % configuration.LevelMilestoneStep != 0 + || (configuration.WingsChancePercent > 0 && BotWingHandler.PlanNextGrant(player) is not null) || !Rand.NextRandomBool(configuration.LevelMilestoneChancePercent) || PickLine(configuration.LevelMilestoneLines) is not { } line || !this.GetThrottle(player.GameContext).TryReserveChat(player, DateTime.UtcNow, configuration.BotChatCooldown)) @@ -282,15 +285,16 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag return; } + var text = GetAnswerText(receiver, line.Text, configuration); var values = CreateBotValues(receiver); var senderName = sender.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); RunLater( receiver.GameContext, - TypingDelay(line.Text), + TypingDelay(text), () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld - ? BotChatSpeaker.WhisperAsync(receiver, sender, line.Text, values, language, line.Gesture) + ? BotChatSpeaker.WhisperAsync(receiver, sender, text, values, language, line.Gesture) : ValueTask.CompletedTask); } @@ -458,13 +462,14 @@ await sender.ForEachObservingAsync( return; } + var text = GetAnswerText(bot, line.Text, configuration); var values = CreateBotValues(bot); values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); RunLater( bot.GameContext, - TypingDelay(line.Text), - () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, language, line.Gesture, sender) : ValueTask.CompletedTask); + TypingDelay(text), + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, text, values, language, line.Gesture, sender) : ValueTask.CompletedTask); } /// @@ -571,6 +576,52 @@ public async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObjec RunLater(bot.GameContext, RandomDelay(NotableMonsterDelaySeconds), () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, line.Text, values, gesture: line.Gesture) : ValueTask.CompletedTask); } + /// + public void BotEarnedWings(Player bot, Item wings) + { + if (this.Configuration is not { WingsChancePercent: > 0 } configuration + || !IsBot(bot) + || !Rand.NextRandomBool(configuration.WingsChancePercent) + || PickLine(configuration.OwnWingsLines) is not { } line) + { + return; + } + + RunLater(bot.GameContext, RandomDelay(LevelMilestoneDelaySeconds), async () => + { + // New wings are something to show off - to nobody, it would be wasted. + if (!IsAvailable(bot) + || !this.MayTalkUnasked(bot, configuration) + || !await HasHumanObserverAsync(bot).ConfigureAwait(false) + || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) + { + return; + } + + await BotChatSpeaker.SayAsync(bot, line.Text, CreateBotValues(bot), gesture: line.Gesture).ConfigureAwait(false); + }); + } + + /// + public ValueTask ItemMovedAsync(Player player, Item item) + { + // Wings go on again after every change of equipment, so each pair of a player gets one comment + // at most - remembered before the dice, or re-equipping would just roll again. + if (this.Configuration is { WingsChancePercent: > 0 } configuration + && player is not OfflinePlayer + && ReferenceEquals(GetWings(player), item) + && item.Definition is { } definition + && player.SelectedCharacter?.Name is { } playerName + && this.GetThrottle(player.GameContext).TryRememberWings(playerName, string.Create(CultureInfo.InvariantCulture, $"{definition.Group}/{definition.Number}+{item.Level}")) + && Rand.NextRandomBool(configuration.WingsChancePercent)) + { + var wingsText = GetWingsText(item); + this.SayToPlayerLater(player, configuration, configuration.PlayerWingsLines, $"wings {playerName}", values => values["wings"] = wingsText); + } + + return ValueTask.CompletedTask; + } + /// public async ValueTask PostMessageSentAsync(Player sender, string message) { @@ -856,9 +907,34 @@ private static Dictionary> CreateBotValues(Pla ["class"] = culture => characterClass?.GetTranslation(culture) ?? string.Empty, ["map"] = culture => map?.GetTranslation(culture) ?? string.Empty, ["location"] = culture => BotChatComposer.FormatLocation(map?.GetTranslation(culture), position), + ["wings"] = GetWingsText(GetWings(bot)), }; } + private static Item? GetWings(Player player) => player.Inventory?.GetItem(InventoryConstants.WingsSlot); + + /// + /// Gets the {wings} value of a pair of wings, e.g. "Wings of Soul +9" - in the receiver's + /// language; empty without wings. + /// + private static Func GetWingsText(Item? wings) + { + var name = wings?.Definition?.Name; + var level = wings?.Level ?? 0; + return culture => name is { } itemName ? BotChatComposer.FormatItem(itemName.GetTranslation(culture), level) : string.Empty; + } + + /// + /// Gets the text a bot answers with: the chosen reply - unless it names the bot's wings and the + /// bot has none, then one of the . + /// + private static LocalizedString GetAnswerText(Player bot, LocalizedString text, BotChatConfiguration configuration) + => text.ValueInNeutralLanguage.Contains("{wings}", StringComparison.Ordinal) + && GetWings(bot) is null + && PickLine(configuration.WithoutWingsLines) is { } withoutWings + ? withoutWings.Text + : text; + /// /// Runs the action after the delay, without holding up the caller - the plugin points are called /// from the game logic, which must not wait for a bot's "typing time". @@ -1027,10 +1103,11 @@ private void AnswerInParty(Player sender, string senderName, string message, Bot return; } + var text = GetAnswerText(speaker, line.Text, configuration); var values = CreateBotValues(speaker); values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); - RunLater(speaker.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(speaker, line.Text, values, language, line.Gesture, sender)); + RunLater(speaker.GameContext, TypingDelay(text), () => BotChatSpeaker.PartySayAsync(speaker, text, values, language, line.Gesture, sender)); } /// @@ -1051,12 +1128,14 @@ private void AnswerChimingIn(Player sender, string senderName, string message, B ? reply.Text : this.PickLineFor(sender, configuration.ConversationJoinLines)?.Text; if (bot is null - || text is not { } answer + || text is not { } chosen || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) { return; } + var answer = GetAnswerText(bot, chosen, configuration); + var values = CreateBotValues(bot); values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index dbc6729dd9..a6f211b761 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -57,6 +57,8 @@ internal sealed class BotChatThrottle private readonly ConcurrentDictionary _knownResets = new(); + private readonly ConcurrentDictionary<(string Character, string Wings), bool> _seenWings = new(); + private readonly ConditionalWeakTable> _lastLines = new(); private readonly List<(GameMap Map, Point Position, DateTime Time)> _humanChat = []; @@ -246,6 +248,17 @@ public bool TryUpdateKnownResets(string characterName, int resets) return resets > previous && this._knownResets.TryUpdate(characterName, resets, previous); } + /// + /// Remembers that the bots saw a character put on a pair of wings, and tells whether it's the + /// first time - the wings go on again after every change of equipment, but a bot comments only + /// once per character and pair until the server restarts. + /// + /// The name of the character. + /// The pair of wings, e.g. its item definition and level. + /// true, if the bots didn't see the character put on this pair before. + public bool TryRememberWings(string characterName, string wingsKey) + => this._seenWings.TryAdd((characterName, wingsKey), true); + /// /// Determines whether the player is due to overhear a conversation. A player seen for the first /// time waits one interval first - the player just entered the game. diff --git a/src/GameLogic/Bots/BotWingHandler.cs b/src/GameLogic/Bots/BotWingHandler.cs index 9bb98ad347..a99282932a 100644 --- a/src/GameLogic/Bots/BotWingHandler.cs +++ b/src/GameLogic/Bots/BotWingHandler.cs @@ -88,6 +88,7 @@ public static async ValueTask TryAdvanceWingsAsync(OfflinePlayer player) } player.Logger.LogDebug("Bot '{Name}' earned its tier {Tier} wings: '{Wings}'.", player.Name, plan.Tier, wings); + player.GameContext.PlugInManager.GetPlugInPoint()?.BotEarnedWings(player, wings); try { diff --git a/src/GameLogic/Bots/IBotWingsEarnedPlugIn.cs b/src/GameLogic/Bots/IBotWingsEarnedPlugIn.cs new file mode 100644 index 0000000000..3045b4fef8 --- /dev/null +++ b/src/GameLogic/Bots/IBotWingsEarnedPlugIn.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// Plugins which are called when a server-side bot put on the new wings it earned at a level +/// milestone (see ). +/// +[Guid("A1E5B0F4-E58D-4D07-A01E-3F2AF82267C0")] +[PlugInPoint("Bot earned wings", "Plugins which are called when a server-side bot put on the new wings it earned at a level milestone.")] +public interface IBotWingsEarnedPlugIn +{ + /// + /// Is called when the bot put on its new wings. + /// + /// The bot. + /// The new wings, already in the wing slot. + void BotEarnedWings(Player bot, Item wings); +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 95c1f12713..65e7376f2d 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5544,5 +5544,29 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotSayChatCommandPlugIn_Description text. public static string BotSayChatCommandPlugIn_Description => ResourceManager.GetString("BotSayChatCommandPlugIn_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_WingsChancePercent_Name text. + public static string BotChatConfiguration_WingsChancePercent_Name => ResourceManager.GetString("BotChatConfiguration_WingsChancePercent_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WingsChancePercent_Description text. + public static string BotChatConfiguration_WingsChancePercent_Description => ResourceManager.GetString("BotChatConfiguration_WingsChancePercent_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_OwnWingsLines_Name text. + public static string BotChatConfiguration_OwnWingsLines_Name => ResourceManager.GetString("BotChatConfiguration_OwnWingsLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_OwnWingsLines_Description text. + public static string BotChatConfiguration_OwnWingsLines_Description => ResourceManager.GetString("BotChatConfiguration_OwnWingsLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PlayerWingsLines_Name text. + public static string BotChatConfiguration_PlayerWingsLines_Name => ResourceManager.GetString("BotChatConfiguration_PlayerWingsLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PlayerWingsLines_Description text. + public static string BotChatConfiguration_PlayerWingsLines_Description => ResourceManager.GetString("BotChatConfiguration_PlayerWingsLines_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WithoutWingsLines_Name text. + public static string BotChatConfiguration_WithoutWingsLines_Name => ResourceManager.GetString("BotChatConfiguration_WithoutWingsLines_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_WithoutWingsLines_Description text. + public static string BotChatConfiguration_WithoutWingsLines_Description => ResourceManager.GetString("BotChatConfiguration_WithoutWingsLines_Description", resourceCulture)!; + } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 785bdcc3eb..b756c8b1cc 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1936,7 +1936,7 @@ Level milestones Said in the normal chat when the bot reached a level milestone. Placeholders: {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Whisper replies - Answers to a player who whispered to the bot or mentioned its name in the normal chat next to it. The best keyword match wins; replies without keywords are the fallback. Placeholders: {player}, {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). + Answers to a player who whispered to the bot or mentioned its name in the normal chat next to it. The best keyword match wins; replies without keywords are the fallback. Placeholders: {player}, {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"), {wings} (the bot's wings; a bot without wings answers with one of the answers without wings). Greetings Said by the nearest bot in the normal chat when a player entered the game. Placeholders: {player}, {level}, {map}, {location} (the bot's map and coordinates, e.g. "Devias 215, 40"). Party: joined @@ -2041,4 +2041,12 @@ Said to a player the bot was in a party with before - instead of a greeting when the player enters the game, and instead of the party lines when the player invites it again. Remembered until the server restarts. Placeholders: {player}, {level}, {map}. Chat command: /botsay Handles the chat command '/botsay <bot> <text>'. A game master makes a server-side bot say the text in the normal chat, to the players who see it - e.g. for staged scenes of an event. Only bots, never a player's character. + Wings comment chance (%) + Chance that a bot comments on wings: its own new pair earned at a level milestone ("finally, my wings!"), or the wings a player next to it put on ("nice wings!"). 0 = never. + Own new wings + Said by a bot which put on the new wings it earned at a level milestone, while a player sees it. Placeholders: {wings}, {level}, {map}. + Player's wings comments + Said by the nearest bot when a player put on wings - once per player and pair until the server restarts. Placeholders: {player}, {wings} (the player's), {map}. + Answers without wings + Said instead of a whisper reply with {wings} when the bot has no wings yet ("no wings yet, saving up"). Placeholders: {player}, {level}, {map}. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 434f21a306..91dd715d3c 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -307,6 +307,32 @@ public void FormatsALocationLikePlayersDo() Assert.That(BotChatComposer.FormatLocation("Devias", null), Is.EqualTo("Devias")); } + /// + /// An item is named the way players name it - with its level, but without a "+0". + /// + [Test] + public void FormatsAnItemLikePlayersDo() + { + Assert.That(BotChatComposer.FormatItem("Wings of Soul", 9), Is.EqualTo("Wings of Soul +9")); + Assert.That(BotChatComposer.FormatItem("Wings of Elf", 0), Is.EqualTo("Wings of Elf")); + } + + /// + /// A player puts the same wings on again after every change of equipment; the bots comment on a + /// pair only once per character - a new pair, or the same one upgraded, is news again. + /// + [Test] + public void CommentsOnAPairOfWingsOnce() + { + var throttle = new BotChatThrottle(); + + Assert.That(throttle.TryRememberWings("Hero", "12/4+9"), Is.True); + Assert.That(throttle.TryRememberWings("Hero", "12/4+9"), Is.False); + Assert.That(throttle.TryRememberWings("Hero", "12/4+10"), Is.True); + Assert.That(throttle.TryRememberWings("Hero", "12/36+0"), Is.True); + Assert.That(throttle.TryRememberWings("Other", "12/4+9"), Is.True); + } + /// /// A whisper is answered by the lines whose keywords match it - ignoring case and punctuation, /// preferring the longest match, and matching whole words only. Without a match, a reply @@ -420,6 +446,25 @@ public void DefaultRepliesAnswerHowAreYou() Assert.That(reply?.Keywords, Does.Contain("how are you")); } + /// + /// The default configuration answers a question about the bot's wings by naming them - in English + /// and in Polish, with or without diacritics. + /// + /// The whispered message. + [TestCase("nice wings!")] + [TestCase("what wings do you have?")] + [TestCase("jakie masz skrzydła?")] + [TestCase("ladne skrzydla")] + public void DefaultRepliesNameTheWings(string message) + { + var configuration = BotChatConfiguration.CreateDefault(); + + var reply = BotChatComposer.PickWhisperReply(configuration.WhisperReplies, message, _ => 0); + + Assert.That(reply?.Text.ValueInNeutralLanguage, Does.Contain("{wings}")); + Assert.That(configuration.WithoutWingsLines.Select(l => l.Text.ValueInNeutralLanguage), Has.None.Contain("{wings}")); + } + /// /// The default lines come with a Polish translation: a Polish player who whispers in Polish gets /// a fitting answer in Polish, also when typing without diacritics. From af9bce87678fa56d03fcf347583300f31fa03b09 Mon Sep 17 00:00:00 2001 From: nolt Date: Sat, 7 Nov 2026 21:22:56 +0100 Subject: [PATCH 34/44] fix(bots): come to a meeting place through the warp which leads there A player who invited a bot from Devias3 227 227 had it walk all the way from the town of Devias, and "devias3" alone wasn't understood at all: the bot only knew the names of the maps, and always entered a map through its spawn gate. The floors of the Lost Tower and the Dungeon, and Kanturu Ruins 3, aren't even connected by walkable ground to the spawn gate, so a bot which was called there gave up on the way. - The whisper may name a warp of the warp list ("devias3 227 227", "losttower7"), in any of its translations. A map and a warp of the same name ("Devias") still mean the map. - With coordinates, the bot enters through the legal warp from which they are the shortest walk - the same search the party followers use to reach a leader on another floor, now shared. Blocked landing tiles are left out there, as the town gates of Lorencia and Devias have some, and a player who lands on one is moved to the spawn gate anyway. - A warp named without coordinates: the bot waits at its gate. - A bot already on the map, but farther away than the meeting's gate, warps through the gate (at most twice) instead of walking. - The level is checked for the warp which leads there, not for the map: a level 60 bot doesn't promise to come to Lost Tower 7. --- docs-website/docs/server-features/bots.md | 14 +- src/GameLogic/Bots/BotChatComposer.cs | 37 +- src/GameLogic/Bots/BotChatPlugIn.cs | 90 +++- src/GameLogic/Bots/BotEventMeeting.cs | 7 +- src/GameLogic/Bots/BotNavigator.cs | 455 ++++++++++-------- .../BotMeetingPlaceTest.cs | 270 +++++++++++ .../BotPartySearchTest.cs | 4 +- 7 files changed, 649 insertions(+), 228 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index a976e4f144..8feb8ab51e 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -379,12 +379,16 @@ to five free bots of the game server whose level fits; class words (`dk`, `bk`, `bm`, `dw`, `elf`, `mg`, `dl`, `sum`, `rf`, … — each meaning its whole class line) narrow it down. Each of them whispers to the player, a few seconds apart. The same search posted again calls five others. The player whispers back to a -bot where to meet — `devias 215 40`, a map name alone, or `here` for where the +bot where to meet — `devias 215 40`, a warp of the warp list like +`devias3 227 227` or `losttower7`, a map name alone, or `here` for where the player stands — and the bot warps there, walks up and waits five minutes for -the invitation. A map it may not go to (its level is too low, no warp leads -there) it declines; a whisper without a place gets asked again. Map names are -recognized in every translation of the map. The words, the number of bots and -the times are part of the configuration. +the invitation. It enters through the warp from which the place is the +shortest walk, like a player would: the floors of the Lost Tower or the +Dungeon can't be walked between at all, and the walk from the town of Devias +to Devias3 takes long. A place it may not go to (its level is too low for any +warp leading there) it declines; a whisper without a place gets asked again. +Map and warp names are recognized in every translation. The words, the number +of bots and the times are part of the configuration. The conversations make a town feel lived in, like the townsfolk of a role-playing game: "my potions are running low" — "the shop here sells them diff --git a/src/GameLogic/Bots/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs index a1055241eb..60204317a9 100644 --- a/src/GameLogic/Bots/BotChatComposer.cs +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -181,24 +181,33 @@ internal static bool ContainsAnyKeyword(string message, string? keywords) } /// - /// Reads a meeting place from a player's message like "devias 215 40": the map whose name - /// (in any of its translations) is in the message, and the first two numbers after removing the - /// name as the coordinates. + /// Reads a meeting place from a player's message like "devias 215 40" or "devias3 227 227": the map + /// or the entry of the warp list whose name (in any of its translations) is in the message, and the + /// first two numbers after removing the name as the coordinates. /// /// The player's message. /// The maps of the game. - /// The map and, if the message names them, the coordinates; null when no map is named. + /// The warp list of the game - "devias3" or "losttower7" name a warp, not a map. + /// + /// The map, the warp if the message names one, and the coordinates if the message names them; + /// null when no map is named. + /// /// - /// The longest matching name wins, so "lost tower" is not taken for a map called "tower". The - /// name is removed before the numbers are read, so a map like "Kalima 1" keeps its digit. + /// The longest matching name wins, so "lost tower" is not taken for a map called "tower"; between a + /// map and a warp of the same name ("Devias"), the map. The name is removed before the numbers are + /// read, so a map like "Kalima 1" keeps its digit. /// - internal static (GameMapDefinition Map, Point? Position)? FindPlace(string message, IEnumerable maps) + internal static (GameMapDefinition Map, WarpInfo? Warp, Point? Position)? FindPlace(string message, IEnumerable maps, IEnumerable warps) { var normalizedMessage = $" {Normalize(message)} "; - var best = maps - .SelectMany(map => GetNames(map).Select(name => (Map: map, Name: name))) + var mapNames = maps.SelectMany(map => GetNames(map.Name).Select(name => (Map: map, Warp: (WarpInfo?)null, Name: name))); + var warpNames = warps + .Where(warp => warp.Gate?.Map is not null) + .SelectMany(warp => GetNames(warp.Name).Select(name => (Map: warp.Gate!.Map!, Warp: (WarpInfo?)warp, Name: name))); + var best = mapNames.Concat(warpNames) .Where(candidate => normalizedMessage.Contains($" {candidate.Name} ", StringComparison.Ordinal)) .OrderByDescending(candidate => candidate.Name.Length) + .ThenBy(candidate => candidate.Warp is null ? 0 : 1) .FirstOrDefault(); if (best.Map is null) { @@ -213,14 +222,14 @@ internal static (GameMapDefinition Map, Point? Position)? FindPlace(string messa .Take(2) .ToList(); Point? position = numbers.Count == 2 ? new Point(numbers[0]!.Value, numbers[1]!.Value) : null; - return (best.Map, position); + return (best.Map, best.Warp, position); - static IEnumerable GetNames(GameMapDefinition map) - => map.Name.GetTranslations() + static IEnumerable GetNames(LocalizedString name) + => name.GetTranslations() .Select(translation => translation.Value) - .Append(map.Name.ValueInNeutralLanguage) + .Append(name.ValueInNeutralLanguage) .Select(Normalize) - .Where(name => name.Length > 0) + .Where(normalized => normalized.Length > 0) .Distinct(); } diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index cb2d0afd27..6172bf4df5 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -676,24 +676,79 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) /// The bot. /// The map. /// true, if the bot may warp to the map. - internal static bool MayWarpTo(Player bot, GameMapDefinition map) - { - if (bot.SelectedCharacter is not { } character) - { - return false; - } + internal static bool MayWarpTo(Player bot, GameMapDefinition map) => GetLegalWarps(bot, map).Any(); - if (BotFeaturePlugIn.GetConfiguration(bot.GameContext)?.IsMapForbidden(map) == true) + /// + /// Gets the entries of the warp list into the map which the bot may use, see . + /// A map like the Lost Tower has one for each floor, each with its own level requirement. + /// + /// The bot. + /// The map. + /// The warp entries. + internal static IEnumerable GetLegalWarps(Player bot, GameMapDefinition map) + { + if (bot.SelectedCharacter is not { } character + || BotFeaturePlugIn.GetConfiguration(bot.GameContext)?.IsMapForbidden(map) == true) { - return false; + return []; } var level = GetLevel(bot); - return bot.GameContext.Configuration.WarpList.Any(warp => + return bot.GameContext.Configuration.WarpList.Where(warp => warp.Gate?.Map?.Number == map.Number && character.GetEffectiveMoveLevelRequirement(warp.LevelRequirement) <= level); } + /// + /// Finds out how the bot gets to a meeting place a player named, like a player would: through the + /// warp the player named ("devias3"), or the warp from which the named coordinates are closest to + /// walk to - the floors of the Lost Tower, for one, aren't connected by walkable ground at all. + /// + /// The bot. + /// The map of the meeting place. + /// The warp the player named; null for the map alone. + /// The coordinates the player named; null for none. + /// + /// null, if the bot may not or can't get there; otherwise the gate to enter the map through + /// (null for a spawn gate) and the point to wait at (null for the safezone). + /// + /// + /// The terrain is read from the map definition, as the map may not be loaded. A warp which the bot's + /// level doesn't allow is out, even when it's the one the player named - like for a player. + /// + internal static (ExitGate? Gate, Point? Position)? FindMeetingEntry(Player bot, GameMapDefinition map, WarpInfo? warp, Point? position) + { + var legalWarps = GetLegalWarps(bot, map).ToList(); + if (legalWarps.Count == 0) + { + return null; + } + + if (position is { } target) + { + // Coordinates typed by hand may well point at a wall or a tree: the navigator waits next to + // them, within the same range as here. + const int waitingRange = 3; + var terrain = new GameMapTerrain(map); + var waitingPoint = terrain.WalkMap[target.X, target.Y] + ? target + : BotEventMeetingPlanner.FindStandingPoint(terrain, target, waitingRange); + return waitingPoint is { } point + && BotNavigator.FindBestReachableWarp(legalWarps, terrain.WalkMap, point, null, true, CancellationToken.None) is { Gate: { } gate } + ? (gate, target) + : null; + } + + if (warp is not null) + { + return legalWarps.Contains(warp) && warp.Gate is { } gate + ? (gate, new Point((byte)((gate.X1 + gate.X2) / 2), (byte)((gate.Y1 + gate.Y2) / 2))) + : null; + } + + return map.ExitGates.Any(gate => gate.IsSpawnGate) ? (null, null) : null; + } + /// /// Gets the time of day of an hour of the server's local time (see ). /// @@ -1030,12 +1085,12 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess var language = GetMessageLanguage(message, configuration); // A named map wins over "here": "i'm here, devias 215 40" names the place. - var place = BotChatComposer.FindPlace(message, bot.GameContext.Configuration.Maps); + var place = BotChatComposer.FindPlace(message, bot.GameContext.Configuration.Maps, bot.GameContext.Configuration.WarpList); if (place is null && BotChatComposer.ContainsAnyKeyword(message, configuration.PartyHereKeywords) && requester.CurrentMap is { } requesterMap) { - place = (requesterMap.Definition, requester.Position); + place = (requesterMap.Definition, null, requester.Position); } if (place is not { } found) @@ -1049,17 +1104,20 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess return; } - var (map, position) = found; + var (map, warp, position) = found; throttle.RemovePartyOffer(bot); - values["map"] = culture => map.Name.GetTranslation(culture) ?? string.Empty; - values["location"] = culture => BotChatComposer.FormatLocation(map.Name.GetTranslation(culture), position); + + // "devias3" is told back as the player wrote it, not as the map ("Devias"). + var placeName = warp?.Name ?? map.Name; + values["map"] = culture => placeName.GetTranslation(culture) ?? string.Empty; + values["location"] = culture => BotChatComposer.FormatLocation(placeName.GetTranslation(culture), position); if (!IsAvailable(bot) || !IsFreeToMeet(bot)) { // Taken into another party, or busy otherwise, since it answered: it lets the player be. return; } - if (!MayWarpTo(bot, map) || !map.ExitGates.Any(gate => gate.IsSpawnGate)) + if (FindMeetingEntry(bot, map, warp, position) is not { } entry) { if (PickLine(configuration.PartyCantGoLines) is { } cantGoLine) { @@ -1069,7 +1127,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess return; } - bot.EventMeeting = new BotEventMeeting(map, position, DateTime.UtcNow + configuration.PartyMeetingWaitTime); + bot.EventMeeting = new BotEventMeeting(map, entry.Position, DateTime.UtcNow + configuration.PartyMeetingWaitTime, entry.Gate); if (PickLine(configuration.PartyOnTheWayLines) is { } onTheWayLine) { this.WhisperLater(bot, requester, onTheWayLine, values, language); diff --git a/src/GameLogic/Bots/BotEventMeeting.cs b/src/GameLogic/Bots/BotEventMeeting.cs index e1ea7d0e83..c61a905b2a 100644 --- a/src/GameLogic/Bots/BotEventMeeting.cs +++ b/src/GameLogic/Bots/BotEventMeeting.cs @@ -20,4 +20,9 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// Castle is entered with the ticket alone, it has no entry NPC. /// /// When the bot stops waiting, e.g. when the event's entrance closes. -internal sealed record BotEventMeeting(GameMapDefinition Map, Point? NpcPosition, DateTime UntilUtc); +/// +/// The gate through which the bot enters the map: the one of the warp the player named ("devias3"), or +/// the one closest to the named coordinates - on a map like the Lost Tower, whose floors aren't connected +/// by walkable ground, the only way to get there; null for a spawn gate of the map. +/// +internal sealed record BotEventMeeting(GameMapDefinition Map, Point? NpcPosition, DateTime UntilUtc, ExitGate? Gate = null); diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index df6e39bbf5..9e5c936140 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -345,6 +345,14 @@ internal sealed class BotNavigator : AsyncDisposable private DateTimeOffset _nextEvaluationUtc = DateTimeOffset.MinValue; private bool _isHopInProgress; + /// + /// The meeting for which the bot warped through the meeting's gate (see ), + /// and how often - so a bot which can't get any closer gives up instead of warping on and on. + /// + private BotEventMeeting? _gateWarpMeeting; + + private int _gateWarpCount; + /// /// Initializes a new instance of the class. /// @@ -454,6 +462,92 @@ internal static bool HasWalkableSpawnGateCore(GameMapDefinition safezoneMap) return terrain.GetWalkableCoordinate(safezoneMap.GetSafezoneGate(terrain)) is not null; } + /// + /// Gets the warp entry whose landing area has the shortest walkable route to a target, see + /// . + /// + /// The warp entries to choose from; the caller checks they are legal. + /// The walk map of the target map. + /// The target position. + /// + /// The position of the bot if it already is on the map: a gate which may land the bot there takes + /// it nowhere; null otherwise. + /// + /// + /// false to reject a gate as soon as any of its landing points is blocked; true to + /// leave the blocked ones out - a player landing there is moved to the map's spawn gate, which is + /// acceptable when the caller can cope with that (and the town gates of Lorencia and Devias have + /// some blocked tiles, so they'd never be picked otherwise). + /// + /// The cancellation token. + /// The best reachable warp, or null if no gate can reach the target. + internal static WarpInfo? FindBestReachableWarp( + IEnumerable warps, + bool[,] walkMap, + Point target, + Point? currentPosition, + bool ignoreBlockedLandingPoints, + CancellationToken cancellationToken) + { + var candidates = warps + .Where(candidate => candidate.Gate is not null) + .Select(candidate => (Warp: candidate, Gate: candidate.Gate!, LandingPoints: candidate.Gate!.GetPossibleLandingPoints() + .Where(point => !ignoreBlockedLandingPoints || walkMap[point.X, point.Y]) + .ToArray())) + .Where(candidate => candidate.LandingPoints.Length > 0 + && (currentPosition is not { } position || !candidate.LandingPoints.Contains(position))) + .ToArray(); + if (candidates.Length == 0) + { + return null; + } + + // Movement on the walk map is bidirectional. One reverse flood fill from the target therefore + // gives both reachability and shortest distance for every possible gate landing point, without + // acquiring a full-map pathfinder once per point. + // Cancellation from this flood fill is expected during shutdown and handled by SafeEvaluateAsync. + cancellationToken.ThrowIfCancellationRequested(); + var width = walkMap.GetLength(0); + var coordinateCount = width * walkMap.GetLength(1); + var distancesFromTarget = ArrayPool.Shared.Rent(coordinateCount); + var pendingCoordinates = ArrayPool.Shared.Rent(coordinateCount); + try + { + Array.Clear(distancesFromTarget, 0, coordinateCount); + PopulateWalkableDistancesFrom( + walkMap, + target, + distancesFromTarget, + pendingCoordinates, + cancellationToken); + WarpInfo? bestWarp = null; + var bestWorstCaseDistance = int.MaxValue; + foreach (var candidate in candidates) + { + var worstCaseDistance = GetWorstLandingDistance(candidate.LandingPoints, distancesFromTarget, width); + if (worstCaseDistance is null + || worstCaseDistance > TravelSearchLimit + || worstCaseDistance > bestWorstCaseDistance + || (worstCaseDistance == bestWorstCaseDistance + && bestWarp?.Gate is { } bestGate + && GateDistance(candidate.Gate, target) >= GateDistance(bestGate, target))) + { + continue; + } + + bestWarp = candidate.Warp; + bestWorstCaseDistance = worstCaseDistance.Value; + } + + return bestWarp; + } + finally + { + ArrayPool.Shared.Return(distancesFromTarget); + ArrayPool.Shared.Return(pendingCoordinates); + } + } + /// /// Follows the party leader across maps after the leader has settled at the destination. /// @@ -708,6 +802,82 @@ internal bool TryFindNearestMonsterGround(GameMap map, out Point ground) return true; } + /// + /// Drives an event meeting (see ): a bot which announced an + /// opened event warps to the meeting map, walks up to the event's entry NPC (or just into the + /// safezone, for Chaos Castle) and waits there until the entrance closes. The meeting ends early + /// when a player takes the bot into a party - the follow logic then takes it into the event behind + /// its new leader. A meeting place which a player named comes with the gate to enter it through + /// (see ), so the bot gets there like a player would - by the + /// warp closest to the place, not by a walk from the town. + /// + /// The current map. + /// Indicates whether the bot currently is in the safezone of the map. + /// The token which aborts the travel wait on shutdown. + /// True, if the meeting consumed this tick. + internal async ValueTask TryEventMeetingAsync(GameMap map, bool inSafezone, CancellationToken cancellationToken) + { + if (this._player.EventMeeting is not { } meeting) + { + return false; + } + + if (DateTime.UtcNow >= meeting.UntilUtc + || this._player.CurrentMiniGame is not null + || BotPartyHandler.HasHumanCompanion(this._player)) + { + this.EndEventMeeting(meeting); + return false; + } + + // Leaving the hunting ground for a while: don't keep other bots off it meanwhile. + this._player.HuntingSpot = null; + + if (map.Definition.Number != meeting.Map.Number || (meeting.NpcPosition is null && !inSafezone)) + { + if ((meeting.Gate ?? meeting.Map.ExitGates.Where(g => g.IsSpawnGate).SelectRandom()) is not { } gate) + { + this.EndEventMeeting(meeting); + return false; + } + + await this.WarpToMeetingAsync(meeting, gate).ConfigureAwait(false); + return true; + } + + if (meeting.NpcPosition is { } npcPosition && this._player.GetDistanceTo(npcPosition) > MerchantTalkRange) + { + var standingPoint = BotEventMeetingPlanner.FindStandingPoint(map.Terrain, npcPosition, MerchantTalkRange); + + // Already on the map, but the meeting's gate lands closer - another floor of the Lost Tower, + // or Devias3 while the bot stands in the town of Devias: warp instead of a long walk, or none + // at all. The second try is for a landing on a blocked tile, which ends at the spawn gate. + if (standingPoint is { } point + && meeting.Gate is { } meetingGate + && this.GetGateWarpCount(meeting) < 2 + && !IsInside(meetingGate, this._player.Position) + && GateDistance(meetingGate, point) < this._player.GetDistanceTo(point)) + { + await this.WarpToMeetingAsync(meeting, meetingGate).ConfigureAwait(false); + return true; + } + + if (standingPoint is null + || !await this.TravelTowardAsync(map, standingPoint.Value, cancellationToken).ConfigureAwait(false)) + { + this._player.Logger.LogDebug("Bot '{Name}' gives up waiting for the event: no route to the entry NPC at {Target}.", this._player.Name, npcPosition); + this.EndEventMeeting(meeting); + return false; + } + + return true; + } + + // Waiting at the meeting point is not "stuck". + this._lastMoveUtc = DateTime.UtcNow; + return true; + } + /// /// Determines whether the navigator evaluates on this timer shot, and if so, starts the next interval: /// once per , and additionally as soon as a travel hop has ended. @@ -886,6 +1056,87 @@ private static int GateDistance(Gate gate, Point target) return Math.Abs(target.X - centerX) + Math.Abs(target.Y - centerY); } + private static bool IsInside(Gate gate, Point position) + => position.X >= gate.X1 && position.X <= gate.X2 && position.Y >= gate.Y1 && position.Y <= gate.Y2; + + private static int? GetWorstLandingDistance( + IReadOnlyCollection landingPoints, + int[] distancesFromTarget, + int mapWidth) + { + var worstCaseDistance = 0; + foreach (var landingPoint in landingPoints) + { + var encodedDistance = distancesFromTarget[(landingPoint.Y * mapWidth) + landingPoint.X]; + if (encodedDistance == 0) + { + return null; + } + + // Zero means unreachable, so stored distances are offset by one. + worstCaseDistance = Math.Max(worstCaseDistance, encodedDistance - 1); + } + + return worstCaseDistance; + } + + private static void PopulateWalkableDistancesFrom( + bool[,] walkMap, + Point start, + int[] distances, + int[] pendingCoordinates, + CancellationToken cancellationToken) + { + if (!walkMap[start.X, start.Y]) + { + return; + } + + var width = walkMap.GetLength(0); + var height = walkMap.GetLength(1); + var readIndex = 0; + var writeIndex = 0; + var startIndex = (start.Y * width) + start.X; + distances[startIndex] = 1; + pendingCoordinates[writeIndex++] = startIndex; + while (readIndex < writeIndex) + { + if ((readIndex & 0xFF) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + var currentIndex = pendingCoordinates[readIndex++]; + var currentX = currentIndex % width; + var currentY = currentIndex / width; + var nextEncodedDistance = distances[currentIndex] + 1; + for (var offsetX = -1; offsetX <= 1; offsetX++) + { + for (var offsetY = -1; offsetY <= 1; offsetY++) + { + if (offsetX == 0 && offsetY == 0) + { + continue; + } + + var nextX = currentX + offsetX; + var nextY = currentY + offsetY; + if ((uint)nextX >= width + || (uint)nextY >= height + || !walkMap[nextX, nextY] + || distances[(nextY * width) + nextX] != 0) + { + continue; + } + + var nextIndex = (nextY * width) + nextX; + distances[nextIndex] = nextEncodedDistance; + pendingCoordinates[writeIndex++] = nextIndex; + } + } + } + } + /// /// Picks an index into a list ranked best-first, each rank taking /// of what the ranks before it left over. Deliberately not a @@ -1431,68 +1682,26 @@ private async ValueTask ClaimBuffAsync(GameMap map, Point buffNpcPosition) } /// - /// Drives an event meeting (see ): a bot which announced an - /// opened event warps to the meeting map, walks up to the event's entry NPC (or just into the - /// safezone, for Chaos Castle) and waits there until the entrance closes. The meeting ends early - /// when a player takes the bot into a party - the follow logic then takes it into the event behind - /// its new leader. + /// Warps the bot through a gate to its meeting, counting the warps through the meeting's own gate. /// - /// The current map. - /// Indicates whether the bot currently is in the safezone of the map. - /// The token which aborts the travel wait on shutdown. - /// True, if the meeting consumed this tick. - private async ValueTask TryEventMeetingAsync(GameMap map, bool inSafezone, CancellationToken cancellationToken) + private async ValueTask WarpToMeetingAsync(BotEventMeeting meeting, ExitGate gate) { - if (this._player.EventMeeting is not { } meeting) - { - return false; - } - - if (DateTime.UtcNow >= meeting.UntilUtc - || this._player.CurrentMiniGame is not null - || BotPartyHandler.HasHumanCompanion(this._player)) - { - this.EndEventMeeting(meeting); - return false; - } - - // Leaving the hunting ground for a while: don't keep other bots off it meanwhile. - this._player.HuntingSpot = null; - - if (map.Definition.Number != meeting.Map.Number || (meeting.NpcPosition is null && !inSafezone)) - { - if (meeting.Map.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } gate) - { - this.EndEventMeeting(meeting); - return false; - } - - this._travelPath = null; - this._hasDestination = false; - this._lastWarpUtc = this._timeProvider.GetUtcNow(); - this._player.Logger.LogDebug("Bot '{Name}' warps to {Map} to wait for players of the event it announced.", this._player.Name, meeting.Map.Name); - await this._player.WarpToAsync(gate).ConfigureAwait(false); - return true; - } - - if (meeting.NpcPosition is { } npcPosition && this._player.GetDistanceTo(npcPosition) > MerchantTalkRange) + if (ReferenceEquals(gate, meeting.Gate)) { - if (BotEventMeetingPlanner.FindStandingPoint(map.Terrain, npcPosition, MerchantTalkRange) is not { } standingPoint - || !await this.TravelTowardAsync(map, standingPoint, cancellationToken).ConfigureAwait(false)) - { - this._player.Logger.LogDebug("Bot '{Name}' gives up waiting for the event: no route to the entry NPC at {Target}.", this._player.Name, npcPosition); - this.EndEventMeeting(meeting); - return false; - } - - return true; + this._gateWarpCount = this.GetGateWarpCount(meeting) + 1; + this._gateWarpMeeting = meeting; } - // Waiting at the meeting point is not "stuck". - this._lastMoveUtc = DateTime.UtcNow; - return true; + this._travelPath = null; + this._hasDestination = false; + this._lastWarpUtc = this._timeProvider.GetUtcNow(); + this._player.Logger.LogDebug("Bot '{Name}' warps to {Map} to wait for players at the meeting point.", this._player.Name, meeting.Map.Name); + await this._player.WarpToAsync(gate).ConfigureAwait(false); } + private int GetGateWarpCount(BotEventMeeting meeting) + => ReferenceEquals(this._gateWarpMeeting, meeting) ? this._gateWarpCount : 0; + /// /// Ends the event meeting, unless a newer one replaced it in the meantime. /// @@ -2234,141 +2443,7 @@ private bool TryGetLegalWarp(GameMapDefinition mapDefinition, [MaybeNullWhen(fal /// that budget is therefore rejected before warping. /// private WarpInfo? FindBestReachableLegalWarp(GameMap map, Point target, CancellationToken cancellationToken) - { - var candidates = this.GetLegalWarps(map.Definition) - .Where(candidate => candidate.Gate is not null) - .Select(candidate => (Warp: candidate, Gate: candidate.Gate!, LandingPoints: candidate.Gate!.GetPossibleLandingPoints().ToArray())) - .Where(candidate => !candidate.LandingPoints.Contains(this._player.Position)) - .ToArray(); - if (candidates.Length == 0) - { - return null; - } - - // Movement on the walk map is bidirectional. One reverse flood fill from the leader therefore - // gives both reachability and shortest distance for every possible gate landing point, without - // acquiring a full-map pathfinder once per point. - // Cancellation from this flood fill is expected during shutdown and handled by SafeEvaluateAsync. - cancellationToken.ThrowIfCancellationRequested(); - var walkMap = map.Terrain.WalkMap; - var width = walkMap.GetLength(0); - var coordinateCount = width * walkMap.GetLength(1); - var distancesFromTarget = ArrayPool.Shared.Rent(coordinateCount); - var pendingCoordinates = ArrayPool.Shared.Rent(coordinateCount); - try - { - Array.Clear(distancesFromTarget, 0, coordinateCount); - this.PopulateWalkableDistancesFrom( - walkMap, - target, - distancesFromTarget, - pendingCoordinates, - cancellationToken); - WarpInfo? bestWarp = null; - var bestWorstCaseDistance = int.MaxValue; - foreach (var candidate in candidates) - { - var worstCaseDistance = this.GetWorstLandingDistance(candidate.LandingPoints, distancesFromTarget, width); - if (worstCaseDistance is null - || worstCaseDistance > TravelSearchLimit - || worstCaseDistance > bestWorstCaseDistance - || (worstCaseDistance == bestWorstCaseDistance - && bestWarp?.Gate is { } bestGate - && GateDistance(candidate.Gate, target) >= GateDistance(bestGate, target))) - { - continue; - } - - bestWarp = candidate.Warp; - bestWorstCaseDistance = worstCaseDistance.Value; - } - - return bestWarp; - } - finally - { - ArrayPool.Shared.Return(distancesFromTarget); - ArrayPool.Shared.Return(pendingCoordinates); - } - } - - private int? GetWorstLandingDistance( - IReadOnlyCollection landingPoints, - int[] distancesFromTarget, - int mapWidth) - { - var worstCaseDistance = 0; - foreach (var landingPoint in landingPoints) - { - var encodedDistance = distancesFromTarget[(landingPoint.Y * mapWidth) + landingPoint.X]; - if (encodedDistance == 0) - { - return null; - } - - // Zero means unreachable, so stored distances are offset by one. - worstCaseDistance = Math.Max(worstCaseDistance, encodedDistance - 1); - } - - return worstCaseDistance; - } - - private void PopulateWalkableDistancesFrom( - bool[,] walkMap, - Point start, - int[] distances, - int[] pendingCoordinates, - CancellationToken cancellationToken) - { - if (!walkMap[start.X, start.Y]) - { - return; - } - - var width = walkMap.GetLength(0); - var height = walkMap.GetLength(1); - var readIndex = 0; - var writeIndex = 0; - var startIndex = (start.Y * width) + start.X; - distances[startIndex] = 1; - pendingCoordinates[writeIndex++] = startIndex; - while (readIndex < writeIndex) - { - if ((readIndex & 0xFF) == 0) - { - cancellationToken.ThrowIfCancellationRequested(); - } - - var currentIndex = pendingCoordinates[readIndex++]; - var currentX = currentIndex % width; - var currentY = currentIndex / width; - var nextEncodedDistance = distances[currentIndex] + 1; - for (var offsetX = -1; offsetX <= 1; offsetX++) - { - for (var offsetY = -1; offsetY <= 1; offsetY++) - { - if (offsetX == 0 && offsetY == 0) - { - continue; - } - - var nextX = currentX + offsetX; - var nextY = currentY + offsetY; - if ((uint)nextX >= width - || (uint)nextY >= height - || !walkMap[nextX, nextY] - || distances[(nextY * width) + nextX] != 0) - { - continue; - } - - var nextIndex = (nextY * width) + nextX; - distances[nextIndex] = nextEncodedDistance; - pendingCoordinates[writeIndex++] = nextIndex; - } - } - } - } + => FindBestReachableWarp(this.GetLegalWarps(map.Definition), map.Terrain.WalkMap, target, this._player.Position, false, cancellationToken); private IEnumerable GetLegalWarps(GameMapDefinition mapDefinition) { diff --git a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs new file mode 100644 index 0000000000..e3988a3e6d --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs @@ -0,0 +1,270 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Runtime.InteropServices; +using System.Threading; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests how a bot gets to the meeting place a player whispered to it (see ): +/// reading a warp like "devias3" or "losttower7" from the whisper, choosing the warp entry which gets +/// it there, and warping through it instead of walking from the town. +/// +[TestFixture] +public class BotMeetingPlaceTest +{ + private const byte LostTowerNumber = 4; + + /// + /// A warp of the warp list names the place as well as a map: "devias3 227 227" is Devias, through + /// the warp Devias3. "devias" alone is the map - the warp of the same name doesn't take its place. + /// + /// The whisper. + /// The expected map number. + /// The expected warp name, or null for none. + /// The expected x coordinate, or -1 for none. + /// The expected y coordinate, or -1 for none. + [TestCase("devias3 227 227", 2, "Devias3", 227, 227)] + [TestCase("come to LostTower7 pls", 4, "LostTower7", -1, -1)] + [TestCase("tarkan2, 90 160", 8, "Tarkan2", 90, 160)] + [TestCase("devias 215 40", 2, null, 215, 40)] + [TestCase("lost tower 100 120", 4, null, 100, 120)] + public void ReadsAWarpAsThePlace(string message, int expectedMap, string? expectedWarp, int expectedX, int expectedY) + { + var maps = new List + { + new() { Number = 2, Name = "Devias" }, + new() { Number = LostTowerNumber, Name = "Lost Tower" }, + new() { Number = 8, Name = "Tarkan" }, + }; + var warps = new List + { + new() { Name = "Devias", Gate = new ExitGate { Map = maps[0] } }, + new() { Name = "Devias3", Gate = new ExitGate { Map = maps[0] } }, + new() { Name = "LostTower", Gate = new ExitGate { Map = maps[1] } }, + new() { Name = "LostTower7", Gate = new ExitGate { Map = maps[1] } }, + new() { Name = "Tarkan2", Gate = new ExitGate { Map = maps[2] } }, + }; + + var place = BotChatComposer.FindPlace(message, maps, warps); + + Assert.That(place, Is.Not.Null); + Assert.That(place!.Value.Map.Number, Is.EqualTo(expectedMap)); + Assert.That(place.Value.Warp?.Name.ValueInNeutralLanguage, Is.EqualTo(expectedWarp)); + Point? expectedPosition = expectedX < 0 ? null : new Point((byte)expectedX, (byte)expectedY); + Assert.That(place.Value.Position, Is.EqualTo(expectedPosition)); + } + + /// + /// Coordinates on a floor of the Lost Tower which can't be walked to from the entrance: the bot + /// enters through the warp of that floor. + /// + /// The task. + [Test] + public async ValueTask EntersTheFloorOfTheCoordinatesAsync() + { + var (bot, _, _, upperGate) = await CreateLostTowerAsync().ConfigureAwait(false); + + var entry = BotChatPlugIn.FindMeetingEntry(bot, upperGate.Map!, null, new Point(200, 200)); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Value.Gate, Is.SameAs(upperGate)); + Assert.That(entry.Value.Position, Is.EqualTo(new Point(200, 200))); + } + + /// + /// A bot whose level isn't enough for the warp to that floor can't come - the other warps of the + /// map don't lead there. + /// + /// The task. + [Test] + public async ValueTask CantComeWithoutTheLevelOfTheFloorAsync() + { + var (bot, _, upperWarp, upperGate) = await CreateLostTowerAsync().ConfigureAwait(false); + upperWarp.LevelRequirement = 400; + + Assert.That(BotChatPlugIn.FindMeetingEntry(bot, upperGate.Map!, null, new Point(200, 200)), Is.Null); + Assert.That(BotChatPlugIn.FindMeetingEntry(bot, upperGate.Map!, upperWarp, null), Is.Null); + } + + /// + /// A named warp without coordinates: the bot waits at the warp's gate. + /// + /// The task. + [Test] + public async ValueTask WaitsAtTheGateOfTheNamedWarpAsync() + { + var (bot, _, upperWarp, upperGate) = await CreateLostTowerAsync().ConfigureAwait(false); + + var entry = BotChatPlugIn.FindMeetingEntry(bot, upperGate.Map!, upperWarp, null); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Value.Gate, Is.SameAs(upperGate)); + Assert.That(entry.Value.Position, Is.EqualTo(new Point(200, 200))); + } + + /// + /// A gate with some blocked tiles - like the town gates of Lorencia and Devias - still counts for a + /// meeting: a bot landing on a blocked tile ends up at the spawn gate, which is the town anyway. + /// Otherwise the bot would walk over from a far away gate. + /// + /// The task. + [Test] + public async ValueTask TakesTheNearGateWithBlockedTilesAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = 100; + var terrain = CreateBlockedTerrain(); + for (var x = 10; x <= 250; x++) + { + MarkWalkable(terrain, (byte)x, 10); + } + + var map = CreateMap(2, terrain); + var townGate = CreateGate(map, 10, 9, 13, 12); + var farGate = CreateGate(map, 240, 10, 241, 11); + gameContext.Configuration.WarpList.Add(new MUnique.OpenMU.Persistence.BasicModel.WarpInfo { Id = Guid.NewGuid(), Name = "Devias", Gate = townGate }); + gameContext.Configuration.WarpList.Add(new MUnique.OpenMU.Persistence.BasicModel.WarpInfo { Id = Guid.NewGuid(), Name = "Devias3", Gate = farGate }); + + var entry = BotChatPlugIn.FindMeetingEntry(bot, map, null, new Point(30, 10)); + + Assert.That(entry?.Gate, Is.SameAs(townGate)); + } + + /// + /// The bot stands on the entrance floor of the Lost Tower when the player names a place on another + /// floor: it warps through the meeting's gate once, and then waits there instead of trying to walk. + /// + /// The task. + [Test] + public async ValueTask WarpsToTheOtherFloorOnTheSameMapAsync() + { + var (bot, lowerGate, _, upperGate) = await CreateLostTowerAsync().ConfigureAwait(false); + var mapDefinition = upperGate.Map!; + var map = new GameMap(mapDefinition, TimeSpan.FromMinutes(1), 8); + var mapChanges = new MapChangeRecordingPlugIn(bot); + bot.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(mapChanges); + var navigator = new BotNavigator(bot); + bot.SetCurrentMapSilently(map); + bot.SelectedCharacter!.CurrentMap = mapDefinition; + bot.SelectedCharacter.PositionX = lowerGate.X1; + bot.SelectedCharacter.PositionY = lowerGate.Y1; + bot.EventMeeting = new BotEventMeeting(mapDefinition, new Point(201, 201), DateTime.UtcNow.AddMinutes(5), upperGate); + + var consumed = await navigator.TryEventMeetingAsync(map, false, CancellationToken.None).ConfigureAwait(false); + + Assert.That(consumed, Is.True); + Assert.That(mapChanges.MapChangeCount, Is.EqualTo(1)); + Assert.That(bot.SelectedCharacter.PositionX, Is.InRange(200, 201)); + Assert.That(bot.SelectedCharacter.PositionY, Is.InRange(200, 201)); + + bot.SetCurrentMapSilently(map); + var waits = await navigator.TryEventMeetingAsync(map, false, CancellationToken.None).ConfigureAwait(false); + + Assert.That(waits, Is.True); + Assert.That(mapChanges.MapChangeCount, Is.EqualTo(1), "standing at the meeting point, the bot doesn't warp again"); + } + + /// + /// Creates a Lost Tower of two floors which aren't connected by walkable ground, with a warp to each + /// (the upper one at level 70), and a bot of level 100. + /// + private static async ValueTask<(OfflinePlayer Bot, ExitGate LowerGate, WarpInfo UpperWarp, ExitGate UpperGate)> CreateLostTowerAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = 100; + + var terrain = CreateBlockedTerrain(); + for (var x = 10; x <= 12; x++) + { + for (var y = 10; y <= 12; y++) + { + MarkWalkable(terrain, (byte)x, (byte)y); + MarkWalkable(terrain, (byte)(x + 190), (byte)(y + 190)); + } + } + + var map = CreateMap(LostTowerNumber, terrain); + gameContext.Configuration.Maps.Add(map); + var lowerGate = CreateGate(map, 10, 10, 11, 11); + lowerGate.IsSpawnGate = true; + var upperGate = CreateGate(map, 200, 200, 201, 201); + var lowerWarp = new MUnique.OpenMU.Persistence.BasicModel.WarpInfo { Id = Guid.NewGuid(), Name = "LostTower", Gate = lowerGate, LevelRequirement = 50 }; + var upperWarp = new MUnique.OpenMU.Persistence.BasicModel.WarpInfo { Id = Guid.NewGuid(), Name = "LostTower7", Gate = upperGate, LevelRequirement = 70 }; + gameContext.Configuration.WarpList.Add(lowerWarp); + gameContext.Configuration.WarpList.Add(upperWarp); + return (bot, lowerGate, upperWarp, upperGate); + } + + private static byte[] CreateBlockedTerrain() + { + var terrain = new byte[ushort.MaxValue + 3]; + Array.Fill(terrain, (byte)4, 3, ushort.MaxValue); + return terrain; + } + + private static void MarkWalkable(byte[] terrain, byte x, byte y) + { + terrain[3 + (y * 256) + x] = 0; + } + + private static GameMapDefinition CreateMap(byte number, byte[] terrain) + => new MUnique.OpenMU.Persistence.BasicModel.GameMapDefinition + { + Id = Guid.NewGuid(), + Number = number, + TerrainData = terrain, + }; + + private static ExitGate CreateGate(GameMapDefinition map, byte x1, byte y1, byte x2, byte y2) + { + var gate = new MUnique.OpenMU.Persistence.BasicModel.ExitGate + { + Id = Guid.NewGuid(), + Map = map, + X1 = x1, + Y1 = y1, + X2 = x2, + Y2 = y2, + }; + + map.ExitGates.Add(gate); + return gate; + } + + [Guid("0E0B1D57-2C59-4C7E-8E39-7A4C5F0B6C11")] + private sealed class MapChangeRecordingPlugIn : IPlayerStateChangedPlugIn + { + private readonly Player _observedPlayer; + + public MapChangeRecordingPlugIn(Player observedPlayer) + { + this._observedPlayer = observedPlayer; + } + + public int MapChangeCount { get; private set; } + + public ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + if (ReferenceEquals(player, this._observedPlayer) + && previousState != PlayerState.ChangingMap + && currentState == PlayerState.ChangingMap) + { + this.MapChangeCount++; + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs index ac8351eda1..791d12d1f7 100644 --- a/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs @@ -79,7 +79,7 @@ public void IgnoresOtherPosts(string message) [TestCase("chodź: Wieża 100 120", 4, 100, 120)] public void ReadsTheMeetingPlace(string message, int expectedMap, int expectedX, int expectedY) { - var place = BotChatComposer.FindPlace(message, CreateMaps()); + var place = BotChatComposer.FindPlace(message, CreateMaps(), []); Assert.That(place, Is.Not.Null); Assert.That(place!.Value.Map.Number, Is.EqualTo(expectedMap)); @@ -93,7 +93,7 @@ public void ReadsTheMeetingPlace(string message, int expectedMap, int expectedX, [Test] public void NoMapNoPlace() { - Assert.That(BotChatComposer.FindPlace("somewhere 10 10", CreateMaps()), Is.Null); + Assert.That(BotChatComposer.FindPlace("somewhere 10 10", CreateMaps(), []), Is.Null); } /// From af570d874c1b72157830c4dd69cdd8fd06eccc1f Mon Sep 17 00:00:00 2001 From: nolt Date: Sun, 8 Nov 2026 19:02:48 +0100 Subject: [PATCH 35/44] feat(bots): understand an event in a party search A party search like "/post party bc7" called no bot: the parser only knew levels, and "bc7" holds no number of its own. Worse, "party bc 7" was read as "level 7 and above". A post may now name a Blood Castle, Devil Square or Chaos Castle with its level ("bc7", "ds 5", "chaos castle 3"). Only bots which may enter that event answer - checked by the same rules a bot follows its leader in with: the event's level bracket (the lower one of the special characters included) and the master class requirement, as configured for the event. An event level which doesn't exist calls no bot, and the event's digit is never taken for a character level. The words for the events are part of the Bot chat configuration. --- docs-website/docs/server-features/bots.md | 6 +- src/GameLogic/Bots/BotChatConfiguration.cs | 19 ++++ src/GameLogic/Bots/BotChatPlugIn.cs | 17 +++- src/GameLogic/Bots/BotPartyRequest.cs | 89 ++++++++++++++++--- .../Properties/PlugInResources.Designer.cs | 18 ++++ src/GameLogic/Properties/PlugInResources.resx | 8 +- .../BotPartySearchTest.cs | 69 +++++++++++++- 7 files changed, 208 insertions(+), 18 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 8feb8ab51e..9c5753a3ca 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -377,7 +377,11 @@ A player can also search party members among the bots with a post: `grupa`, …) and a level — `300-350`, `300+`, `min 300` or just `300` — calls up to five free bots of the game server whose level fits; class words (`dk`, `bk`, `bm`, `dw`, `elf`, `mg`, `dl`, `sum`, `rf`, … — each meaning its whole class -line) narrow it down. Each of them whispers to the player, a few seconds apart. +line) narrow it down. Instead of a level the post may name an event with its +level — `/post party bc7`, `pt ds 5`, `party chaos castle 3` — and then only bots +which may enter that event answer: the event's own level bracket (the lower one +of the Magic Gladiator, Dark Lord, Rage Fighter and Summoner included) and the +master class requirement of the Blood Castle 8, as configured for the event. Each of them whispers to the player, a few seconds apart. The same search posted again calls five others. The player whispers back to a bot where to meet — `devias 215 40`, a warp of the warp list like `devias3 227 227` or `losttower7`, a map name alone, or `here` for where the diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs index 699140f4eb..0d2de7895c 100644 --- a/src/GameLogic/Bots/BotChatConfiguration.cs +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -216,6 +216,25 @@ public class BotChatConfiguration [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyRequestKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyRequestKeywords_Description))] public string PartyRequestKeywords { get; set; } = "party, pt, grupa, grupy, grupe, grupę, grp"; + /// + /// Gets or sets the comma-separated words for the Blood Castle in a party search: followed by the + /// event level, like "party bc7", they stand for the level bracket of that Blood Castle. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyBloodCastleKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyBloodCastleKeywords_Description))] + public string PartyBloodCastleKeywords { get; set; } = "bc, blood castle"; + + /// + /// Gets or sets the comma-separated words for the Devil Square in a party search, like "party ds5". + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyDevilSquareKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyDevilSquareKeywords_Description))] + public string PartyDevilSquareKeywords { get; set; } = "ds, devil square"; + + /// + /// Gets or sets the comma-separated words for the Chaos Castle in a party search, like "party cc3". + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_PartyChaosCastleKeywords_Name), Description = nameof(PlugInResources.BotChatConfiguration_PartyChaosCastleKeywords_Description))] + public string PartyChaosCastleKeywords { get; set; } = "cc, chaos castle"; + /// /// Gets or sets the comma-separated words with which a player tells an answering bot to come to /// where the player stands, instead of naming a map and coordinates. diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 6172bf4df5..aa87eb8bc0 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -628,7 +628,12 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) if (this.Configuration is not { PartyVolunteerCount: > 0 } configuration || sender is OfflinePlayer || sender.SelectedCharacter?.Name is not { } senderName - || BotPartyRequest.TryParse(message, configuration.PartyRequestKeywords, configuration.PartyClassAliases) is not { } request) + || BotPartyRequest.TryParse( + message, + configuration.PartyRequestKeywords, + configuration.PartyClassAliases, + GetPartyEventKeywords(configuration), + sender.GameContext.Configuration.MiniGameDefinitions) is not { } request) { return; } @@ -644,7 +649,8 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) && IsFreeToMeet(bot) && throttle.GetOpenPartyOffer(bot, now) is null && request.IsWantedLevel(GetLevel(bot)) - && IsOfWantedClass(bot, request, classes)); + && IsOfWantedClass(bot, request, classes) + && (request.MiniGame is null || BotMiniGameHandler.IsEligible(bot, request.MiniGame, out _))); var volunteers = throttle.TakeNewVolunteers(sender, candidates, configuration.PartyVolunteerCount, now, count => Rand.NextInt(0, count)); var delay = TimeSpan.Zero; foreach (var bot in volunteers) @@ -820,6 +826,13 @@ internal static TimeSpan GetTypingTime(int length, TimeSpan reaction, TimeSpan p private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; + private static IEnumerable<(MiniGameType Type, string Keywords)> GetPartyEventKeywords(BotChatConfiguration configuration) => + [ + (MiniGameType.BloodCastle, configuration.PartyBloodCastleKeywords), + (MiniGameType.DevilSquare, configuration.PartyDevilSquareKeywords), + (MiniGameType.ChaosCastle, configuration.PartyChaosCastleKeywords), + ]; + private static bool IsOfWantedClass(Player bot, BotPartyRequest request, ICollection classes) { if (request.ClassNumbers.Count == 0) diff --git a/src/GameLogic/Bots/BotPartyRequest.cs b/src/GameLogic/Bots/BotPartyRequest.cs index acb807bacc..a64f2d4e09 100644 --- a/src/GameLogic/Bots/BotPartyRequest.cs +++ b/src/GameLogic/Bots/BotPartyRequest.cs @@ -8,42 +8,78 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Text.RegularExpressions; /// -/// A player's search for party members, read from a /post like "party 300-350 bk dw": -/// which levels and - optionally - which character classes the player looks for. +/// A player's search for party members, read from a /post like "party 300-350 bk dw" or +/// "party bc7": which levels or which event and - optionally - which character classes the player +/// looks for. /// -/// The lowest wanted level. -/// The highest wanted level; when the post names a minimum only. +/// The lowest wanted level; 0 when the post names an event only. +/// The highest wanted level; when the post names a minimum or an event only. /// /// The numbers of the wanted character classes, as configured in the /// ; empty when the post names no class. /// -internal sealed partial record BotPartyRequest(int MinimumLevel, int MaximumLevel, IReadOnlyCollection ClassNumbers) +/// +/// The event named in the post, e.g. the Blood Castle 7 for "bc7"; only bots which may enter it answer. +/// null when the post names no event. +/// +internal sealed partial record BotPartyRequest(int MinimumLevel, int MaximumLevel, IReadOnlyCollection ClassNumbers, MiniGameDefinition? MiniGame = null) { /// /// Reads a party search from a posted message. The message must contain one of the keywords and a - /// level: "300-350" (a range), "300+", "min 300" or a single "300" (a minimum). + /// level: "300-350" (a range), "300+", "min 300" or a single "300" (a minimum) - or an event with its + /// level, like "bc7", "ds 5" or "chaos castle 3". /// /// The posted message. /// The comma-separated words which mark a party search, e.g. party, pt. /// The words for the character classes. + /// The comma-separated words for each event type, e.g. bc, blood castle. + /// The events of the game, from which a named event takes its entry rules. /// The party search, or null when the message is no party search. /// - /// A post without a level is no party search: "thx for the party" must not call the bots. + /// A post without a level or event is no party search: "thx for the party" must not call the bots. + /// The event's level brackets aren't copied here: the bots are checked against the event's own + /// entry rules, which differ between the classes and can be changed by the server owner. + /// An event level which doesn't exist ("bc9") makes no search, and the event's digit is never + /// taken for a character level - "party bc 7" doesn't call every bot above level 7. /// - internal static BotPartyRequest? TryParse(string message, string keywords, IEnumerable classAliases) + internal static BotPartyRequest? TryParse( + string message, + string keywords, + IEnumerable classAliases, + IEnumerable<(MiniGameType Type, string Keywords)> eventKeywords, + IEnumerable miniGames) { - if (!BotChatComposer.ContainsAnyKeyword(message, keywords) - || ParseLevels(message.ToLowerInvariant()) is not { } levels) + if (!BotChatComposer.ContainsAnyKeyword(message, keywords)) + { + return null; + } + + var text = message.ToLowerInvariant(); + MiniGameDefinition? miniGame = null; + if (FindEvent(text, eventKeywords) is { } namedEvent) + { + miniGame = miniGames.FirstOrDefault(definition => definition.Type == namedEvent.Type && definition.GameLevel == namedEvent.Level); + if (miniGame is null) + { + return null; + } + + text = text.Remove(namedEvent.Index, namedEvent.Length).Insert(namedEvent.Index, " "); + } + + var levels = ParseLevels(text); + if (levels is null && miniGame is null) { return null; } var classNumbers = classAliases - .Where(alias => BotChatComposer.ContainsAnyKeyword(message, alias.Keywords)) + .Where(alias => BotChatComposer.ContainsAnyKeyword(text, alias.Keywords)) .Select(alias => alias.ClassNumber) .Distinct() .ToList(); - return new BotPartyRequest(levels.Minimum, levels.Maximum, classNumbers); + var (minimum, maximum) = levels ?? (0, int.MaxValue); + return new BotPartyRequest(minimum, maximum, classNumbers, miniGame); } /// @@ -53,6 +89,35 @@ internal sealed partial record BotPartyRequest(int MinimumLevel, int MaximumLeve /// true, if the level is within the wanted range. internal bool IsWantedLevel(int level) => level >= this.MinimumLevel && level <= this.MaximumLevel; + /// + /// Finds the first event word followed by a level, e.g. "bc7" or "blood castle 7"; the event word + /// must stand on its own, so "abc7" names no event. + /// + private static (MiniGameType Type, byte Level, int Index, int Length)? FindEvent(string message, IEnumerable<(MiniGameType Type, string Keywords)> eventKeywords) + { + (MiniGameType Type, byte Level, int Index, int Length)? first = null; + foreach (var (type, keywords) in eventKeywords) + { + var words = (keywords ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(word => Regex.Replace(Regex.Escape(word.ToLowerInvariant()), @"(\\ )+", @"\s+")) + .ToList(); + if (words.Count == 0) + { + continue; + } + + var pattern = $@"(? word.Length))})\s*(\d{{1,2}})(?![\p{{L}}\p{{N}}])"; + var match = Regex.Match(message, pattern, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (match.Success && (first is null || match.Index < first.Value.Index)) + { + first = (type, byte.Parse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture), match.Index, match.Length); + } + } + + return first; + } + private static (int Minimum, int Maximum)? ParseLevels(string message) { if (RangeRegex().Match(message) is { Success: true } range) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 65e7376f2d..027c35badc 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -5274,6 +5274,24 @@ public static string DiscordIntegrationFeaturePlugIn_Description { /// Gets the localized BotChatConfiguration_PartyRequestKeywords_Description text. public static string BotChatConfiguration_PartyRequestKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyRequestKeywords_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_PartyBloodCastleKeywords_Name text. + public static string BotChatConfiguration_PartyBloodCastleKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyBloodCastleKeywords_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyBloodCastleKeywords_Description text. + public static string BotChatConfiguration_PartyBloodCastleKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyBloodCastleKeywords_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyDevilSquareKeywords_Name text. + public static string BotChatConfiguration_PartyDevilSquareKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyDevilSquareKeywords_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyDevilSquareKeywords_Description text. + public static string BotChatConfiguration_PartyDevilSquareKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyDevilSquareKeywords_Description", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyChaosCastleKeywords_Name text. + public static string BotChatConfiguration_PartyChaosCastleKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyChaosCastleKeywords_Name", resourceCulture)!; + + /// Gets the localized BotChatConfiguration_PartyChaosCastleKeywords_Description text. + public static string BotChatConfiguration_PartyChaosCastleKeywords_Description => ResourceManager.GetString("BotChatConfiguration_PartyChaosCastleKeywords_Description", resourceCulture)!; + /// Gets the localized BotChatConfiguration_PartyHereKeywords_Name text. public static string BotChatConfiguration_PartyHereKeywords_Name => ResourceManager.GetString("BotChatConfiguration_PartyHereKeywords_Name", resourceCulture)!; diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index b756c8b1cc..90267fd5bf 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1950,7 +1950,13 @@ Party search: answering bots How many bots whisper to a player who searches party members with /post, e.g. "/post party 300-350 bk dw". The same search posted again is answered by other bots. 0 = off. Party search: keywords - Comma-separated words which mark a /post as a party search. The post also needs a level: "300-350", "300+", "min 300" or "300". + Comma-separated words which mark a /post as a party search. The post also needs a level: "300-350", "300+", "min 300" or "300" - or an event with its level, like "bc7". + Party search: Blood Castle words + Comma-separated words for the Blood Castle in a party search. Followed by the event level, like "party bc7", they call the bots which may enter that Blood Castle. + Party search: Devil Square words + Comma-separated words for the Devil Square in a party search. Followed by the event level, like "party ds5", they call the bots which may enter that Devil Square. + Party search: Chaos Castle words + Comma-separated words for the Chaos Castle in a party search. Followed by the event level, like "party cc3", they call the bots which may enter that Chaos Castle. Party search: "here" words Comma-separated words with which the player tells an answering bot to come to where the player stands, instead of naming a map and coordinates. Party search: answer timeout diff --git a/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs index 791d12d1f7..ef35382229 100644 --- a/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs @@ -29,6 +29,21 @@ public class BotPartySearchTest new() { Keywords = "elf", ClassNumber = 8 }, ]; + private static readonly (MiniGameType Type, string Keywords)[] EventKeywords = + [ + (MiniGameType.BloodCastle, "bc, blood castle"), + (MiniGameType.DevilSquare, "ds, devil square"), + (MiniGameType.ChaosCastle, "cc, chaos castle"), + ]; + + private static readonly MiniGameDefinition[] MiniGames = + [ + new() { Type = MiniGameType.BloodCastle, GameLevel = 7 }, + new() { Type = MiniGameType.BloodCastle, GameLevel = 8 }, + new() { Type = MiniGameType.DevilSquare, GameLevel = 5 }, + new() { Type = MiniGameType.ChaosCastle, GameLevel = 3 }, + ]; + /// /// A post with a party keyword and a level is a party search: a range, a minimum written as /// "300+", "min 300" or a single number, and optionally the wanted classes. @@ -44,7 +59,7 @@ public class BotPartySearchTest [TestCase("lf party lvl 380, need BM", 380, int.MaxValue, "4")] public void ReadsAPartySearch(string message, int minimum, int maximum, string classes) { - var request = BotPartyRequest.TryParse(message, Keywords, Aliases); + var request = BotPartyRequest.TryParse(message, Keywords, Aliases, EventKeywords, MiniGames); Assert.That(request, Is.Not.Null); Assert.That(request!.MinimumLevel, Is.EqualTo(minimum)); @@ -52,6 +67,56 @@ public void ReadsAPartySearch(string message, int minimum, int maximum, string c Assert.That(string.Join(",", request.ClassNumbers), Is.EqualTo(classes)); } + /// + /// A post may name an event with its level instead of a level - written together or apart, short or + /// in full. The event's digit is no character level; a level given besides the event still counts. + /// + /// The posted message. + /// The expected event type. + /// The expected event level. + /// The expected minimum level. + /// The expected class numbers, comma-separated. + [TestCase("party bc7", MiniGameType.BloodCastle, 7, 0, "")] + [TestCase("PT BC 8 bm", MiniGameType.BloodCastle, 8, 0, "4")] + [TestCase("grupa Blood Castle 7", MiniGameType.BloodCastle, 7, 0, "")] + [TestCase("party ds5 elf", MiniGameType.DevilSquare, 5, 0, "8")] + [TestCase("party cc3 350+", MiniGameType.ChaosCastle, 3, 350, "")] + public void ReadsAnEventSearch(string message, MiniGameType type, int gameLevel, int minimum, string classes) + { + var request = BotPartyRequest.TryParse(message, Keywords, Aliases, EventKeywords, MiniGames); + + Assert.That(request, Is.Not.Null); + Assert.That(request!.MiniGame, Is.Not.Null); + Assert.That(request.MiniGame!.Type, Is.EqualTo(type)); + Assert.That(request.MiniGame.GameLevel, Is.EqualTo(gameLevel)); + Assert.That(request.MinimumLevel, Is.EqualTo(minimum)); + Assert.That(request.MaximumLevel, Is.EqualTo(int.MaxValue)); + Assert.That(string.Join(",", request.ClassNumbers), Is.EqualTo(classes)); + } + + /// + /// A level search names no event. + /// + [Test] + public void ALevelSearchNamesNoEvent() + { + Assert.That(BotPartyRequest.TryParse("party 300-350", Keywords, Aliases, EventKeywords, MiniGames)!.MiniGame, Is.Null); + } + + /// + /// An event level which doesn't exist makes no search - its digit is not taken for a character + /// level either - and an event word must stand on its own. + /// + /// The posted message. + [TestCase("party bc9")] + [TestCase("party bc 9")] + [TestCase("party abc7")] + [TestCase("bc7 anyone?")] + public void IgnoresUnknownEvents(string message) + { + Assert.That(BotPartyRequest.TryParse(message, Keywords, Aliases, EventKeywords, MiniGames), Is.Null); + } + /// /// Without a party keyword, or without a level, a post calls no bot - "thx for the party" is no /// search. @@ -61,7 +126,7 @@ public void ReadsAPartySearch(string message, int minimum, int maximum, string c [TestCase("selling wings 300kk")] public void IgnoresOtherPosts(string message) { - Assert.That(BotPartyRequest.TryParse(message, Keywords, Aliases), Is.Null); + Assert.That(BotPartyRequest.TryParse(message, Keywords, Aliases, EventKeywords, MiniGames), Is.Null); } /// From 1fe7b435ee2a0ce2e96b292a5f93aadd0e9b2b7e Mon Sep 17 00:00:00 2001 From: nolt Date: Sun, 8 Nov 2026 19:10:03 +0100 Subject: [PATCH 36/44] docs(bots): wrap the party search paragraph The paragraph about events in a party search ran past the line length of the docs (markdownlint MD013). --- docs-website/docs/server-features/bots.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 9c5753a3ca..e447197399 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -378,10 +378,12 @@ A player can also search party members among the bots with a post: to five free bots of the game server whose level fits; class words (`dk`, `bk`, `bm`, `dw`, `elf`, `mg`, `dl`, `sum`, `rf`, … — each meaning its whole class line) narrow it down. Instead of a level the post may name an event with its -level — `/post party bc7`, `pt ds 5`, `party chaos castle 3` — and then only bots -which may enter that event answer: the event's own level bracket (the lower one -of the Magic Gladiator, Dark Lord, Rage Fighter and Summoner included) and the -master class requirement of the Blood Castle 8, as configured for the event. Each of them whispers to the player, a few seconds apart. +level — `/post party bc7`, `pt ds 5`, `party chaos castle 3` — and then only +bots which may enter that event answer: the event's own level bracket (the +lower one of the Magic Gladiator, Dark Lord, Rage Fighter and Summoner +included) and the master class requirement of the Blood Castle 8, as +configured for the event. Each of them whispers to the player, a few seconds +apart. The same search posted again calls five others. The player whispers back to a bot where to meet — `devias 215 40`, a warp of the warp list like `devias3 227 227` or `losttower7`, a map name alone, or `here` for where the From 9c9b218a6d0ae8edbc152ae722b96dacce7ab4f8 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:33:25 +0100 Subject: [PATCH 37/44] fix(bots): forget bot conversations nobody chimed in to --- src/GameLogic/Bots/BotChatPlugIn.cs | 2 +- src/GameLogic/Bots/BotChatThrottle.cs | 20 ++++++++++++++++- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 26 +++++++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index aa87eb8bc0..3ad79ef462 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -1353,7 +1353,7 @@ private async ValueTask HoldConversationsAsync(GameContext gameContext, BotChatC () => IsAvailable(first) ? BotChatSpeaker.SayAsync(first, conversation.Riposte, firstValues) : ValueTask.CompletedTask); } - throttle.RememberConversation(first, second, now + riposteDelay + ConversationChimeInWindow); + throttle.RememberConversation(first, second, now + riposteDelay + ConversationChimeInWindow, now); } } diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index a6f211b761..47c0565323 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -75,6 +75,20 @@ internal sealed class BotChatThrottle private int _handledForceStartCount; + /// + /// Gets the number of remembered conversations, expired ones included until they're pruned. + /// + internal int RecentConversationCount + { + get + { + lock (this._recentConversations) + { + return this._recentConversations.Count; + } + } + } + /// /// Reserves the right to announce an occasion (e.g. "Blood Castle opened") for the given time /// window. Only the first caller within the window gets it. @@ -398,10 +412,14 @@ public bool IsHumanChatBusy(GameMap map, Point position, double range, DateTime /// The bot which opened the conversation. /// The bot which replied. /// Until when a player's message counts as chiming in. - public void RememberConversation(Player first, Player second, DateTime until) + /// The current time; conversations which ended before it are forgotten. + public void RememberConversation(Player first, Player second, DateTime until, DateTime now) { lock (this._recentConversations) { + // Pruned here as well: on a quiet server no player chimes in to prune them, while the bots + // keep talking. + this._recentConversations.RemoveAll(c => c.Until < now); this._recentConversations.Add((first, second, until)); } } diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 91dd715d3c..9555f7c8db 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -165,16 +165,38 @@ public async ValueTask APlayerCanChimeInOnceAsync() var first = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); var second = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); var throttle = new BotChatThrottle(); - throttle.RememberConversation(first, second, Start.AddSeconds(30)); + throttle.RememberConversation(first, second, Start.AddSeconds(30), Start); Assert.That(throttle.TakeRecentConversation(_ => false, Start.AddSeconds(10)), Is.Null); Assert.That(throttle.TakeRecentConversation(bot => bot == second, Start.AddSeconds(10)), Is.Not.Null); Assert.That(throttle.TakeRecentConversation(_ => true, Start.AddSeconds(11)), Is.Null); - throttle.RememberConversation(first, second, Start.AddSeconds(30)); + throttle.RememberConversation(first, second, Start.AddSeconds(30), Start); Assert.That(throttle.TakeRecentConversation(_ => true, Start.AddSeconds(31)), Is.Null); } + /// + /// Conversations nobody chimed in to are forgotten when the bots have the next one - on a quiet + /// server no player's message prunes them. + /// + /// The task. + [Test] + public async ValueTask ForgetsConversationsNobodyChimedInToAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var first = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var second = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var throttle = new BotChatThrottle(); + + for (var minute = 0; minute < 100; minute++) + { + var now = Start.AddMinutes(minute); + throttle.RememberConversation(first, second, now.AddSeconds(30), now); + } + + Assert.That(throttle.RecentConversationCount, Is.EqualTo(1)); + } + /// /// A bot remembers the players it was in a party with - and only those. /// From 78b4a29e6ecdce77400cde790965744348b1e4c1 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:33:40 +0100 Subject: [PATCH 38/44] fix(bots): forget expired chat occasions --- src/GameLogic/Bots/BotChatThrottle.cs | 25 +++++++++++++++++++++++ tests/MUnique.OpenMU.Tests/BotChatTest.cs | 20 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index 47c0565323..f3f96e125f 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -39,6 +39,12 @@ internal sealed class BotChatThrottle /// private static readonly TimeSpan PlayerScanInterval = TimeSpan.FromSeconds(5); + /// + /// How often the expired occasions are forgotten. Their keys include player names (greetings, + /// congratulations), so without it they'd pile up until the server restarts. + /// + private static readonly TimeSpan OccasionCleanupInterval = TimeSpan.FromMinutes(10); + private readonly ConcurrentDictionary _occasions = new(); private readonly ConditionalWeakTable _bots = new(); @@ -69,6 +75,8 @@ internal sealed class BotChatThrottle private long _nextPlayerScanTicks; + private long _nextOccasionCleanupTicks; + private long _nextPostTicks; private DateTime _nextMerchantTalkUtc; @@ -89,6 +97,11 @@ internal int RecentConversationCount } } + /// + /// Gets the number of remembered occasions, expired ones included until they're cleaned up. + /// + internal int OccasionCount => this._occasions.Count; + /// /// Reserves the right to announce an occasion (e.g. "Blood Castle opened") for the given time /// window. Only the first caller within the window gets it. @@ -99,6 +112,18 @@ internal int RecentConversationCount /// true, if the caller may announce the occasion. public bool TryReserveOccasion(string occasionKey, DateTime now, TimeSpan window) { + if (TryReserve(ref this._nextOccasionCleanupTicks, now, OccasionCleanupInterval)) + { + foreach (var occasion in this._occasions) + { + if (occasion.Value <= now) + { + // Removes the entry only if it wasn't reserved again meanwhile. + this._occasions.TryRemove(occasion); + } + } + } + while (true) { if (this._occasions.TryAdd(occasionKey, now + window)) diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 9555f7c8db..3a0bfe0daf 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -52,6 +52,26 @@ public void AnOccasionIsAnnouncedOncePerWindow() Assert.That(throttle.TryReserveOccasion("mini game BloodCastle", Start.AddMinutes(5), window), Is.True); } + /// + /// Occasions are keyed by player names too (greetings, congratulations); the expired ones are + /// forgotten from time to time instead of piling up until the server restarts. + /// + [Test] + public void ForgetsExpiredOccasions() + { + var throttle = new BotChatThrottle(); + var window = TimeSpan.FromMinutes(1); + + for (var i = 0; i < 100; i++) + { + throttle.TryReserveOccasion($"greeting Player{i}", Start.AddSeconds(i), window); + } + + Assert.That(throttle.OccasionCount, Is.EqualTo(100)); + Assert.That(throttle.TryReserveOccasion("greeting Latecomer", Start.AddMinutes(20), window), Is.True); + Assert.That(throttle.OccasionCount, Is.EqualTo(1)); + } + /// /// Small talk happens once per interval - a freshly started server waits one interval first, /// while the bots log in - and a forced start from the admin panel makes it due at once. From abab81671ace8ddd69d5d514e9c47fefb621ec68 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:34:02 +0100 Subject: [PATCH 39/44] fix(bots): reserve a bot for one party search at a time --- src/GameLogic/Bots/BotChatPlugIn.cs | 7 ++++- src/GameLogic/Bots/BotChatThrottle.cs | 22 ++++++++++++-- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 35 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 3ad79ef462..e8dae2405b 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -660,7 +660,12 @@ public async ValueTask PostMessageSentAsync(Player sender, string message) return; } - throttle.SetPartyOffer(bot, new BotPartyOffer(sender, now + configuration.PartyOfferTimeout)); + if (!throttle.TryReservePartyOffer(bot, new BotPartyOffer(sender, now + configuration.PartyOfferTimeout), now)) + { + // Taken by another player's search since the candidates were picked. + continue; + } + delay += RandomDelay(PartyVolunteerSpacingSeconds); var whisperDelay = delay + TypingDelay(line.Text); var values = CreateBotValues(bot); diff --git a/src/GameLogic/Bots/BotChatThrottle.cs b/src/GameLogic/Bots/BotChatThrottle.cs index f3f96e125f..4eb51a19f4 100644 --- a/src/GameLogic/Bots/BotChatThrottle.cs +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -53,6 +53,8 @@ internal sealed class BotChatThrottle private readonly ConditionalWeakTable _partyOffers = new(); + private readonly Lock _partyOfferLock = new(); + private readonly ConditionalWeakTable _partySearches = new(); private readonly ConditionalWeakTable> _nextConversations = new(); @@ -238,11 +240,27 @@ public IReadOnlyList TakeNewVolunteers(Player requester, IEnumerable - /// Remembers that the bot answered a player's party search and waits for the meeting place. + /// Reserves the bot for a player's party search: it answers it and waits for the meeting place. + /// A bot with an open offer to another player isn't taken - two players searching at the same + /// time don't both get it. /// /// The bot. /// The offer. - public void SetPartyOffer(Player bot, BotPartyOffer offer) => this._partyOffers.AddOrUpdate(bot, offer); + /// The current time. + /// true, if the bot was free and is now reserved for the offer. + public bool TryReservePartyOffer(Player bot, BotPartyOffer offer, DateTime now) + { + lock (this._partyOfferLock) + { + if (this.GetOpenPartyOffer(bot, now) is not null) + { + return false; + } + + this._partyOffers.AddOrUpdate(bot, offer); + return true; + } + } /// /// Gets the bot's open offer to a player's party search, if it has one which didn't expire yet. diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index 3a0bfe0daf..f9e12809fa 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.Tests; using System.Globalization; +using System.Threading; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.Interfaces; @@ -234,6 +235,40 @@ public async ValueTask RemembersPartyCompanionsAsync() Assert.That(throttle.IsFriend(bot, "Stranger"), Is.False); } + /// + /// Two players searching a party at the same time can't both reserve the same bot: one wins, and + /// the bot's open offer is the winner's. After the offer expired, the bot is free again. + /// + /// The task. + [Test] + public async ValueTask APartySearchReservesTheBotOnceAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var requesters = new List(); + for (var i = 0; i < 8; i++) + { + requesters.Add(await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false)); + } + + var throttle = new BotChatThrottle(); + var expires = Start.AddMinutes(2); + using var startSignal = new ManualResetEventSlim(); + var attempts = requesters + .Select(requester => Task.Run(() => + { + startSignal.Wait(); + return throttle.TryReservePartyOffer(bot, new BotPartyOffer(requester, expires), Start) ? requester : null; + })) + .ToList(); + startSignal.Set(); + var winners = (await Task.WhenAll(attempts).ConfigureAwait(false)).OfType().ToList(); + + Assert.That(winners, Has.Count.EqualTo(1)); + Assert.That(throttle.GetOpenPartyOffer(bot, Start)?.Requester, Is.SameAs(winners[0])); + Assert.That(throttle.TryReservePartyOffer(bot, new BotPartyOffer(requesters[0], expires.AddMinutes(2)), expires), Is.True); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From b15ff0a04f4c3364f56471f0ab1da2724d52730c Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:34:51 +0100 Subject: [PATCH 40/44] fix(bots): keep the party offer when the meeting place is out of reach --- src/GameLogic/Bots/BotChatPlugIn.cs | 13 ++++++-- .../BotMeetingPlaceTest.cs | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index e8dae2405b..b3afc029d8 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -829,6 +829,13 @@ internal static TimeSpan GetTypingTime(int length, TimeSpan reaction, TimeSpan p return time < MaximumTypingTime ? time : MaximumTypingTime; } + /// + /// Gets the chat limits and memory of the bots of a game server. + /// + /// The game context. + /// The throttle. + internal BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); + private static bool IsBot(Player player) => player is OfflinePlayer && player.Account?.IsBot == true; private static IEnumerable<(MiniGameType Type, string Keywords)> GetPartyEventKeywords(BotChatConfiguration configuration) => @@ -1048,8 +1055,6 @@ private static ValueTask RefuseDuelAsync(Player bot, Player requester) ? new DuelActions().HandleDuelResponseAsync(bot, requester, false) : ValueTask.CompletedTask; - private BotChatThrottle GetThrottle(IGameContext gameContext) => this._throttles.GetOrCreateValue(gameContext); - /// /// Determines whether the bot may say something nobody asked for in the normal chat: not while /// the human players around it are busy talking - the bots shouldn't drown them out. @@ -1123,7 +1128,6 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess } var (map, warp, position) = found; - throttle.RemovePartyOffer(bot); // "devias3" is told back as the player wrote it, not as the map ("Devias"). var placeName = warp?.Name ?? map.Name; @@ -1132,11 +1136,13 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess if (!IsAvailable(bot) || !IsFreeToMeet(bot)) { // Taken into another party, or busy otherwise, since it answered: it lets the player be. + throttle.RemovePartyOffer(bot); return; } if (FindMeetingEntry(bot, map, warp, position) is not { } entry) { + // The offer stays open: the player may name another place until it expires. if (PickLine(configuration.PartyCantGoLines) is { } cantGoLine) { this.WhisperLater(bot, requester, cantGoLine, values, language); @@ -1145,6 +1151,7 @@ private void AnswerMeetingPlace(OfflinePlayer bot, Player requester, string mess return; } + throttle.RemovePartyOffer(bot); bot.EventMeeting = new BotEventMeeting(map, entry.Position, DateTime.UtcNow + configuration.PartyMeetingWaitTime, entry.Gate); if (PickLine(configuration.PartyOnTheWayLines) is { } onTheWayLine) { diff --git a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs index e3988a3e6d..14d9aa8db2 100644 --- a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.Tests; +using System.ComponentModel; using System.Runtime.InteropServices; using System.Threading; using MUnique.OpenMU.DataModel.Configuration; @@ -175,6 +176,36 @@ public async ValueTask WarpsToTheOtherFloorOnTheSameMapAsync() Assert.That(mapChanges.MapChangeCount, Is.EqualTo(1), "standing at the meeting point, the bot doesn't warp again"); } + /// + /// A place the bot can't get to is declined, but the bot keeps waiting for the player's whisper: + /// the player may name another place. A place it can get to ends the offer - the bot sets off. + /// + /// The task. + [Test] + public async ValueTask KeepsTheOfferUntilAPlaceItCanGetToAsync() + { + var (bot, _, upperWarp, _) = await CreateLostTowerAsync().ConfigureAwait(false); + upperWarp.LevelRequirement = 400; + await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + bot.IsAlive = true; + bot.Account!.IsBot = true; + var requester = await PlayerTestHelper.CreatePlayerAsync(bot.GameContext).ConfigureAwait(false); + var plugIn = new BotChatPlugIn { Configuration = BotChatConfiguration.CreateDefault() }; + var throttle = plugIn.GetThrottle(bot.GameContext); + var offer = new BotPartyOffer(requester, DateTime.UtcNow.AddMinutes(3)); + Assert.That(throttle.TryReservePartyOffer(bot, offer, DateTime.UtcNow), Is.True); + + plugIn.WhisperMessageReceived(requester, bot, "lost tower 200 200", new CancelEventArgs()); + + Assert.That(throttle.GetOpenPartyOffer(bot, DateTime.UtcNow), Is.SameAs(offer)); + Assert.That(bot.EventMeeting, Is.Null); + + plugIn.WhisperMessageReceived(requester, bot, "lost tower 11 11", new CancelEventArgs()); + + Assert.That(throttle.GetOpenPartyOffer(bot, DateTime.UtcNow), Is.Null); + Assert.That(bot.EventMeeting?.NpcPosition, Is.EqualTo(new Point(11, 11))); + } + /// /// Creates a Lost Tower of two floors which aren't connected by walkable ground, with a warp to each /// (the upper one at level 70), and a bot of level 100. @@ -196,6 +227,7 @@ public async ValueTask WarpsToTheOtherFloorOnTheSameMapAsync() } var map = CreateMap(LostTowerNumber, terrain); + map.Name = "Lost Tower"; gameContext.Configuration.Maps.Add(map); var lowerGate = CreateGate(map, 10, 10, 11, 11); lowerGate.IsSpawnGate = true; From bd429585f342c9a24b7ea9acd24afc21a24b39e9 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:35:30 +0100 Subject: [PATCH 41/44] fix(bots): walk to a meeting place on the map the bot stands on --- src/GameLogic/Bots/BotChatPlugIn.cs | 31 ++++++++++++---- src/GameLogic/Bots/BotNavigator.cs | 36 +++++++++++++++++++ .../BotMeetingPlaceTest.cs | 30 ++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index b3afc029d8..6154f8e008 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -714,6 +714,7 @@ internal static IEnumerable GetLegalWarps(Player bot, GameMapDefinitio /// Finds out how the bot gets to a meeting place a player named, like a player would: through the /// warp the player named ("devias3"), or the warp from which the named coordinates are closest to /// walk to - the floors of the Lost Tower, for one, aren't connected by walkable ground at all. + /// A bot which already stands on the map and can walk to the named coordinates needs no warp. /// /// The bot. /// The map of the meeting place. @@ -721,7 +722,8 @@ internal static IEnumerable GetLegalWarps(Player bot, GameMapDefinitio /// The coordinates the player named; null for none. /// /// null, if the bot may not or can't get there; otherwise the gate to enter the map through - /// (null for a spawn gate) and the point to wait at (null for the safezone). + /// (null for a spawn gate, or none for a bot walking there on the map) and the point to wait + /// at (null for the safezone). /// /// /// The terrain is read from the map definition, as the map may not be loaded. A warp which the bot's @@ -729,12 +731,12 @@ internal static IEnumerable GetLegalWarps(Player bot, GameMapDefinitio /// internal static (ExitGate? Gate, Point? Position)? FindMeetingEntry(Player bot, GameMapDefinition map, WarpInfo? warp, Point? position) { - var legalWarps = GetLegalWarps(bot, map).ToList(); - if (legalWarps.Count == 0) + if (BotFeaturePlugIn.GetConfiguration(bot.GameContext)?.IsMapForbidden(map) == true) { return null; } + var legalWarps = GetLegalWarps(bot, map).ToList(); if (position is { } target) { // Coordinates typed by hand may well point at a wall or a tree: the navigator waits next to @@ -744,12 +746,29 @@ internal static (ExitGate? Gate, Point? Position)? FindMeetingEntry(Player bot, var waitingPoint = terrain.WalkMap[target.X, target.Y] ? target : BotEventMeetingPlanner.FindStandingPoint(terrain, target, waitingRange); - return waitingPoint is { } point - && BotNavigator.FindBestReachableWarp(legalWarps, terrain.WalkMap, point, null, true, CancellationToken.None) is { Gate: { } gate } - ? (gate, target) + if (waitingPoint is not { } point) + { + return null; + } + + if (BotNavigator.FindBestReachableWarp(legalWarps, terrain.WalkMap, point, null, true, CancellationToken.None) is { Gate: { } gate }) + { + return (gate, target); + } + + // A bot already on the map walks there without a warp - also on a map which has no entry in + // the warp list at all. + return bot.CurrentMap?.Definition.Number == map.Number + && BotNavigator.CanWalk(terrain.WalkMap, bot.Position, point, CancellationToken.None) + ? (null, target) : null; } + if (legalWarps.Count == 0) + { + return null; + } + if (warp is not null) { return legalWarps.Contains(warp) && warp.Gate is { } gate diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 9e5c936140..a00a04b6d5 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -462,6 +462,42 @@ internal static bool HasWalkableSpawnGateCore(GameMapDefinition safezoneMap) return terrain.GetWalkableCoordinate(safezoneMap.GetSafezoneGate(terrain)) is not null; } + /// + /// Determines whether there is a walkable route between two points of a map, not longer than the + /// bots' travel search limit. + /// + /// The walk map of the map. + /// The start point. + /// The target point. + /// The cancellation token. + /// true, if the target can be walked to. + internal static bool CanWalk(bool[,] walkMap, Point from, Point to, CancellationToken cancellationToken) + { + if (!walkMap[from.X, from.Y]) + { + return false; + } + + var width = walkMap.GetLength(0); + var coordinateCount = width * walkMap.GetLength(1); + var distancesFromTarget = ArrayPool.Shared.Rent(coordinateCount); + var pendingCoordinates = ArrayPool.Shared.Rent(coordinateCount); + try + { + Array.Clear(distancesFromTarget, 0, coordinateCount); + PopulateWalkableDistancesFrom(walkMap, to, distancesFromTarget, pendingCoordinates, cancellationToken); + + // Zero means unreachable, so stored distances are offset by one. + var encodedDistance = distancesFromTarget[(from.Y * width) + from.X]; + return encodedDistance != 0 && encodedDistance - 1 <= TravelSearchLimit; + } + finally + { + ArrayPool.Shared.Return(distancesFromTarget); + ArrayPool.Shared.Return(pendingCoordinates); + } + } + /// /// Gets the warp entry whose landing area has the shortest walkable route to a target, see /// . diff --git a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs index 14d9aa8db2..390fe5eda0 100644 --- a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs @@ -176,6 +176,36 @@ public async ValueTask WarpsToTheOtherFloorOnTheSameMapAsync() Assert.That(mapChanges.MapChangeCount, Is.EqualTo(1), "standing at the meeting point, the bot doesn't warp again"); } + /// + /// A map without an entry in the warp list: a bot standing on it walks to the named coordinates; + /// a bot elsewhere can't get there. + /// + /// The task. + [Test] + public async ValueTask WalksThereOnAMapWithoutWarpAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + var terrain = CreateBlockedTerrain(); + for (var x = 10; x <= 30; x++) + { + MarkWalkable(terrain, (byte)x, 10); + } + + var mapDefinition = CreateMap(LostTowerNumber, terrain); + var target = new Point(30, 10); + + Assert.That(BotChatPlugIn.FindMeetingEntry(bot, mapDefinition, null, target), Is.Null); + + bot.SetCurrentMapSilently(new GameMap(mapDefinition, TimeSpan.FromMinutes(1), 8)); + bot.Position = new Point(10, 10); + var entry = BotChatPlugIn.FindMeetingEntry(bot, mapDefinition, null, target); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Value.Gate, Is.Null); + Assert.That(entry.Value.Position, Is.EqualTo(target)); + } + /// /// A place the bot can't get to is declined, but the bot keeps waiting for the player's whisper: /// the player may name another place. A place it can get to ends the offer - the bot sets off. From 8aaf76943cfaeee5cb56d33f59f6fd7b09337b1b Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:36:54 +0100 Subject: [PATCH 42/44] fix(bots): say a delayed party line only to the party it was meant for --- src/GameLogic/Bots/BotChatPlugIn.cs | 10 +++++---- src/GameLogic/Bots/BotChatSpeaker.cs | 9 +++++--- tests/MUnique.OpenMU.Tests/BotChatTest.cs | 27 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index 6154f8e008..f94af514f9 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -330,6 +330,7 @@ public void BotJoinedParty(Player bot, Player inviter) if (partyConfiguration is not { PartyChatChancePercent: > 0 } configuration || !Rand.NextRandomBool(configuration.PartyChatChancePercent) + || bot.Party is not { } party || PickLine(inviterIsFriend && configuration.FriendLines.Count > 0 ? configuration.FriendLines : configuration.PartyJoinedLines) is not { } line || !throttle.TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) { @@ -339,7 +340,7 @@ public void BotJoinedParty(Player bot, Player inviter) var values = CreateBotValues(bot); var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; values["player"] = _ => inviterName; - RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values, gesture: line.Gesture, toward: inviter)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, party, line.Text, values, gesture: line.Gesture, toward: inviter)); } /// @@ -359,7 +360,8 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR var definition = miniGame.Definition; foreach (var bot in speakers) { - if (!Rand.NextRandomBool(configuration.PartyChatChancePercent) + if (bot.Party is not { } party + || !Rand.NextRandomBool(configuration.PartyChatChancePercent) || PickLine(configuration.EventFinishedLines) is not { } line || !this.GetThrottle(bot.GameContext).TryReserveChat(bot, DateTime.UtcNow, configuration.BotChatCooldown)) { @@ -368,7 +370,7 @@ public ValueTask MiniGameEndedAsync(MiniGameContext miniGame, Player? winner, IR var values = CreateBotValues(bot); values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; - RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, line.Text, values, gesture: line.Gesture)); + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, party, line.Text, values, gesture: line.Gesture)); } return ValueTask.CompletedTask; @@ -1209,7 +1211,7 @@ private void AnswerInParty(Player sender, string senderName, string message, Bot var values = CreateBotValues(speaker); values["player"] = _ => senderName; var language = GetMessageLanguage(message, configuration); - RunLater(speaker.GameContext, TypingDelay(text), () => BotChatSpeaker.PartySayAsync(speaker, text, values, language, line.Gesture, sender)); + RunLater(speaker.GameContext, TypingDelay(text), () => BotChatSpeaker.PartySayAsync(speaker, party, text, values, language, line.Gesture, sender)); } /// diff --git a/src/GameLogic/Bots/BotChatSpeaker.cs b/src/GameLogic/Bots/BotChatSpeaker.cs index b96d9d224d..7a8505950d 100644 --- a/src/GameLogic/Bots/BotChatSpeaker.cs +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -66,17 +66,20 @@ internal static async ValueTask WhisperAsync(Player bot, Player receiver, Locali } /// - /// Says the line in the party chat, to the players of the bot's party. + /// Says the line in the party chat, to the players of the bot's party - only if the bot is still in + /// the party it meant to talk to: the line is said after a "typing time", meanwhile the bot may have + /// left it and joined another. /// /// The speaking bot. + /// The party the bot meant to talk to. /// The text. /// The placeholder values. /// The language to say it in, for every receiver; null for each receiver's own. /// The gesture the bot makes with it. /// The player the bot talks to, to turn to for the gesture. - internal static async ValueTask PartySayAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None, Player? toward = null) + internal static async ValueTask PartySayAsync(Player bot, Party party, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None, Player? toward = null) { - if (bot.SelectedCharacter?.Name is not { } name || bot.Party is not { } party) + if (bot.SelectedCharacter?.Name is not { } name || !ReferenceEquals(bot.Party, party)) { return; } diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs index f9e12809fa..5d50001e40 100644 --- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -6,8 +6,11 @@ namespace MUnique.OpenMU.Tests; using System.Globalization; using System.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; @@ -269,6 +272,30 @@ public async ValueTask APartySearchReservesTheBotOnceAsync() Assert.That(throttle.TryReservePartyOffer(bot, new BotPartyOffer(requesters[0], expires.AddMinutes(2)), expires), Is.True); } + /// + /// A party line said after the typing time goes only to the party the bot meant to talk to - not to + /// another party it joined meanwhile. + /// + /// The task. + [Test] + public async ValueTask SaysAPartyLineOnlyToThePartyItMeantAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var companion = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); + await party.AddAsync(bot).ConfigureAwait(false); + await party.AddAsync(companion).ConfigureAwait(false); + var formerParty = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); + var view = Mock.Get(companion.ViewPlugIns.GetPlugIn()!); + + await BotChatSpeaker.PartySayAsync(bot, formerParty, new LocalizedString("gg"), NoValues).ConfigureAwait(false); + view.Verify(v => v.ChatMessageAsync("gg", It.IsAny(), ChatMessageType.Party), Times.Never); + + await BotChatSpeaker.PartySayAsync(bot, party, new LocalizedString("gg"), NoValues).ConfigureAwait(false); + view.Verify(v => v.ChatMessageAsync("gg", It.IsAny(), ChatMessageType.Party), Times.Once); + } + /// /// The chat and whisper reply cooldowns belong to one bot; another bot may still talk, and the /// two cooldowns don't block each other. From 729ad62c003e8c8e2d551d6683e050b92404970c Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 13 Nov 2026 21:37:06 +0100 Subject: [PATCH 43/44] fix(bots): drop delayed bot chat once the plugin is deactivated --- src/GameLogic/Bots/BotChatPlugIn.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs index f94af514f9..15a1fde6cf 100644 --- a/src/GameLogic/Bots/BotChatPlugIn.cs +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -1038,7 +1038,8 @@ private static LocalizedString GetAnswerText(Player bot, LocalizedString text, B /// /// Runs the action after the delay, without holding up the caller - the plugin points are called - /// from the game logic, which must not wait for a bot's "typing time". + /// from the game logic, which must not wait for a bot's "typing time". A plugin deactivated + /// meanwhile drops the action: the bots fall silent at once, not a few lines later. /// private static void RunLater(IGameContext gameContext, TimeSpan delay, Func action) { @@ -1047,6 +1048,11 @@ private static void RunLater(IGameContext gameContext, TimeSpan delay, Func Date: Fri, 13 Nov 2026 21:40:55 +0100 Subject: [PATCH 44/44] docs(bots): describe naming another meeting place and walking on the same map --- docs-website/docs/server-features/bots.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index e447197399..2cb38e694e 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -391,8 +391,11 @@ player stands — and the bot warps there, walks up and waits five minutes for the invitation. It enters through the warp from which the place is the shortest walk, like a player would: the floors of the Lost Tower or the Dungeon can't be walked between at all, and the walk from the town of Devias -to Devias3 takes long. A place it may not go to (its level is too low for any -warp leading there) it declines; a whisper without a place gets asked again. +to Devias3 takes long. A bot already standing on the map walks to the named +coordinates, even on a map without an entry in the warp list. A place it may +not go to (its level is too low for any warp leading there) it declines, and +the player may name another one while the offer lasts; a whisper without a +place gets asked again. Map and warp names are recognized in every translation. The words, the number of bots and the times are part of the configuration.