diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md index 3556b7418a..2cb38e694e 100644 --- a/docs-website/docs/server-features/bots.md +++ b/docs-website/docs/server-features/bots.md @@ -331,6 +331,166 @@ 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, 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 | +|---|---|---| +| 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 | +| 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 | +| 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 | +| 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 +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. + +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. 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 +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 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. + +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 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 +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, 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), `{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 +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. + +`{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 +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 + 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 +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). + +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. + +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 +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/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/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/BotChatComposer.cs b/src/GameLogic/Bots/BotChatComposer.cs new file mode 100644 index 0000000000..60204317a9 --- /dev/null +++ b/src/GameLogic/Bots/BotChatComposer.cs @@ -0,0 +1,286 @@ +// +// 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; +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 +/// language, with the placeholders filled in. Pure logic - exposed for unit tests. +/// +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, BotChatTimeOfDay timeOfDay, Func pickIndex, object? previous = null) + { + var candidates = WithoutPrevious( + 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. + /// + /// 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. + /// 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, bool requireKeyword = false) + { + var usable = replies + .Where(reply => !string.IsNullOrWhiteSpace(reply.Text.ValueInNeutralLanguage)) + .ToList(); + var normalizedMessage = $" {Normalize(message)} "; + 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); + if (bestScore == 0 && requireKeyword) + { + return null; + } + + 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)]; + } + + /// + /// Renders a text for a receiver of the given culture. + /// + /// 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. + /// + /// 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(LocalizedString text, CultureInfo culture, IReadOnlyDictionary> values) + { + var template = 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(); + } + + /// + /// 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; + + /// + /// 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. + /// + /// 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. + /// + /// 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); + + /// + /// 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" 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 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"; 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, WarpInfo? Warp, Point? Position)? FindPlace(string message, IEnumerable maps, IEnumerable warps) + { + var normalizedMessage = $" {Normalize(message)} "; + 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) + { + 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, best.Warp, position); + + static IEnumerable GetNames(LocalizedString name) + => name.GetTranslations() + .Select(translation => translation.Value) + .Append(name.ValueInNeutralLanguage) + .Select(Normalize) + .Where(normalized => normalized.Length > 0) + .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) + .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 new file mode 100644 index 0000000000..0d2de7895c --- /dev/null +++ b/src/GameLogic/Bots/BotChatConfiguration.cs @@ -0,0 +1,976 @@ +// +// 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.DataModel.Composition; +using MUnique.OpenMU.Interfaces; + +/// +/// 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 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. + /// + [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 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 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 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 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 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 ). + /// + [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 + /// 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 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 + /// 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 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. + /// + [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. + /// + [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 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_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 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_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 + /// ). 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 ). + /// Placeholders: {event}, {map} (the map of the meeting point), {location} + /// (the meeting point with its coordinates), {level}. + /// + [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(); + + /// + /// 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}. + /// + [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(); + + /// + /// Gets or sets the lines a bot says in the normal chat when it reached a level milestone + /// (see ). Placeholders: {level}, {map}, {location}. + /// + [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(); + + /// + /// 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}, {wings} (see + /// ). + /// + [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(); + + /// + /// 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}. + /// + [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(); + + /// + /// 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}. + /// + [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(); + + /// + /// 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)] + [CollapsedByDefault] + [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)] + [CollapsedByDefault] + [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)] + [CollapsedByDefault] + [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 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}. + /// + [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}. + /// + [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(); + + /// + /// Gets or sets the whispers with which a bot turns down a player's duel challenge. + /// Placeholders: {player}, {level}, {map}, {location}. + /// + [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(); + + /// + /// Gets or sets the whispers with which a bot answers a player's party search. + /// Placeholders: {player}, {level}, {class}, {map}, {location}. + /// + [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(); + + /// + /// 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)] + [CollapsedByDefault] + [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)] + [CollapsedByDefault] + [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)] + [CollapsedByDefault] + [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)] + [CollapsedByDefault] + [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}. + /// + [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(); + + /// + /// 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)] + [CollapsedByDefault] + [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 advertises like a merchant, now and then + /// (see ) - only when a player sees it. + /// Placeholders: {level}, {map}, {location}. + /// + [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(); + + /// + /// 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)] + [CollapsedByDefault] + [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. + /// + /// 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 + /// the Polish phrases with and without diacritics, since players often type without them. + /// + public static BotChatConfiguration CreateDefault() + { + var configuration = new BotChatConfiguration(); + Add( + configuration.EventLines, + ("{event} open, anyone going? im in {map}", "{event} otwarte, ktoś idzie? jestem w {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.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}!"), + ("{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}, 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", "sorki, poluję"), + ("hi, can't talk now", "hej, nie mogę teraz gadać"), + ("hi, what's up?", "hej, co tam?"), + ("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} :)", "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.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"), + ("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 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}, 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'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}"), + ("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}"), + ("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}"), + ("nice, {level}!", "nieźle, {level}!"), + ("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"), + ("{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.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!"), + ("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ę"), + ("not trading now, busy", "teraz nie handluję, jestem zajęty"), + ("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, 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?"), + ("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"), + ("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ę"), + ("be there in a minute", "zaraz będę"), + ("coming, wait for me at {location}", "idę, czekaj na mnie w {location}")); + Add( + configuration.PartyCantGoLines, + ("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")); + 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", + }); + 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); + 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, jak leci", + ("fine, thanks for asking", "dobrze, dzięki że pytasz"), + ("good, you?", "spoko, a ty?"), + ("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, siemka, czesc, cześć, hej, elo, witam, dzien dobry, dzień dobry", + ("hi, what's up?", "hej, co tam?"), + ("hey :)", "hej :)"), + ("hello", "siema"), + ("yo", "elo"), + ("hey {player}", "hej {player}")); + AddReplies( + "level, lvl, what level, poziom, jaki lvl, jaki poziom, ktory lvl, który lvl", + ("{level} now", "{level} teraz"), + ("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}"), + ("{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( + "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"), + ("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ę, dzieks, thx bro", + ("np", "np"), + ("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"), + ("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); + 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) + { + 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) + { + lines.Add(new BotChatLine { Text = Translated(text) }); + } + } + + 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) + { + configuration.WhisperReplies.Add(new BotChatReply { Keywords = keywords, Text = Translated(text) }); + } + } + + 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/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/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/BotChatLine.cs b/src/GameLogic/Bots/BotChatLine.cs new file mode 100644 index 0000000000..d5f9718744 --- /dev/null +++ b/src/GameLogic/Bots/BotChatLine.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.Interfaces; + +/// +/// 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 list offers is +/// documented on the list. +/// +public class BotChatLine +{ + /// + /// 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 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; } + + /// + /// 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 new file mode 100644 index 0000000000..15a1fde6cf --- /dev/null +++ b/src/GameLogic/Bots/BotChatPlugIn.cs @@ -0,0 +1,1633 @@ +// +// 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 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; +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; + +/// +/// 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. 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. +/// +[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, + IPlayerStateChangedPlugIn, + IBotPartyJoinedPlugIn, + IMiniGameEndedPlugIn, + IAttackableGotKilledPlugIn, + IChatMessageSentPlugIn, + IPeriodicTaskPlugIn, + IPostMessageSentPlugIn, + ITradeRequestedPlugIn, + IDuelRequestedPlugIn, + IMonsterItemDroppedPlugIn, + IObjectAddedToMapPlugIn, + IBotWingsEarnedPlugIn, + IItemMovedPlugIn, + ISupportCustomConfiguration, + ISupportDefaultCustomConfiguration, + IDisabledByDefault +{ + /// + /// 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 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 + /// started by the same schedule run doesn't get its own post. + /// + 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. + /// + private static readonly (int Min, int Max) LevelMilestoneDelaySeconds = (1, 4); + + /// + /// 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 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); + + /// + /// 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); + + /// + /// 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". + /// + private static readonly (int Min, int Max) CongratulationDelaySeconds = (2, 5); + + /// + /// 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 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. + /// + private static readonly (int Min, int Max) PartyVolunteerSpacingSeconds = (2, 6); + + 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. + /// + private int _forceStartCount; + + /// + public BotChatConfiguration? Configuration { get; set; } + + /// + public object CreateDefaultConfig() => BotChatConfiguration.CreateDefault(); + + /// + 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 + || !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 { } 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; + } + + 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)) + { + return; + } + + RunLater( + player.GameContext, + RandomDelay(LevelMilestoneDelaySeconds), + () => IsAvailable(player) && this.MayTalkUnasked(player, configuration) + ? BotChatSpeaker.SayAsync(player, line.Text, CreateBotValues(player), gesture: line.Gesture) + : ValueTask.CompletedTask); + } + + /// + 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 + || !Rand.NextRandomBool(configuration.WhisperReplyChancePercent) + || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line + || !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + 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(text), + () => IsAvailable(receiver) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld + ? BotChatSpeaker.WhisperAsync(receiver, sender, text, values, language, line.Gesture) + : 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; + } + + /// + public void BotJoinedParty(Player bot, Player inviter) + { + 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) + || 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)) + { + return; + } + + var values = CreateBotValues(bot); + var inviterName = inviter.SelectedCharacter?.Name ?? string.Empty; + values["player"] = _ => inviterName; + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, party, line.Text, values, gesture: line.Gesture, toward: inviter)); + } + + /// + 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 (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)) + { + continue; + } + + var values = CreateBotValues(bot); + values["event"] = culture => definition.Name.GetTranslation(culture) ?? string.Empty; + RunLater(bot.GameContext, TypingDelay(line.Text), () => BotChatSpeaker.PartySayAsync(bot, party, line.Text, values, gesture: line.Gesture)); + } + + 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 lines = bot.GetRecentDeathCount(killerPlayer) >= OfflinePlayer.RepeatedKillThreshold ? configuration.KilledAgainLines : configuration.KilledLines; + if (!this.MayTalkUnasked(bot, configuration) + || this.PickLineFor(killerPlayer, lines) 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.Text, values, gesture: line.Gesture, toward: killerPlayer); + }); + return ValueTask.CompletedTask; + } + + /// + 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 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( + observer => + { + if (IsBot(observer) && observer.SelectedCharacter?.Name is { } botName && BotChatComposer.MentionsName(message, botName)) + { + mentionedBots.Add(observer); + } + + return ValueTask.CompletedTask; + }, + 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 + || !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown)) + { + return; + } + + var text = GetAnswerText(bot, line.Text, configuration); + var values = CreateBotValues(bot); + values["player"] = _ => senderName; + var language = GetMessageLanguage(message, configuration); + RunLater( + bot.GameContext, + TypingDelay(text), + () => IsAvailable(bot) ? BotChatSpeaker.SayAsync(bot, text, values, language, line.Gesture, sender) : ValueTask.CompletedTask); + } + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + if (this.Configuration is not { } configuration) + { + return; + } + + await this.MakeMerchantTalkAsync(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); + } + } + + /// + 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 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 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) + { + if (this.Configuration is not { PartyVolunteerCount: > 0 } configuration + || sender is OfflinePlayer + || sender.SelectedCharacter?.Name is not { } senderName + || BotPartyRequest.TryParse( + message, + configuration.PartyRequestKeywords, + configuration.PartyClassAliases, + GetPartyEventKeywords(configuration), + sender.GameContext.Configuration.MiniGameDefinitions) 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) + && (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) + { + if (PickLine(configuration.PartyVolunteerLines) is not { } line) + { + return; + } + + 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); + values["player"] = _ => senderName; + RunLater( + bot.GameContext, + whisperDelay, + () => IsAvailable(bot) && sender.PlayerState.CurrentState == PlayerState.EnteredWorld + ? BotChatSpeaker.WhisperAsync(bot, sender, line.Text, values, gesture: line.Gesture) + : 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) => GetLegalWarps(bot, map).Any(); + + /// + /// 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 []; + } + + var level = GetLevel(bot); + 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. + /// 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. + /// 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, 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 + /// 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) + { + 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 + // 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); + 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 + ? (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 ). + /// + /// 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. + /// + /// 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 <= HearingRange) + .OrderBy(candidate => candidate.Distance) + .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; + } + + /// + /// 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; + } + + /// + /// 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) => + [ + (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) + { + 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. + /// + 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. + /// + private static bool IsAvailable(Player bot) + => bot.PlayerState.CurrentState == PlayerState.EnteredWorld + && 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 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; + 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); + + /// + /// 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)); + + private static BotChatLine? PickLine(IEnumerable lines) + => 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 + /// 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); + + 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; + 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), + ["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". 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) + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(delay).ConfigureAwait(false); + if (!gameContext.PlugInManager.IsPlugInActive(typeof(BotChatPlugIn))) + { + return; + } + + 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; + } + + /// + /// 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; + + /// + /// 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. + /// + private BotChatLine? PickLineFor(Player listener, ICollection lines) + { + var throttle = this.GetThrottle(listener.GameContext); + 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); + } + + 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, bool requireKeyword = false) + { + var throttle = this.GetThrottle(listener.GameContext); + 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); + } + + 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 + /// 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; + 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, bot.GameContext.Configuration.WarpList); + if (place is null + && BotChatComposer.ContainsAnyKeyword(message, configuration.PartyHereKeywords) + && requester.CurrentMap is { } requesterMap) + { + place = (requesterMap.Definition, null, requester.Position); + } + + if (place is not { } found) + { + if (throttle.TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown) + && this.PickLineFor(requester, configuration.PartyAskWhereLines) is { } askLine) + { + this.WhisperLater(bot, requester, askLine, values, language); + } + + return; + } + + var (map, warp, position) = found; + + // "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. + 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); + } + + return; + } + + throttle.RemovePartyOffer(bot); + 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); + } + } + + /// + /// 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 text = GetAnswerText(speaker, line.Text, configuration); + var values = CreateBotValues(speaker); + values["player"] = _ => senderName; + var language = GetMessageLanguage(message, configuration); + RunLater(speaker.GameContext, TypingDelay(text), () => BotChatSpeaker.PartySayAsync(speaker, party, 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 { } 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); + 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. + /// + 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, gesture: line.Gesture).ConfigureAwait(false); + } + }); + } + + 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, language, line.Gesture) + : ValueTask.CompletedTask); + + private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatConfiguration configuration) + { + if (configuration.MerchantTalkChancePercent <= 0 + || !this.GetThrottle(gameContext).TryStartMerchantTalk(DateTime.UtcNow, configuration.MerchantTalkInterval, Volatile.Read(ref this._forceStartCount)) + || !Rand.NextRandomBool(configuration.MerchantTalkChancePercent)) + { + 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.MerchantLines) is not { } line) + { + return; + } + + var speaker = candidates[Rand.NextInt(0, candidates.Count)]; + if (this.MayTalkUnasked(speaker, configuration) + && this.GetThrottle(gameContext).TryReserveChat(speaker, DateTime.UtcNow, configuration.BotChatCooldown)) + { + await BotChatSpeaker.SayAsync(speaker, line.Text, CreateBotValues(speaker), gesture: line.Gesture).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, IList players) + { + var now = DateTime.UtcNow; + var throttle = this.GetThrottle(gameContext); + if (configuration.ConversationChancePercent <= 0 + || configuration.Conversations.Count == 0) + { + return; + } + + 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 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(); + 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) + { + 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); + var replyDelay = TypingDelay(conversation.Reply); + var hasRiposte = !string.IsNullOrWhiteSpace(conversation.Riposte.ValueInNeutralLanguage); + var riposteDelay = hasRiposte ? replyDelay + TypingDelay(conversation.Riposte) : replyDelay; + RunLater( + gameContext, + 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, now); + } + } + + /// + /// 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 + || !Rand.NextRandomBool(configuration.CongratulationChancePercent)) + { + return; + } + + 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) + { + 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 + || !this.MayTalkUnasked(bot, configuration) + || this.PickLineFor(player, lines) is not { } line + || !throttle.TryReserveOccasion(occasionKey, now, configuration.CongratulationCooldown) + || !throttle.TryReserveChat(bot, now, configuration.BotChatCooldown)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => playerName; + 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 + || player.CurrentMap is not { } map + || player.SelectedCharacter?.Name is not { } playerName) + { + return; + } + + var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); + 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, 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)) + { + return; + } + + var values = CreateBotValues(bot); + values["player"] = _ => playerName; + 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; + if (now >= miniGame.EnterEndsAtUtc) + { + 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 + .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 + || !throttle.TryReserveOccasion($"mini game {definition.Type}", now, miniGame.EnterEndsAtUtc - now) + || !Rand.NextRandomBool(configuration.EventPostChancePercent) + || PickLine(configuration.EventLines) is not { } line + || !throttle.TryReservePost(now, configuration.MinTimeBetweenPosts)) + { + return; + } + + 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; + 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); + } + + 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.InvasionLines) 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; + values["location"] = values["map"]; + 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..a26f642796 --- /dev/null +++ b/src/GameLogic/Bots/BotChatReply.cs @@ -0,0 +1,46 @@ +// +// 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; } + + /// + /// 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 new file mode 100644 index 0000000000..7a8505950d --- /dev/null +++ b/src/GameLogic/Bots/BotChatSpeaker.cs @@ -0,0 +1,149 @@ +// +// 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.GameLogic.Views.World; +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 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 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; + } + + await bot.ForEachObservingAsync( + receiver => SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Normal), + false).ConfigureAwait(false); + await GestureAsync(bot, gesture, toward).ConfigureAwait(false); + } + + /// + /// Whispers the line to a player. + /// + /// The whispering bot. + /// The receiving player. + /// The text. + /// The placeholder values. + /// The language to whisper in; null for the receiver's own. + /// 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; + } + + await SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Whisper).ConfigureAwait(false); + await GestureAsync(bot, gesture, receiver).ConfigureAwait(false); + } + + /// + /// 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, Party party, LocalizedString text, IReadOnlyDictionary> values, CultureInfo? language = null, BotChatGesture gesture = BotChatGesture.None, Player? toward = null) + { + if (bot.SelectedCharacter?.Name is not { } name || !ReferenceEquals(bot.Party, party)) + { + return; + } + + foreach (var receiver in party.PartyList.OfType()) + { + await SendAsync(receiver, BotChatComposer.Render(text, language ?? receiver.Culture, values), name, ChatMessageType.Party).ConfigureAwait(false); + } + + await GestureAsync(bot, gesture, toward).ConfigureAwait(false); + } + + /// + /// Posts the line to all players of the game server, in the format of the /post command. + /// + /// The posting bot. + /// The text. + /// The placeholder values. + internal static async ValueTask PostAsync(Player bot, LocalizedString text, IReadOnlyDictionary> values) + { + if (bot.SelectedCharacter?.Name is not { } name) + { + return; + } + + await bot.GameContext.ForEachPlayerAsync(receiver => + SendAsync( + receiver, + PostChatCommandPlugIn.FormatPost(name, BotChatComposer.Render(text, receiver.Culture, values)), + PostChatCommandPlugIn.PostSender, + ChatMessageType.Gens).AsTask()) + .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)) + { + 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..4eb51a19f4 --- /dev/null +++ b/src/GameLogic/Bots/BotChatThrottle.cs @@ -0,0 +1,579 @@ +// +// 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; +using MUnique.OpenMU.Pathfinding; + +/// +/// 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 +/// moment can't both get through. The time is passed in, which keeps the class testable. +/// +internal sealed class BotChatThrottle +{ + /// + /// The shortest gap between two merchant adverts, so an interval of zero doesn't make the bots talk + /// every second. + /// + 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 + /// within this time is answered by other bots. + /// + private static readonly TimeSpan PartySearchMemory = TimeSpan.FromMinutes(30); + + /// + /// 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 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(); + + private readonly Lock _merchantTalkLock = new(); + + private readonly ConditionalWeakTable _partyOffers = new(); + + private readonly Lock _partyOfferLock = new(); + + private readonly ConditionalWeakTable _partySearches = new(); + + private readonly ConditionalWeakTable> _nextConversations = new(); + + private readonly HashSet _usedConversations = []; + + private readonly Lock _conversationLock = new(); + + 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 = []; + + private readonly List<(Player First, Player Second, DateTime Until)> _recentConversations = []; + + private readonly ConditionalWeakTable> _friends = new(); + + private long _nextPlayerScanTicks; + + private long _nextOccasionCleanupTicks; + + private long _nextPostTicks; + + private DateTime _nextMerchantTalkUtc; + + 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; + } + } + } + + /// + /// 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. + /// + /// 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) + { + 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)) + { + 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); + + /// + /// 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 merchant adverts. + /// The current number of forced starts of the plugin. + /// true, if a bot may advertise now. + public bool TryStartMerchantTalk(DateTime now, TimeSpan interval, int forceStartCount) + { + lock (this._merchantTalkLock) + { + if (this._nextMerchantTalkUtc == default) + { + // A freshly started server waits one interval first - the bots are just logging in. + this._nextMerchantTalkUtc = now + interval; + } + + if (now < this._nextMerchantTalkUtc && forceStartCount == this._handledForceStartCount) + { + return false; + } + + this._handledForceStartCount = forceStartCount; + this._nextMerchantTalkUtc = now + (interval > MinimumMerchantTalkInterval ? interval : MinimumMerchantTalkInterval); + return true; + } + } + + /// + /// 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; + } + } + + /// + /// 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. + /// 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. + /// + /// 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); + + /// + /// Determines whether it's time for the next look over the players. + /// + /// The current time. + /// true, if the caller should look now. + 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); + } + + /// + /// 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. + /// + /// 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; + } + } + + /// + /// 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; + } + } + + /// + /// 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; + } + } + + /// + /// 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. + /// 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)); + } + } + + /// + /// 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); + } + } + + /// + /// 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) + { + 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 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. + /// + 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/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/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/BotConversation.cs b/src/GameLogic/Bots/BotConversation.cs new file mode 100644 index 0000000000..5d5dfe88f9 --- /dev/null +++ b/src/GameLogic/Bots/BotConversation.cs @@ -0,0 +1,54 @@ +// +// 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 , +/// and the first one may have the last word with a . +/// +/// +/// 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 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. + /// + [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 new file mode 100644 index 0000000000..77f800df3a --- /dev/null +++ b/src/GameLogic/Bots/BotConversationDefaults.cs @@ -0,0 +1,308 @@ +// +// 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; 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) + { + 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ś")); + 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")); + 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, + 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"), + ("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"), + ("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ć"), + ("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) + => 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"); + 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, + When = when, + }); + } + } +} 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/Bots/BotEventMeeting.cs b/src/GameLogic/Bots/BotEventMeeting.cs new file mode 100644 index 0000000000..c61a905b2a --- /dev/null +++ b/src/GameLogic/Bots/BotEventMeeting.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.Bots; + +using MUnique.OpenMU.Pathfinding; + +/// +/// 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 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 bot stops waiting, e.g. when the event's entrance closes. +/// +/// 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/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..a00a04b6d5 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,128 @@ 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 + /// . + /// + /// 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 +838,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 +1092,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 @@ -1066,6 +1353,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 +1717,39 @@ private async ValueTask ClaimBuffAsync(GameMap map, Point buffNpcPosition) await BotBuffHandler.TryRequestBuffAsync(this._player, map, buffNpcPosition).ConfigureAwait(false); } + /// + /// Warps the bot through a gate to its meeting, counting the warps through the meeting's own gate. + /// + private async ValueTask WarpToMeetingAsync(BotEventMeeting meeting, ExitGate gate) + { + if (ReferenceEquals(gate, meeting.Gate)) + { + this._gateWarpCount = this.GetGateWarpCount(meeting) + 1; + this._gateWarpMeeting = meeting; + } + + 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. + /// + /// 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 @@ -2151,141 +2479,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/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/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..a64f2d4e09 --- /dev/null +++ b/src/GameLogic/Bots/BotPartyRequest.cs @@ -0,0 +1,153 @@ +// +// 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" or +/// "party bc7": which levels or which event and - optionally - which character classes the player +/// looks for. +/// +/// 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. +/// +/// +/// 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) - 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 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, + IEnumerable<(MiniGameType Type, string Keywords)> eventKeywords, + IEnumerable miniGames) + { + 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(text, alias.Keywords)) + .Select(alias => alias.ClassNumber) + .Distinct() + .ToList(); + var (minimum, maximum) = levels ?? (0, int.MaxValue); + return new BotPartyRequest(minimum, maximum, classNumbers, miniGame); + } + + /// + /// 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; + + /// + /// 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) + { + 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/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/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/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/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/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 688976c24b..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 @@ -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. @@ -251,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/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/ChatCommands/PostChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/PostChatCommandPlugIn.cs index 43b01cc6aa..6f8cd9a6fc 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,18 @@ 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); + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.PostMessageSentAsync(player, message).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/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/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/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 3da4665754..027c35badc 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4615,6 +4615,96 @@ 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_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_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 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 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)!; @@ -5112,5 +5202,389 @@ 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_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_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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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)!; + + /// 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 72a1208212..90267fd5bf 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1897,4 +1897,162 @@ 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. + Text + 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 (%) + 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. + 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. + 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. + 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), {location} (the meeting point with coordinates), {level}. + Invasion lines + 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}, {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"), {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 + 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}, {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}. + 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" - 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 + 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. + 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. + 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}. + 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}. + 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}. + 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. + 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. + 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}. + 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}. + 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}. + 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. + 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/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/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/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; } = []; +} 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 . /// diff --git a/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs new file mode 100644 index 0000000000..edbef97d93 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotChatGreetingTest.cs @@ -0,0 +1,101 @@ +// +// 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; + } + + /// + /// 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); + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs new file mode 100644 index 0000000000..5d50001e40 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs @@ -0,0 +1,647 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +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; + +/// +/// 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); + } + + /// + /// 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. + /// + [Test] + public void MerchantTalkFollowsTheIntervalOrAForcedStart() + { + var throttle = new BotChatThrottle(); + var interval = TimeSpan.FromMinutes(10); + + 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); + } + + /// + /// 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 150 to 250 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(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); + } + + /// + /// 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); + } + + /// + /// 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))); + } + + /// + /// 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), 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), 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. + /// + /// 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); + } + + /// + /// 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); + } + + /// + /// 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. + /// + /// 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); + } + + /// + /// Blank lines are ignored - a list without a usable line stays silent. + /// + [Test] + public void PicksOnlyUsableLines() + { + var lines = new List + { + new() { Text = " " }, + new() { Text = "{level} finally" }, + }; + + 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. + /// + [Test] + public void RendersTheLineInTheReceiversLanguage() + { + var polish = CultureInfo.GetCultureInfo("pl"); + 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(text, polish, values), Is.EqualTo("Inwazja na Devias!")); + Assert.That(BotChatComposer.Render(text, 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() + { + 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")); + } + + /// + /// 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 + /// 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 replies = new List + { + 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(replies, message, _ => 0); + + 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])); + } + + /// + /// 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")); + } + + /// + /// 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. + /// + /// 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. + /// + [Test] + public void DefaultRepliesAnswerHowAreYou() + { + var configuration = BotChatConfiguration.CreateDefault(); + + var reply = BotChatComposer.PickWhisperReply(configuration.WhisperReplies, "hey, how are you?", _ => 0); + + 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. + /// + /// 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.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")); + } + + /// + /// 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")); + } + + /// + /// 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)); + } + + /// + /// 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. + /// + [Test] + public void DefaultConfigurationHasLinesForEverySituation() + { + var configuration = BotChatConfiguration.CreateDefault(); + + foreach (var property in typeof(BotChatConfiguration).GetProperties().Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>))) + { + Assert.That(property.GetValue(configuration) as System.Collections.ICollection, Is.Not.Empty, property.Name); + } + } +} 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; + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs new file mode 100644 index 0000000000..390fe5eda0 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotMeetingPlaceTest.cs @@ -0,0 +1,332 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.ComponentModel; +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"); + } + + /// + /// 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. + /// + /// 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. + /// + 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); + map.Name = "Lost Tower"; + 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 new file mode 100644 index 0000000000..ef35382229 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotPartySearchTest.cs @@ -0,0 +1,216 @@ +// +// 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 }, + ]; + + 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. + /// + /// 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, EventKeywords, MiniGames); + + 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)); + } + + /// + /// 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. + /// + /// 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, EventKeywords, MiniGames), 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" }, + ]; +} 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); + } +}