diff --git a/docs-website/docs/server-features/bots.md b/docs-website/docs/server-features/bots.md
index 096c25cdf2..d3894b037a 100644
--- a/docs-website/docs/server-features/bots.md
+++ b/docs-website/docs/server-features/bots.md
@@ -296,6 +296,73 @@ first pair at level 180, the second at 280 and the third, master-only pair at
400. Which class wears which pair comes from the item data, and the outgrown
pair is destroyed rather than dropped.
+### Selling on the market
+
+With the `Bot merchants` plugin (off by default, next to `Bots` in the plugin
+list), the bots sell their best loot to the players in personal stores on Loren
+Market, the map which is made for them. There are no dedicated merchant bots:
+any bot from level 6 may become one now and then.
+
+On its trips to town, a bot puts the loot worth a stall into its personal store
+instead of selling it to the NPC, each piece with its price:
+
+| Goods | Price class |
+|---|---|
+| Ancient items | expensive |
+| Excellent items with two or more options | expensive |
+| Excellent items with one wanted option and luck | expensive |
+| Excellent items with one wanted option, without luck | medium |
+| Excellent items with only a cheap option, with luck | cheap |
+| Normal items of the higher sets from +7, with luck | cheap |
+| The jewels above the stock the bot keeps | its own price |
+
+The wanted options are damage decrease, defense success rate and reflection on
+armor, and excellent damage, the damage increases and attack speed on weapons;
+more life, mana or zen are the cheap ones. "Higher sets" start at the drop
+level 60. Each price class has its currency - zen or a jewel - with surcharges
+per level above +7 and per additional option, and ancient items and the best
+excellent items of the higher sets cost more. For each kind of jewel, the
+currency (zen or another jewel) and the price are set separately; by default,
+the jewels are sold for their value in zen. A store holds at most two jewels of
+a kind (`Jewels: maximum per kind`): Jewels of Chaos are the most common loot,
+and with more, many stalls offered nothing else. Prices in jewels need the
+`Player shop currencies` plugin with these jewels; goods whose currency isn't
+allowed are sold to the NPC as before. Players of the original client can only
+buy goods for zen.
+
+A zen price isn't exact to the last coin: when a good goes into the store, its
+price is drawn within 10 % up or down and rounded the way a player would ask
+for it - to 50,000 below a million, to 100,000 below ten million, to whole
+millions above. A Jewel of Chaos costs 750,000 at one merchant and 850,000 at
+another. The price stays until the good is sold or leaves the store; prices in
+jewels stay exact. `Zen price spread` 0 keeps all prices exact.
+
+With enough goods (4 by default, `Minimum goods to start`), a bot goes to the
+market for a shift of 30 to 90 minutes: through the map's spawn gate, regardless
+of the warp list, to a stall apart from the other merchants - anywhere in the
+safezone of the market, at least two tiles away from the walls, so no store
+blocks a corridor like the entrance. Some players sell in town instead, and so
+do 20 % of the shifts: in Lorencia, Devias, Noria or Elvenland, in the safezone
+near the spawn gate, in the middle of the town (`City merchants (%)`, `City map
+numbers`); a city without a free stall sends the bot to the market. Every store
+gets a name no other open store has. At most 50 bots per game server sell at the
+same time (`Maximum merchants`) - a ceiling, not a number of merchants: only
+bots with enough goods go, so there are few right after a server start. A bot
+leaves early when it sold everything, after 30 minutes without a sale, or when
+it logs out; then it warps home and goes back hunting, and its next shift is two
+hours later at the earliest. A bot in a party of bots leaves it for the shift,
+like a player who goes selling; a party with a player is never left. At most 3
+bots start a shift every 30 seconds (`New merchants per 30 seconds`), so after a
+server start the market fills up within minutes and the parties don't fall apart
+at once. On a shift, a bot declines party invitations.
+
+A bot buys nothing, so the zen and the jewels paid for its goods are removed
+right after each sale. There are no discounts - players would learn to wait for
+them. A good which found no buyer for three shifts leaves the store and is sold
+to the NPC, making room for fresh goods. The server counts these shifts in
+memory only: a restart, or a day in which the good wasn't on the market, starts
+the count over.
+
### Mini game events
A bot never enters Blood Castle, Devil Square or Chaos Castle on its own — it
@@ -377,7 +444,8 @@ only talks about what it could actually do:
| A player killed the bot (outside of an event) | normal chat, `{player}`, before it respawns — a different line when the same player kills it again | the bot |
| A player mentioned the bot's name in the normal chat next to it | normal chat, the same lines as whisper replies | the bot |
| 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 |
+| Now and then, advertising its goods (see Selling on the market) | normal chat, `{location}`, `{store}` | a bot behind its open store on the market or in a city which a player sees |
+| A player whispered to a merchant what it sells ("what are you selling?", "za ile?"), or asked it by name in the normal chat | the same way back: a line of the *merchant whisper replies* which points to its store, `{store}` | a bot behind its open store on the market or in a city |
| 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 |
diff --git a/src/GameLogic/Bots/BotChatConfiguration.cs b/src/GameLogic/Bots/BotChatConfiguration.cs
index 0d2de7895c..c14b37ea09 100644
--- a/src/GameLogic/Bots/BotChatConfiguration.cs
+++ b/src/GameLogic/Bots/BotChatConfiguration.cs
@@ -264,20 +264,20 @@ public class BotChatConfiguration
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.
+ /// Gets or sets how often a bot selling its goods on the market may advertise them (see
+ /// and ), 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.
+ /// Gets or sets the chance, in percent, that a bot advertises when the interval is over. Only bots
+ /// behind their open store on the market advertise, so nothing happens while the
+ /// is off.
///
[Range(0, 100)]
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantTalkChancePercent_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantTalkChancePercent_Description))]
- public int MerchantTalkChancePercent { get; set; }
+ public int MerchantTalkChancePercent { get; set; } = 80;
///
/// Gets or sets how often a human player may overhear a conversation of two bots near them (see
@@ -349,6 +349,20 @@ public class BotChatConfiguration
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_WhisperReplies_Description))]
public ICollection WhisperReplies { get; set; } = new List();
+ ///
+ /// Gets or sets the answers of a bot behind its open store on the market (see )
+ /// to a whisper, or a mention of its name in the normal chat next to it, which asks what it sells: they
+ /// point to the store, which shows the goods and prices. Only a reply whose keyword matches is used,
+ /// before the ; any other message gets a usual answer. The keywords cover
+ /// those of the usual trade replies, so a merchant never answers that it has nothing to sell.
+ /// Placeholders as in , and {store}.
+ ///
+ [MemberOfAggregate]
+ [ScaffoldColumn(true)]
+ [CollapsedByDefault]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantWhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantWhisperReplies_Description))]
+ public ICollection MerchantWhisperReplies { 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,
@@ -567,9 +581,9 @@ public class BotChatConfiguration
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
+ /// Gets or sets the lines a bot behind its open store on the market advertises with, now and then
/// (see ) - only when a player sees it.
- /// Placeholders: {level}, {map}, {location}.
+ /// Placeholders: {level}, {map}, {location}, {store} (the name of its store).
///
[MemberOfAggregate]
[ScaffoldColumn(true)]
@@ -724,14 +738,12 @@ public static BotChatConfiguration CreateDefault()
("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"));
+ ("selling my loot, take a look at {location}", "sprzedaję dropa, zajrzyj na {location}"),
+ ("check my store '{store}', good prices", "zajrzyj do sklepu '{store}', dobre ceny"),
+ ("store open at {location}, come and see", "sklep otwarty na {location}, zapraszam"),
+ ("good items for sale, {location}", "dobre itemy na sprzedaż, {location}"),
+ ("'{store}' is open, first come first served", "'{store}' otwarty, kto pierwszy ten lepszy"),
+ ("selling what i found today, {location}", "sprzedaję, co dziś wypadło, {location}"));
Add(
configuration.CongratulationLines,
("gz", "gz"),
@@ -911,6 +923,14 @@ public static BotChatConfiguration CreateDefault()
("cya", "na razie"),
("ok, see you later", "ok, do później"),
("take care", "trzymaj się"));
+ AddMerchantReplies(
+ "sell, selling, wts, wtb, trade, price, how much, shop, store, buy, sprzedajesz, sprzedasz, co sprzedajesz, sprzedam, kupisz, handel, masz coś, masz cos, cena, za ile, ile za, ile chcesz, sklep, kupię, kupie",
+ ("check my store, everything's priced", "zajrzyj do sklepu, wszystko z cenami"),
+ ("click my shop, it's all there", "kliknij mój sklep, wszystko tam jest"),
+ ("have a look at '{store}' :)", "zerknij do '{store}' :)"),
+ ("prices are in the shop, no haggling", "ceny są w sklepie, nie targuję się"),
+ ("open my store, maybe something catches your eye", "otwórz sklep, może coś wpadnie w oko"),
+ ("all i sell is on the shelf, take a look", "co sprzedaję, to na półce, zobacz sam"));
SetGesture(configuration.GreetingLines, BotChatGesture.Greeting);
SetGesture(configuration.PartyJoinedLines, BotChatGesture.Salute);
SetGesture(configuration.LevelMilestoneLines, BotChatGesture.Win);
@@ -967,6 +987,14 @@ void AddReplies(string keywords, params (string English, string Polish)[] texts)
}
}
+ void AddMerchantReplies(string keywords, params (string English, string Polish)[] texts)
+ {
+ foreach (var text in texts)
+ {
+ configuration.MerchantWhisperReplies.Add(new BotChatReply { Keywords = keywords, Text = Translated(text) });
+ }
+ }
+
void AddClass(string keywords, byte classNumber)
=> configuration.PartyClassAliases.Add(new BotClassAlias { Keywords = keywords, ClassNumber = classNumber });
diff --git a/src/GameLogic/Bots/BotChatPlugIn.cs b/src/GameLogic/Bots/BotChatPlugIn.cs
index 15a1fde6cf..c63e7d82e0 100644
--- a/src/GameLogic/Bots/BotChatPlugIn.cs
+++ b/src/GameLogic/Bots/BotChatPlugIn.cs
@@ -279,7 +279,7 @@ public void WhisperMessageReceived(Player sender, Player receiver, string messag
|| !IsBot(receiver)
|| sender is OfflinePlayer
|| !Rand.NextRandomBool(configuration.WhisperReplyChancePercent)
- || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line
+ || (this.PickMerchantReplyFor(receiver, sender, message, configuration) ?? this.PickReplyFor(sender, configuration.WhisperReplies, message)) is not { } line
|| !this.GetThrottle(receiver.GameContext).TryReserveWhisperReply(receiver, DateTime.UtcNow, configuration.WhisperReplyCooldown))
{
return;
@@ -458,7 +458,7 @@ await sender.ForEachObservingAsync(
if (mentionedBots.FirstOrDefault(IsAvailable) is not { } bot
|| !Rand.NextRandomBool(configuration.WhisperReplyChancePercent)
- || this.PickReplyFor(sender, configuration.WhisperReplies, message) is not { } line
+ || (this.PickMerchantReplyFor(bot, sender, message, configuration) ?? this.PickReplyFor(sender, configuration.WhisperReplies, message)) is not { } line
|| !this.GetThrottle(bot.GameContext).TryReserveWhisperReply(bot, DateTime.UtcNow, configuration.WhisperReplyCooldown))
{
return;
@@ -886,10 +886,12 @@ private static bool IsOfWantedClass(Player bot, BotPartyRequest request, ICollec
///
/// 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.
+ /// an errand, a market shift 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.MarketShift is null
&& !bot.IsOnShoppingTrip
&& !bot.IsOnBuffTrip
&& !bot.IsOnChaosMachineTrip
@@ -1009,6 +1011,7 @@ private static Dictionary> CreateBotValues(Pla
["map"] = culture => map?.GetTranslation(culture) ?? string.Empty,
["location"] = culture => BotChatComposer.FormatLocation(map?.GetTranslation(culture), position),
["wings"] = GetWingsText(GetWings(bot)),
+ ["store"] = _ => (bot as OfflinePlayer)?.MarketShift?.StoreName ?? string.Empty,
};
}
@@ -1105,6 +1108,15 @@ private bool MayTalkUnasked(Player bot, BotChatConfiguration configuration)
return line;
}
+ ///
+ /// Picks the answer of a bot behind its open store on the market to a message which asks what it sells;
+ /// null for any other bot or message.
+ ///
+ private BotChatReply? PickMerchantReplyFor(Player bot, Player listener, string message, BotChatConfiguration configuration)
+ => bot is OfflinePlayer { MarketShift: { IsStoreOpened: true, IsEnding: false } }
+ ? this.PickReplyFor(listener, configuration.MerchantWhisperReplies, message, requireKeyword: true)
+ : null;
+
///
/// Picks the reply to the player's message, other than the reply the player got last time.
///
@@ -1298,7 +1310,10 @@ private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatCo
var players = await gameContext.GetPlayersAsync().ConfigureAwait(false);
var candidates = new List();
- foreach (var bot in players.Where(p => IsBot(p) && IsAvailable(p) && IsInSafezone(p)))
+
+ // Only a bot which really sells something advertises: one behind its open store on the market
+ // (see BotMerchantPlugIn).
+ foreach (var bot in players.Where(p => IsBot(p) && IsAvailable(p) && p is OfflinePlayer { MarketShift: { IsStoreOpened: true, IsEnding: false } }))
{
// Talking to nobody would be wasted: only a bot which a player sees may talk.
if (await HasHumanObserverAsync(bot).ConfigureAwait(false))
diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs
index e8d24d2753..e78ee61f8e 100644
--- a/src/GameLogic/Bots/BotManager.cs
+++ b/src/GameLogic/Bots/BotManager.cs
@@ -242,13 +242,14 @@ public async ValueTask RestartBotAsync(IGameContext gameContext, BotPlayer
///
/// Groups a share of the active bots into small hunting parties of level-wise similar characters.
/// Members follow their leader, the elf heals the group, buffs are shared
- /// and the party experience bonus applies. The rest keeps hunting solo.
+ /// and the party experience bonus applies. The rest keeps hunting solo - and a bot on a market
+ /// shift keeps selling (see ).
///
/// The game context (provides the party manager).
public async ValueTask FormPartiesAsync(IGameContext gameContext)
{
var candidates = this._bots.Values
- .Where(b => b.Party is null && b.Attributes is not null)
+ .Where(b => b.Party is null && b.Attributes is not null && b.MarketShift is null)
.OrderBy(BotResetHandler.GetEffectiveLevel)
.ToList();
diff --git a/src/GameLogic/Bots/BotMarketPlanner.cs b/src/GameLogic/Bots/BotMarketPlanner.cs
new file mode 100644
index 0000000000..e68d3e2b50
--- /dev/null
+++ b/src/GameLogic/Bots/BotMarketPlanner.cs
@@ -0,0 +1,302 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerShopCurrencies;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The decisions of a market shift (see ) which don't need a running game:
+/// which bot may go to the market, where it puts its stall and when it leaves.
+///
+internal static class BotMarketPlanner
+{
+ ///
+ /// The lowest level at which the game allows opening a personal store (see the set price action).
+ ///
+ internal const int MinimumStoreLevel = 6;
+
+ ///
+ /// How far from the arrival point of a city a stall may be: the stalls stay in the middle of the
+ /// town, where the players are, instead of in its corners. On the market, the whole safezone is used.
+ ///
+ internal const int StallSearchRange = 18;
+
+ ///
+ /// How many tiles around a stall have to be walkable safezone: a store doesn't stand against a wall,
+ /// and corridors narrower than five tiles, like the entrance of the market, stay free.
+ ///
+ internal const int StallMargin = 2;
+
+ ///
+ /// The least distance between two stalls, so that the players can click each store.
+ ///
+ internal const double StallSpacing = 3;
+
+ ///
+ /// The four directions in which the search for reachable tiles goes on.
+ ///
+ private static readonly (int X, int Y)[] Neighbors = [(1, 0), (-1, 0), (0, 1), (0, -1)];
+
+ ///
+ /// Why a market shift ends.
+ ///
+ internal enum EndReason
+ {
+ /// The shift goes on.
+ None,
+
+ /// The shift is over.
+ TimeIsUp,
+
+ /// The bot has fewer goods left than it needs to stay.
+ SoldOut,
+
+ /// The bot didn't sell anything for a long time.
+ NoSales,
+
+ /// The store was closed by the game, e.g. after its last item was sold.
+ StoreClosed,
+
+ /// A player took the bot into a party.
+ JoinedParty,
+
+ /// The game didn't open the store, e.g. because of a price in a currency which isn't allowed.
+ StoreNotOpened,
+ }
+
+ ///
+ /// Counts the goods in the personal store of the bot which have a price - only those can be sold.
+ ///
+ /// The bot.
+ /// The number of priced goods.
+ internal static int CountPricedGoods(Player bot)
+ => bot.ShopStorage?.Items.Count(item => item.GetStorePrice() is not null) ?? 0;
+
+ ///
+ /// Determines whether the bot may start a market shift now: it has enough priced goods, is free of
+ /// errands and company, and its last shift is long enough ago.
+ ///
+ ///
+ /// Most bots hunt in parties of bots, so a party of bots doesn't keep a bot from the market - it
+ /// leaves the party for its shift, like a player who goes selling. A party with a player isn't left.
+ ///
+ /// The bot.
+ /// The configuration.
+ /// The current time.
+ /// true, if the bot may go to the market.
+ internal static bool MayStartShift(OfflinePlayer bot, BotMerchantConfiguration configuration, DateTime now)
+ {
+ return bot.MarketShift is null
+ && now >= bot.NextMarketShiftUtc
+ && bot.PlayerState.CurrentState == PlayerState.EnteredWorld
+ && bot.IsAlive
+ && bot.Level >= Math.Max(configuration.MinimumLevel, MinimumStoreLevel)
+ && (bot.Party is null || HasOnlyBots(bot.Party.PartyList))
+ && bot.CurrentMiniGame is null
+ && bot.EventMeeting is null
+ && !bot.IsOnShoppingTrip
+ && !bot.IsOnBuffTrip
+ && !bot.IsOnChaosMachineTrip
+ && !bot.HasRevengeIntent
+ && !(bot.ShopStorage?.StoreOpen ?? false)
+ && CountPricedGoods(bot) >= configuration.MinimumGoodsToStart;
+ }
+
+ ///
+ /// Determines whether all members of a party are bots, so that a bot may leave it for a market shift.
+ ///
+ /// The members of the party.
+ /// true, if no player is in the party.
+ internal static bool HasOnlyBots(IEnumerable members)
+ => members.All(member => member is Player { Account.IsBot: true });
+
+ ///
+ /// Draws the length of a shift between the configured minimum and maximum.
+ ///
+ /// The configuration.
+ /// Returns a random number between the inclusive minimum and the exclusive maximum.
+ /// The length of the shift.
+ internal static TimeSpan DrawShiftDuration(BotMerchantConfiguration configuration, Func nextInt)
+ {
+ var minimum = (int)Math.Max(1, configuration.MinimumShiftDuration.TotalSeconds);
+ var maximum = (int)Math.Max(minimum, configuration.MaximumShiftDuration.TotalSeconds);
+ return TimeSpan.FromSeconds(nextInt(minimum, maximum + 1));
+ }
+
+ ///
+ /// Finds a free stall: a safezone tile which the bot can walk to from the arrival point, away from the
+ /// walls (see ), which keeps its distance from the stalls of the other merchants.
+ ///
+ /// The terrain of the map.
+ /// The center of the arrival area of the map.
+ /// The stalls of the other merchants.
+ /// Returns a random number between the inclusive minimum and the exclusive maximum.
+ /// How far from the arrival point the stall may be; null for the whole safezone.
+ /// The stall; null, if the map is full.
+ internal static Point? FindStall(GameMapTerrain terrain, Point arrival, IReadOnlyCollection takenStalls, Func nextInt, int? searchRange = StallSearchRange)
+ {
+ var candidates = new List();
+ foreach (var point in GetReachableSafezone(terrain, arrival, searchRange))
+ {
+ if (HasMargin(terrain, point)
+ && takenStalls.All(stall => stall.EuclideanDistanceTo(point) >= StallSpacing))
+ {
+ candidates.Add(point);
+ }
+ }
+
+ return candidates.Count == 0 ? null : candidates[nextInt(0, candidates.Count)];
+ }
+
+ ///
+ /// Draws the map of the next shift: one of the cities for the configured share of the shifts,
+ /// otherwise the market.
+ ///
+ /// The configuration.
+ /// The usable city maps; when empty, every shift is on the market.
+ /// Returns a random number between the inclusive minimum and the exclusive maximum.
+ /// The map number.
+ internal static short DrawShiftMap(BotMerchantConfiguration configuration, IReadOnlyList cities, Func nextInt)
+ {
+ if (cities.Count == 0 || nextInt(0, 100) >= configuration.CityMerchantsPercent)
+ {
+ return configuration.MarketMapNumber;
+ }
+
+ return cities[nextInt(0, cities.Count)];
+ }
+
+ ///
+ /// Draws a store name which no other open store has, so that two stores next to each other can be
+ /// told apart.
+ ///
+ /// The configured store names.
+ /// The names of the other open stores.
+ /// The name of the bot, used when all store names are taken.
+ /// Returns a random number between the inclusive minimum and the exclusive maximum.
+ /// The store name.
+ internal static string DrawStoreName(IReadOnlyList storeNames, IReadOnlyCollection takenNames, string botName, Func nextInt)
+ {
+ var free = storeNames.Where(n => !takenNames.Contains(n, StringComparer.OrdinalIgnoreCase)).ToList();
+ return free.Count == 0 ? botName : free[nextInt(0, free.Count)];
+ }
+
+ ///
+ /// Determines whether and why the shift of the bot ends now.
+ ///
+ /// The bot.
+ /// The shift.
+ /// The configuration.
+ /// The current time.
+ /// The reason; , if the shift goes on.
+ internal static EndReason GetEndReason(Player bot, BotMarketShift shift, BotMerchantConfiguration configuration, DateTime now)
+ {
+ if (now >= shift.UntilUtc)
+ {
+ return EndReason.TimeIsUp;
+ }
+
+ if (bot.Party is not null)
+ {
+ return EndReason.JoinedParty;
+ }
+
+ if (!shift.IsStoreOpened)
+ {
+ // Still on the way to the stall: only the time and a party end it.
+ return EndReason.None;
+ }
+
+ if (!(bot.ShopStorage?.StoreOpen ?? false))
+ {
+ return EndReason.StoreClosed;
+ }
+
+ if (CountPricedGoods(bot) < configuration.MinimumGoodsToStay)
+ {
+ return EndReason.SoldOut;
+ }
+
+ if (configuration.LeaveWithoutSalesAfter > TimeSpan.Zero
+ && now - shift.LastSaleUtc >= configuration.LeaveWithoutSalesAfter)
+ {
+ return EndReason.NoSales;
+ }
+
+ return EndReason.None;
+ }
+
+ ///
+ /// Gets the walkable safezone tiles which are connected to the arrival point, within the range.
+ /// A safezone behind a wall, which the bot can't walk to, is left out.
+ ///
+ private static List GetReachableSafezone(GameMapTerrain terrain, Point arrival, int? searchRange)
+ {
+ bool IsUsable(int x, int y)
+ => x is >= 0 and <= byte.MaxValue
+ && y is >= 0 and <= byte.MaxValue
+ && terrain.WalkMap[x, y]
+ && terrain.SafezoneMap[x, y]
+ && (searchRange is not { } range || new Point((byte)x, (byte)y).EuclideanDistanceTo(arrival) <= range);
+
+ var reached = new List();
+ var visited = new bool[byte.MaxValue + 1, byte.MaxValue + 1];
+ var queue = new Queue();
+
+ // The spawn gate may be a few tiles wide, so the search starts at every usable tile close to its center.
+ for (var x = arrival.X - 2; x <= arrival.X + 2; x++)
+ {
+ for (var y = arrival.Y - 2; y <= arrival.Y + 2; y++)
+ {
+ if (IsUsable(x, y))
+ {
+ visited[x, y] = true;
+ queue.Enqueue(new Point((byte)x, (byte)y));
+ }
+ }
+ }
+
+ while (queue.TryDequeue(out var point))
+ {
+ reached.Add(point);
+ foreach (var (dx, dy) in Neighbors)
+ {
+ var x = point.X + dx;
+ var y = point.Y + dy;
+ if (IsUsable(x, y) && !visited[x, y])
+ {
+ visited[x, y] = true;
+ queue.Enqueue(new Point((byte)x, (byte)y));
+ }
+ }
+ }
+
+ return reached;
+ }
+
+ ///
+ /// Determines whether all tiles within of the point are walkable safezone.
+ ///
+ private static bool HasMargin(GameMapTerrain terrain, Point point)
+ {
+ for (var x = point.X - StallMargin; x <= point.X + StallMargin; x++)
+ {
+ for (var y = point.Y - StallMargin; y <= point.Y + StallMargin; y++)
+ {
+ if (x is < 0 or > byte.MaxValue
+ || y is < 0 or > byte.MaxValue
+ || !terrain.WalkMap[x, y]
+ || !terrain.SafezoneMap[x, y])
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/GameLogic/Bots/BotMarketShift.cs b/src/GameLogic/Bots/BotMarketShift.cs
new file mode 100644
index 0000000000..9ce886c9d7
--- /dev/null
+++ b/src/GameLogic/Bots/BotMarketShift.cs
@@ -0,0 +1,128 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Threading;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// A shift of a bot on the market map: the bot goes there with the goods it collected in its personal
+/// store, opens the store at its stall and sells until the shift is over (see ).
+///
+///
+/// Set by and carried out by . The plugin reads
+/// and updates it from its own timer and from the sales, so the changing values are volatile.
+///
+internal sealed class BotMarketShift
+{
+ private long _lastSaleTicks;
+ private volatile bool _isStoreOpened;
+ private volatile bool _isOpenRequested;
+ private volatile bool _isEnding;
+ private volatile bool _isSettled;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The market map.
+ /// The position at which the bot opens its store.
+ /// The name of the store.
+ /// When the shift starts.
+ /// When the shift is over.
+ public BotMarketShift(GameMapDefinition map, Point stall, string storeName, DateTime startUtc, DateTime untilUtc)
+ {
+ this.Map = map;
+ this.Stall = stall;
+ this.StoreName = storeName;
+ this.StartUtc = startUtc;
+ this.UntilUtc = untilUtc;
+ this._lastSaleTicks = startUtc.Ticks;
+ }
+
+ ///
+ /// Gets the market map.
+ ///
+ public GameMapDefinition Map { get; }
+
+ ///
+ /// Gets the position at which the bot opens its store - apart from the stalls of the other merchants.
+ ///
+ public Point Stall { get; }
+
+ ///
+ /// Gets the name of the store.
+ ///
+ public string StoreName { get; }
+
+ ///
+ /// Gets when the shift started.
+ ///
+ public DateTime StartUtc { get; }
+
+ ///
+ /// Gets when the shift is over.
+ ///
+ public DateTime UntilUtc { get; }
+
+ ///
+ /// Gets or sets when the bot sold something the last time; the start of the shift before the first sale.
+ ///
+ public DateTime LastSaleUtc
+ {
+ get => new(Interlocked.Read(ref this._lastSaleTicks), DateTimeKind.Utc);
+ set => Interlocked.Exchange(ref this._lastSaleTicks, value.Ticks);
+ }
+
+ ///
+ /// Gets or sets the money of the bot when it opened its store: everything above is income, which is removed.
+ ///
+ public int MoneyAtOpen { get; set; }
+
+ ///
+ /// Gets or sets the items of the inventory when the bot opened its store: every other item which
+ /// appears in it during the shift is income, e.g. jewels paid for a good, and is removed.
+ ///
+ public IReadOnlySet- ItemsAtOpen { get; set; } = new HashSet
- ();
+
+ ///
+ /// Gets or sets a value indicating whether the bot opened its store on this shift.
+ ///
+ public bool IsStoreOpened
+ {
+ get => this._isStoreOpened;
+ set => this._isStoreOpened = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the bot already queued the opening of its store, which
+ /// runs in its next tick.
+ ///
+ public bool IsOpenRequested
+ {
+ get => this._isOpenRequested;
+ set => this._isOpenRequested = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the shift is ending: the store gets closed in the next
+ /// tick of the bot, and the shift is removed afterward.
+ ///
+ public bool IsEnding
+ {
+ get => this._isEnding;
+ set => this._isEnding = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the store of the shift is closed and its income removed.
+ /// A removal of the income which was queued by a late sale does nothing afterward - the goods taken
+ /// out of the store meanwhile aren't income (see ).
+ ///
+ public bool IsSettled
+ {
+ get => this._isSettled;
+ set => this._isSettled = value;
+ }
+}
diff --git a/src/GameLogic/Bots/BotMerchandise.cs b/src/GameLogic/Bots/BotMerchandise.cs
new file mode 100644
index 0000000000..64e39e3e19
--- /dev/null
+++ b/src/GameLogic/Bots/BotMerchandise.cs
@@ -0,0 +1,497 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+using MUnique.OpenMU.GameLogic.PlayerActions.PlayerStore;
+using MUnique.OpenMU.GameLogic.PlayerShopCurrencies;
+using MUnique.OpenMU.GameLogic.Views.PlayerShop;
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Decides which loot of a bot is worth a stall on the market and what it costs, and puts it into the
+/// personal store of the bot instead of selling it to an NPC (see ).
+///
+///
+/// The goods come from what the bot would otherwise sell to a merchant NPC: the loot it doesn't wear,
+/// the gear it outgrew and the jewels above the stock it keeps for its own gear. A price class () follows from the excellent options,
+/// luck and ancient set of the item, the price from the class and the settings of the
+/// .
+///
+internal static class BotMerchandise
+{
+ ///
+ /// The item level up to which the price has no level surcharge: a bot upgrades items without
+ /// luck up to +7, so anything above has been risked with luck.
+ ///
+ internal const int PriceBaseItemLevel = 7;
+
+ private static readonly MoveItemAction MoveAction = new();
+
+ private static readonly SetItemPriceAction PriceAction = new();
+
+ /// The game's own action to close a personal store, which tells the players around.
+ private static readonly CloseStoreAction StoreCloseAction = new();
+
+ ///
+ /// The excellent options which the players look for: on armor the damage decrease, defense success
+ /// rate and damage reflection, on weapons the excellent damage rate, the damage increases and the
+ /// attack speed. The others - more life, mana or zen - are cheap.
+ ///
+ private static readonly HashSet WantedExcellentAttributes =
+ [
+ Stats.ArmorDamageDecrease,
+ Stats.DefenseRatePvm,
+ Stats.DamageReflection,
+ Stats.ExcellentDamageChance,
+ Stats.PhysicalBaseDmgIncrease,
+ Stats.WizardryBaseDmgIncrease,
+ Stats.PhysicalBaseDmg,
+ Stats.WizardryBaseDmg,
+ Stats.AttackSpeedAny,
+ ];
+
+ ///
+ /// The price class of a good.
+ ///
+ internal enum PriceClass
+ {
+ /// Not worth a stall: sold to an NPC.
+ None,
+
+ /// A normal item of a higher set with luck, or an excellent item with only a cheap option and luck.
+ Cheap,
+
+ /// An excellent item with one wanted option, without luck.
+ Medium,
+
+ /// An ancient item, an excellent item with two or more options, or with one wanted option and luck.
+ Expensive,
+
+ /// A jewel of the surplus which the bot doesn't keep for its own gear.
+ Jewel,
+ }
+
+ ///
+ /// Determines the price class of an item.
+ ///
+ /// The item.
+ /// The configuration.
+ /// The price class; , if the item isn't worth a stall.
+ internal static PriceClass Classify(Item item, BotMerchantConfiguration configuration)
+ {
+ if (item.Definition is not { } definition
+ || !definition.IsPersonalStoreSellable
+ || HasOption(item, ItemOptionTypes.HarmonyOption))
+ {
+ // The game doesn't open a store with such an item.
+ return PriceClass.None;
+ }
+
+ if (GetJewelPrice(item, configuration) is not null)
+ {
+ return PriceClass.Jewel;
+ }
+
+ if (item.IsAncient())
+ {
+ return PriceClass.Expensive;
+ }
+
+ var luck = HasOption(item, ItemOptionTypes.Luck);
+ var excellentOptions = GetExcellentAttributes(item).ToList();
+ if (excellentOptions.Count >= 2)
+ {
+ return PriceClass.Expensive;
+ }
+
+ if (excellentOptions.Count == 1)
+ {
+ return WantedExcellentAttributes.Contains(excellentOptions[0])
+ ? luck ? PriceClass.Expensive : PriceClass.Medium
+ : luck ? PriceClass.Cheap : PriceClass.None;
+ }
+
+ return luck
+ && definition.DropLevel >= configuration.HighSetMinimumDropLevel
+ && item.Level >= configuration.MinimumNormalItemLevel
+ ? PriceClass.Cheap
+ : PriceClass.None;
+ }
+
+ ///
+ /// Draws a zen price within the spread around the calculated one and rounds it the way a player would:
+ /// to 1,000 zen below 100,000, to 50,000 below a million, to 100,000 below ten million, and to whole
+ /// millions above.
+ ///
+ /// The calculated price.
+ /// How far, in percent up or down, the price may differ; 0 keeps it exact.
+ /// A random number from 0 (the lowest price) to 1 (the highest).
+ /// The price for the store.
+ internal static int SpreadZenPrice(int amount, int spreadPercent, double random)
+ {
+ if (spreadPercent <= 0)
+ {
+ return amount;
+ }
+
+ var drawn = amount * (1 + (spreadPercent / 100.0 * ((Math.Clamp(random, 0, 1) * 2) - 1)));
+ double step = drawn switch
+ {
+ < 1_000 => 1,
+ < 100_000 => 1_000,
+ < 1_000_000 => 50_000,
+ < 10_000_000 => 100_000,
+ _ => 1_000_000,
+ };
+
+ var rounded = Math.Round(drawn / step, MidpointRounding.AwayFromZero) * step;
+ return (int)Math.Clamp(rounded, 1, int.MaxValue);
+ }
+
+ ///
+ /// Calculates the price of a good.
+ ///
+ /// The item.
+ /// The price class of the item.
+ /// The configuration.
+ /// The currency and the amount; null for .
+ internal static (string Currency, int Amount)? CalculatePrice(Item item, PriceClass priceClass, BotMerchantConfiguration configuration)
+ {
+ var levelSteps = Math.Max(0, item.Level - PriceBaseItemLevel);
+ var optionSteps = GetOptionLevel(item);
+ switch (priceClass)
+ {
+ case PriceClass.Cheap:
+ return (configuration.CheapCurrency, Sum(configuration.CheapPrice, configuration.CheapPricePerLevel * (long)levelSteps, configuration.CheapPricePerOption * optionSteps));
+ case PriceClass.Medium:
+ return (configuration.MediumCurrency, Sum(configuration.MediumPrice, configuration.MediumPricePerLevel * (long)levelSteps, configuration.MediumPricePerOption * optionSteps));
+ case PriceClass.Expensive:
+ var excellentOptions = GetExcellentAttributes(item).ToList();
+ var isAncient = item.IsAncient();
+ var luck = HasOption(item, ItemOptionTypes.Luck);
+ long basePrice = excellentOptions.Count switch
+ {
+ >= 3 => configuration.ExpensiveThreeOptionsPrice,
+ 2 => configuration.ExpensivePrice,
+ _ => isAncient ? configuration.ExpensivePrice : configuration.ExpensiveOneOptionPrice,
+ };
+
+ if (isAncient && luck)
+ {
+ basePrice += configuration.AncientLuckSurcharge;
+ }
+
+ var hasHighSetSurcharge = isAncient
+ || (luck && optionSteps > 0 && excellentOptions.Any(WantedExcellentAttributes.Contains));
+ if (hasHighSetSurcharge && item.Definition is { } definition)
+ {
+ var steps = Math.Max(0, definition.DropLevel - configuration.HighSetMinimumDropLevel) / Math.Max(1, configuration.HighSetSurchargeDropLevels);
+ basePrice += (long)steps * configuration.HighSetSurcharge;
+ }
+
+ return (configuration.ExpensiveCurrency, Sum(basePrice, configuration.ExpensivePricePerLevel * (long)levelSteps, configuration.ExpensivePricePerOption * optionSteps));
+ case PriceClass.Jewel:
+ return GetJewelPrice(item, configuration);
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Puts the goods among the junk of the bot into its personal store, each with its price, instead of
+ /// selling them to the merchant NPC.
+ ///
+ /// The bot.
+ /// The items which the bot would sell to the merchant NPC.
+ /// The number of items which were put into the store.
+ ///
+ /// Nothing is stocked when the plugin is off, when the store is open or full, or when the price
+ /// isn't allowed, e.g. a price in jewels without the player shop currencies plugin - such goods are
+ /// sold to the NPC as before.
+ ///
+ internal static async ValueTask StockStoreAsync(OfflinePlayer player, IReadOnlyCollection
- junk)
+ {
+ if (player.ShopStorage is not { StoreOpen: false } shopStorage)
+ {
+ return 0;
+ }
+
+ if (player.GameContext.FeaturePlugIns.GetPlugIn() is not { Configuration: { } configuration } plugIn)
+ {
+ // Switched off: the goods of earlier shifts must not stay locked away in the store.
+ await TakeOutOfStoreAsync(player, shopStorage.Items.ToList()).ConfigureAwait(false);
+ return 0;
+ }
+
+ if (player.Level < Math.Max(configuration.MinimumLevel, BotMarketPlanner.MinimumStoreLevel))
+ {
+ return 0;
+ }
+
+ var stocked = 0;
+ foreach (var item in junk)
+ {
+ var priceClass = Classify(item, configuration);
+ if (plugIn.IsRetired(item, configuration)
+ || CalculatePrice(item, priceClass, configuration) is not { } offer
+ || !TryCreatePrice(player.GameContext, offer.Currency, offer.Amount, out var price)
+ || (priceClass == PriceClass.Jewel && !MayStockJewel(shopStorage, item, price, configuration))
+ || shopStorage.CheckInvSpace(item) is not { } storeSlot)
+ {
+ continue;
+ }
+
+ if (price.Kind == ShopPriceKind.Zen)
+ {
+ var spread = SpreadZenPrice(price.Amount, configuration.PriceSpreadPercent, Rand.NextDouble());
+ price = price with { Amount = Math.Min(spread, ShopPrices.GetMaximumAmount(player.GameContext, ShopPriceKind.Zen)) };
+ }
+
+ var fromSlot = item.ItemSlot;
+ if (await PriceAction.TrySetPriceAsync(player, fromSlot, price).ConfigureAwait(false) != ItemPriceResult.Success)
+ {
+ continue;
+ }
+
+ await MoveAction.MoveItemAsync(player, fromSlot, Storages.Inventory, storeSlot, Storages.PersonalStore).ConfigureAwait(false);
+ if (shopStorage.GetItem(storeSlot) == item)
+ {
+ stocked++;
+ }
+ else
+ {
+ // The move was refused; the item stays junk and must not keep a price.
+ item.SetStorePrice(null);
+ }
+ }
+
+ if (stocked > 0)
+ {
+ player.Logger.LogDebug("Bot '{Name}' put {Count} item(s) into its store for the market.", player.Name, stocked);
+ }
+
+ return stocked;
+ }
+
+ ///
+ /// Closes the store of the market shift, removes the last income and retires the goods which found
+ /// no buyer for too long.
+ ///
+ /// The bot.
+ /// The market shift.
+ ///
+ /// Runs in the tick of the bot (see ), or under its
+ /// persistence lock when it logs out (see ). The store is closed
+ /// first: closing takes the store lock of the purchases, so no sale can add income afterward.
+ ///
+ /// The task.
+ internal static async ValueTask CloseShiftStoreAsync(OfflinePlayer bot, BotMarketShift shift)
+ {
+ if (bot.ShopStorage?.StoreOpen ?? false)
+ {
+ await StoreCloseAction.CloseStoreAsync(bot).ConfigureAwait(false);
+ }
+
+ if (shift.IsStoreOpened && !shift.IsSettled)
+ {
+ await RemoveIncomeAsync(bot, shift).ConfigureAwait(false);
+
+ // A sale right before the store was closed queued another removal of the income, which runs
+ // after this one - it must not take the goods retired below for income.
+ shift.IsSettled = true;
+ await RetireUnsoldGoodsAsync(bot).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Ends the market shift of a bot which logs out: closes its store and removes the income of the
+ /// sales whose removal is still queued for a tick which won't come anymore.
+ ///
+ /// The bot.
+ ///
+ /// Runs before the logout saves the bot - otherwise the income of the last sales would be saved and
+ /// stay with the bot. The shift lives in memory only, so after the next login nothing could tell
+ /// the income from the own money anymore.
+ ///
+ /// The task.
+ internal static async ValueTask EndShiftOnLogoutAsync(OfflinePlayer bot)
+ {
+ if (bot.MarketShift is not { } shift)
+ {
+ return;
+ }
+
+ shift.IsEnding = true;
+ await CloseShiftStoreAsync(bot, shift).ConfigureAwait(false);
+ bot.MarketShift = null;
+ }
+
+ ///
+ /// Removes the income of the shift: the zen above what the bot had when it opened its store, and the
+ /// items which appeared in its inventory since then - the jewels paid for its goods. The bot buys
+ /// nothing, and the jewels would fill its inventory until it can't take any payment anymore.
+ ///
+ /// The bot.
+ /// The market shift.
+ /// Runs in the tick of the bot (see ) or before its logout (see ).
+ /// The task.
+ internal static async ValueTask RemoveIncomeAsync(Player bot, BotMarketShift shift)
+ {
+ if (!shift.IsStoreOpened || shift.IsSettled || bot.Inventory is not { } inventory)
+ {
+ return;
+ }
+
+ var money = Math.Max(0, bot.Money - shift.MoneyAtOpen);
+ if (money > 0)
+ {
+ bot.Money = shift.MoneyAtOpen;
+ }
+
+ var income = inventory.Items.Where(item => !shift.ItemsAtOpen.Contains(item)).ToList();
+ foreach (var item in income)
+ {
+ await bot.DestroyInventoryItemAsync(item).ConfigureAwait(false);
+ }
+
+ if (money > 0 || income.Count > 0)
+ {
+ bot.Logger.LogDebug("Bot '{Name}' removed its income of {Money} zen and {Count} item(s).", bot.Name, money, income.Count);
+ }
+ }
+
+ ///
+ /// After a market shift, counts one more shift without a buyer for each good left in the store,
+ /// and takes the goods which reached out
+ /// of it - the next visit at a merchant NPC sells them, making room for fresh goods.
+ ///
+ /// The bot, with its store closed.
+ /// The task.
+ internal static async ValueTask RetireUnsoldGoodsAsync(OfflinePlayer bot)
+ {
+ if (bot.ShopStorage is not { StoreOpen: false } shopStorage
+ || bot.GameContext.FeaturePlugIns.GetPlugIn() is not { Configuration: { } configuration } plugIn)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ var retired = shopStorage.Items
+ .Where(item => plugIn.CountUnsoldShift(item, now) >= configuration.UnsoldShiftsUntilNpc)
+ .ToList();
+ await TakeOutOfStoreAsync(bot, retired).ConfigureAwait(false);
+ }
+
+ ///
+ /// Takes goods out of the store into the inventory, without their price: they become junk which the
+ /// bot sells to a merchant NPC. A good which doesn't fit into the inventory stays in the store for now.
+ ///
+ private static async ValueTask TakeOutOfStoreAsync(OfflinePlayer bot, IReadOnlyCollection
- goods)
+ {
+ if (goods.Count == 0 || bot.Inventory is not { } inventory)
+ {
+ return;
+ }
+
+ var takenOut = 0;
+ foreach (var item in goods)
+ {
+ if (inventory.CheckInvSpace(item) is not { } slot)
+ {
+ continue;
+ }
+
+ var fromSlot = item.ItemSlot;
+ await MoveAction.MoveItemAsync(bot, fromSlot, Storages.PersonalStore, slot, Storages.Inventory).ConfigureAwait(false);
+ if (inventory.GetItem(slot) == item)
+ {
+ item.SetStorePrice(null);
+ takenOut++;
+ }
+ }
+
+ if (takenOut > 0)
+ {
+ bot.Logger.LogDebug("Bot '{Name}' took {Count} unsold item(s) out of its store.", bot.Name, takenOut);
+ }
+ }
+
+ ///
+ /// Creates the price from the configured currency: zen or an item, e.g. a jewel, if the game allows it
+ /// in player shops. Account coins are no currency of the bots - they are usually bought with real money.
+ ///
+ private static bool TryCreatePrice(IGameContext gameContext, string currency, int amount, out ShopPrice price)
+ {
+ if (!ShopPrices.TryParse(gameContext, currency, amount, out price)
+ || price.Kind == ShopPriceKind.AccountCoin
+ || !ShopPrices.IsAllowed(gameContext, price))
+ {
+ return false;
+ }
+
+ price = price with { Amount = Math.Min(price.Amount, ShopPrices.GetMaximumAmount(gameContext, price.Kind)) };
+ return true;
+ }
+
+ ///
+ /// Gets the configured price of a jewel; null, if the item is no jewel which the bots sell.
+ ///
+ private static (string Currency, int Amount)? GetJewelPrice(Item item, BotMerchantConfiguration configuration)
+ {
+ if (item.Definition is not { } definition)
+ {
+ return null;
+ }
+
+ var identifier = new ItemIdentifier(definition.Number, definition.Group);
+ return identifier switch
+ {
+ _ when identifier == ItemConstants.JewelOfBless => (configuration.BlessCurrency, configuration.BlessPrice),
+ _ when identifier == ItemConstants.JewelOfSoul => (configuration.SoulCurrency, configuration.SoulPrice),
+ _ when identifier == ItemConstants.JewelOfChaos => (configuration.ChaosCurrency, configuration.ChaosPrice),
+ _ when identifier == ItemConstants.JewelOfLife => (configuration.LifeCurrency, configuration.LifePrice),
+ _ when identifier == ItemConstants.JewelOfCreation => (configuration.CreationCurrency, configuration.CreationPrice),
+ _ => null,
+ };
+ }
+
+ ///
+ /// Determines whether the jewel may go into the store: the store doesn't hold the maximum of its kind
+ /// yet, and its price isn't paid with the same jewel - a Bless for a Bless is no trade.
+ ///
+ private static bool MayStockJewel(IStorage shopStorage, Item jewel, ShopPrice price, BotMerchantConfiguration configuration)
+ {
+ if (ShopPrice.IsSameItem(price.PriceItem, jewel.Definition))
+ {
+ return false;
+ }
+
+ var stockedOfKind = shopStorage.Items.Count(i => ShopPrice.IsSameItem(i.Definition, jewel.Definition));
+ return stockedOfKind < configuration.MaximumJewelsPerKind;
+ }
+
+ private static int Sum(long basePrice, long levelSurcharge, double optionSurcharge)
+ => (int)Math.Clamp(basePrice + levelSurcharge + (long)Math.Floor(optionSurcharge), 1, int.MaxValue);
+
+ private static bool HasOption(Item item, ItemOptionType optionType)
+ => item.ItemOptions.Any(link => link.ItemOption?.OptionType == optionType);
+
+ ///
+ /// Gets the level of the additional option: 1 for +4, up to 4 for +16.
+ ///
+ private static int GetOptionLevel(Item item)
+ => item.ItemOptions.Where(link => link.ItemOption?.OptionType == ItemOptionTypes.Option).Select(link => link.Level).DefaultIfEmpty(0).Max();
+
+ private static IEnumerable GetExcellentAttributes(Item item)
+ => item.ItemOptions
+ .Where(link => link.ItemOption?.OptionType == ItemOptionTypes.Excellent)
+ .Select(link => link.ItemOption!.PowerUpDefinition?.TargetAttribute)
+ .OfType();
+}
diff --git a/src/GameLogic/Bots/BotMerchantConfiguration.cs b/src/GameLogic/Bots/BotMerchantConfiguration.cs
new file mode 100644
index 0000000000..14b366e2e6
--- /dev/null
+++ b/src/GameLogic/Bots/BotMerchantConfiguration.cs
@@ -0,0 +1,391 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Globalization;
+
+///
+/// Configuration of the : how many bots sell their goods on the market
+/// map and in the cities at the same time, and for how long.
+///
+///
+/// A bot goes to the market when it collected enough goods in its personal store, stays there for a
+/// shift of random length, and goes back hunting afterward. Some players open their store in a city
+/// instead, so a share of the shifts takes place there. The limits apply per game server.
+///
+public class BotMerchantConfiguration
+{
+ ///
+ /// The default of : Loren Market, the map which is made for player stores.
+ ///
+ internal const short DefaultMarketMapNumber = 79;
+
+ ///
+ /// The default of : Lorencia, Devias, Noria and Elvenland, the towns where the players meet.
+ ///
+ internal const string DefaultCityMapNumbers = "0, 2, 3, 51";
+
+ ///
+ /// Gets or sets the number of the map on which the bots open their stores.
+ ///
+ ///
+ /// The bot gets there through the map's spawn gate directly, regardless of the warp list - its level
+ /// requirement and costs are for the players.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MarketMapNumber_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MarketMapNumber_Description))]
+ public short MarketMapNumber { get; set; } = DefaultMarketMapNumber;
+
+ ///
+ /// Gets or sets the maximum number of bots with an open store at the same time, on the market and in
+ /// the cities together, per game server.
+ ///
+ ///
+ /// It's a ceiling, not a number of dedicated merchants: only bots which collected enough goods go,
+ /// and they go back hunting after their shift.
+ ///
+ [Range(0, 50)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MaximumMerchants_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MaximumMerchants_Description))]
+ public int MaximumMerchants { get; set; } = 50;
+
+ ///
+ /// Gets or sets the share of the shifts, in percent, which a bot spends in one of the
+ /// instead of on the market.
+ ///
+ [Range(0, 100)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CityMerchantsPercent_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CityMerchantsPercent_Description))]
+ public int CityMerchantsPercent { get; set; } = 20;
+
+ ///
+ /// Gets or sets how many bots at most start a shift every 30 seconds, per game server.
+ ///
+ ///
+ /// After a server start, many bots have goods at once; this way the market fills up within minutes
+ /// instead of all at once, and the parties of the bots don't fall apart together.
+ ///
+ [Range(1, 50)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_NewMerchantsPerCheck_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_NewMerchantsPerCheck_Description))]
+ public int NewMerchantsPerCheck { get; set; } = 3;
+
+ ///
+ /// Gets or sets the numbers of the city maps, separated by commas, in which a share of the shifts
+ /// (see ) takes place.
+ ///
+ ///
+ /// Like on the market, the stall is in the safezone near the spawn gate of the map, which the bot
+ /// reaches regardless of the warp list.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CityMapNumbers_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CityMapNumbers_Description))]
+ public string CityMapNumbers { get; set; } = DefaultCityMapNumbers;
+
+ ///
+ /// Gets or sets the minimum level of a bot to go to the market.
+ ///
+ ///
+ /// The game allows opening a personal store from level 6, so lower values have the same effect.
+ ///
+ [Range(0, 1000)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MinimumLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MinimumLevel_Description))]
+ public int MinimumLevel { get; set; } = 6;
+
+ ///
+ /// Gets or sets the minimum number of priced goods in the personal store of a bot to go to the market.
+ ///
+ [Range(1, 32)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MinimumGoodsToStart_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MinimumGoodsToStart_Description))]
+ public int MinimumGoodsToStart { get; set; } = 4;
+
+ ///
+ /// Gets or sets the number of goods below which a bot closes its store and leaves the market early.
+ /// By default, it stays as long as it has anything to sell.
+ ///
+ [Range(1, 32)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MinimumGoodsToStay_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MinimumGoodsToStay_Description))]
+ public int MinimumGoodsToStay { get; set; } = 1;
+
+ ///
+ /// Gets or sets the shortest shift on the market. The actual length is random between this and
+ /// , so the merchants don't all leave at once.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MinimumShiftDuration_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MinimumShiftDuration_Description))]
+ public TimeSpan MinimumShiftDuration { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Gets or sets the longest shift on the market.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MaximumShiftDuration_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MaximumShiftDuration_Description))]
+ public TimeSpan MaximumShiftDuration { get; set; } = TimeSpan.FromMinutes(90);
+
+ ///
+ /// Gets or sets after how long without a sale a bot leaves the market before its shift is over,
+ /// making room for another one; zero switches it off.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_LeaveWithoutSalesAfter_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_LeaveWithoutSalesAfter_Description))]
+ public TimeSpan LeaveWithoutSalesAfter { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Gets or sets the time after a shift before the same bot goes to the market again, so that the
+ /// market sees different bots.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_TimeBetweenShifts_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_TimeBetweenShifts_Description))]
+ public TimeSpan TimeBetweenShifts { get; set; } = TimeSpan.FromHours(2);
+
+ ///
+ /// Gets or sets after how many market shifts without a buyer a good leaves the store and is sold to
+ /// an NPC, making room for fresh goods.
+ ///
+ ///
+ /// There are no discounts: players who know that the price drops would never buy at the first one.
+ /// The count is kept while the game server runs.
+ ///
+ [Range(1, 100)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_UnsoldShiftsUntilNpc_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_UnsoldShiftsUntilNpc_Description))]
+ public int UnsoldShiftsUntilNpc { get; set; } = 3;
+
+ ///
+ /// Gets or sets the names of the stores, separated by commas; every shift draws one which no other
+ /// open store of the bots has.
+ ///
+ ///
+ /// A store name is a single text which every player sees in the same language, so it can't be translated.
+ /// When all of them are taken, the store gets the name of the bot.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_StoreNames_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_StoreNames_Description))]
+ public string StoreNames { get; set; } = "Good stuff, Cheap items, Best prices, Look inside, Fair prices, Rare finds, Bargains, Hot deals, Quick sale, Loot sale, Clean items, Low prices, Must see, Take a look, All must go, Fair offers, Big sale, Mega sale, Cheap stuff, Good deals, Nice items, Fresh loot, Drops for sale, Buy here, Shop here, Come and see, Ok prices, Best deals, Rare items, For sale, Daily deals, Grab it, Last pieces, Check it out, Click me, Stop by, Best offer, Nice prices, Sale here, Low cost, Fast sale, Fair deal, Everything goes, Lucky find, Fine stuff, Nice loot, Special offer, Pick one, Cheap and good, Open shop";
+
+ ///
+ /// Gets or sets the drop level from which an item counts as one of the higher sets: only such normal items are worth a stall, and above it ancient and the best excellent items get the surcharge of .
+ ///
+ [Range(0, 400)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_HighSetMinimumDropLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_HighSetMinimumDropLevel_Description))]
+ public int HighSetMinimumDropLevel { get; set; } = 60;
+
+ ///
+ /// Gets or sets the minimum level (+) of a normal item of a higher set with luck to be stocked.
+ ///
+ [Range(0, 15)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MinimumNormalItemLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MinimumNormalItemLevel_Description))]
+ public int MinimumNormalItemLevel { get; set; } = 7;
+
+ ///
+ /// Gets or sets how far, in percent up or down, a zen price may differ from the calculated one. The price is
+ /// drawn once, when the good goes into the store, and rounded to an amount a player would ask for - so the
+ /// same good costs a bit differently at two merchants. Prices in jewels stay exact; 0 keeps all prices exact.
+ ///
+ [Range(0, 50)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_PriceSpreadPercent_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_PriceSpreadPercent_Description))]
+ public int PriceSpreadPercent { get; set; } = 10;
+
+ ///
+ /// Gets or sets the currency of the cheap goods: normal items, and excellent items with only a cheap option. "zen" or the name of a jewel ("bless", "soul", "chaos", "life", "creation").
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CheapCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CheapCurrency_Description))]
+ public string CheapCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the base price of the cheap goods.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CheapPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CheapPrice_Description))]
+ public int CheapPrice { get; set; } = 2_000_000;
+
+ ///
+ /// Gets or sets the surcharge of the cheap goods per item level above +7.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CheapPricePerLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CheapPricePerLevel_Description))]
+ public int CheapPricePerLevel { get; set; } = 1_000_000;
+
+ ///
+ /// Gets or sets the surcharge of the cheap goods per +4 of the additional option; the sum is rounded down.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CheapPricePerOption_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CheapPricePerOption_Description))]
+ public double CheapPricePerOption { get; set; } = 500_000;
+
+ ///
+ /// Gets or sets the currency of the medium goods: excellent items with one wanted option, without luck.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MediumCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MediumCurrency_Description))]
+ public string MediumCurrency { get; set; } = "bless";
+
+ ///
+ /// Gets or sets the base price of the medium goods.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MediumPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MediumPrice_Description))]
+ public int MediumPrice { get; set; } = 2;
+
+ ///
+ /// Gets or sets the surcharge of the medium goods per item level above +7.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MediumPricePerLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MediumPricePerLevel_Description))]
+ public int MediumPricePerLevel { get; set; } = 1;
+
+ ///
+ /// Gets or sets the surcharge of the medium goods per +4 of the additional option; the sum is rounded down.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MediumPricePerOption_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MediumPricePerOption_Description))]
+ public double MediumPricePerOption { get; set; } = 0.5;
+
+ ///
+ /// Gets or sets the currency of the expensive goods: ancient items, excellent items with two or more options, and with one wanted option and luck.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveCurrency_Description))]
+ public string ExpensiveCurrency { get; set; } = "soul";
+
+ ///
+ /// Gets or sets the base price of an excellent item with one wanted option and luck.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveOneOptionPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveOneOptionPrice_Description))]
+ public int ExpensiveOneOptionPrice { get; set; } = 3;
+
+ ///
+ /// Gets or sets the base price of an excellent item with two options and of an ancient item.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePrice_Description))]
+ public int ExpensivePrice { get; set; } = 5;
+
+ ///
+ /// Gets or sets the base price of an excellent item with three or more options.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Description))]
+ public int ExpensiveThreeOptionsPrice { get; set; } = 10;
+
+ ///
+ /// Gets or sets the surcharge of the expensive goods per item level above +7.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePricePerLevel_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePricePerLevel_Description))]
+ public int ExpensivePricePerLevel { get; set; } = 1;
+
+ ///
+ /// Gets or sets the surcharge of the expensive goods per +4 of the additional option; the sum is rounded down.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePricePerOption_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ExpensivePricePerOption_Description))]
+ public double ExpensivePricePerOption { get; set; } = 0.5;
+
+ ///
+ /// Gets or sets the surcharge of an ancient item with luck.
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_AncientLuckSurcharge_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_AncientLuckSurcharge_Description))]
+ public int AncientLuckSurcharge { get; set; } = 1;
+
+ ///
+ /// Gets or sets the surcharge of an ancient item, and of an excellent item with a wanted option, luck and an additional option, per drop levels above .
+ ///
+ [Range(0, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_HighSetSurcharge_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_HighSetSurcharge_Description))]
+ public int HighSetSurcharge { get; set; } = 1;
+
+ ///
+ /// Gets or sets how many drop levels make one step of .
+ ///
+ [Range(1, 400)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_HighSetSurchargeDropLevels_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_HighSetSurchargeDropLevels_Description))]
+ public int HighSetSurchargeDropLevels { get; set; } = 20;
+
+ ///
+ /// Gets or sets how many jewels of one kind a bot puts into its store at most, so that its stall isn't full of jewels; 0 keeps the jewels out of the stores.
+ ///
+ ///
+ /// Each jewel counts as one of the , and Jewels of Chaos are the most common loot.
+ /// With more than two per kind, most stores on the market held nothing but a few Jewels of Chaos.
+ ///
+ [Range(0, 32)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_MaximumJewelsPerKind_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_MaximumJewelsPerKind_Description))]
+ public int MaximumJewelsPerKind { get; set; } = 2;
+
+ ///
+ /// Gets or sets the currency for which a bot sells its surplus of the Jewel of Bless: "zen" or another jewel.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_BlessCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_BlessCurrency_Description))]
+ public string BlessCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the price of one Jewel of Bless.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_BlessPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_BlessPrice_Description))]
+ public int BlessPrice { get; set; } = 9_000_000;
+
+ ///
+ /// Gets or sets the currency for which a bot sells its surplus of the Jewel of Soul: "zen" or another jewel.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_SoulCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_SoulCurrency_Description))]
+ public string SoulCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the price of one Jewel of Soul.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_SoulPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_SoulPrice_Description))]
+ public int SoulPrice { get; set; } = 6_000_000;
+
+ ///
+ /// Gets or sets the currency for which a bot sells its surplus of the Jewel of Chaos: "zen" or another jewel.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ChaosCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ChaosCurrency_Description))]
+ public string ChaosCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the price of one Jewel of Chaos.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_ChaosPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_ChaosPrice_Description))]
+ public int ChaosPrice { get; set; } = 810_000;
+
+ ///
+ /// Gets or sets the currency for which a bot sells its surplus of the Jewel of Life: "zen" or another jewel.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_LifeCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_LifeCurrency_Description))]
+ public string LifeCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the price of one Jewel of Life.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_LifePrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_LifePrice_Description))]
+ public int LifePrice { get; set; } = 45_000_000;
+
+ ///
+ /// Gets or sets the currency for which a bot sells its surplus of the Jewel of Creation: "zen" or another jewel.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CreationCurrency_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CreationCurrency_Description))]
+ public string CreationCurrency { get; set; } = "zen";
+
+ ///
+ /// Gets or sets the price of one Jewel of Creation.
+ ///
+ [Range(1, int.MaxValue)]
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotMerchantConfiguration_CreationPrice_Name), Description = nameof(PlugInResources.BotMerchantConfiguration_CreationPrice_Description))]
+ public int CreationPrice { get; set; } = 36_000_000;
+
+ ///
+ /// Gets the store names of , without the empty ones.
+ ///
+ /// The store names.
+ internal IReadOnlyList GetStoreNames()
+ => (this.StoreNames ?? string.Empty)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ ///
+ /// Gets the map numbers of , without the ones which aren't numbers.
+ ///
+ /// The map numbers.
+ internal IReadOnlyList GetCityMapNumbers()
+ => (this.CityMapNumbers ?? string.Empty)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(n => short.TryParse(n, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ? number : (short?)null)
+ .OfType()
+ .Distinct()
+ .ToList();
+}
diff --git a/src/GameLogic/Bots/BotMerchantPlugIn.cs b/src/GameLogic/Bots/BotMerchantPlugIn.cs
new file mode 100644
index 0000000000..dcf855a538
--- /dev/null
+++ b/src/GameLogic/Bots/BotMerchantPlugIn.cs
@@ -0,0 +1,325 @@
+//
+// 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.InteropServices;
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.Persistence;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Lets the server-side bots sell their goods in personal stores on the market map (Loren Market) and,
+/// for a share of the shifts, in the cities: a bot with enough priced goods in its store goes there for
+/// a shift, opens the store and goes back hunting afterward (see ).
+///
+///
+/// Every bot may become a merchant now and then - there are no dedicated merchant bots. A shift ends
+/// when its time is up, when the bot sold everything, after a long time without a sale and when
+/// the bot logs out (the presence rotation of the bots goes on as usual). The limits of the
+/// apply per game server.
+/// It's a feature plugin of the bots, listed in the admin panel next to them.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.BotMerchantPlugIn_Name), Description = nameof(PlugInResources.BotMerchantPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("6E0B6C2F-8F7E-4C71-9C55-2B7C1E4A9D13")]
+public class BotMerchantPlugIn
+ : IFeaturePlugIn,
+ IPeriodicTaskPlugIn,
+ IItemSoldToOtherPlayerPlugIn,
+ ISupportCustomConfiguration,
+ ISupportDefaultCustomConfiguration,
+ IDisabledByDefault
+{
+ ///
+ /// How often a game server sends new bots to the market. The shifts last half an hour and more, so
+ /// there's no need to check every second like the timer of the periodic tasks does.
+ ///
+ private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ /// How often the counts of the goods which weren't counted for a long time are forgotten.
+ ///
+ private static readonly TimeSpan UnsoldShiftsCleanupInterval = TimeSpan.FromMinutes(10);
+
+ ///
+ /// The least time for which the count of a good is kept after its last shift. A good leaves the
+ /// stores without a sale too - a retired good goes to an NPC, the bot may be removed -, so without
+ /// it the counts would pile up until the server restarts.
+ ///
+ private static readonly TimeSpan MinimumUnsoldShiftsMemory = TimeSpan.FromDays(1);
+
+ private readonly ConcurrentDictionary _nextCheckUtc = new();
+
+ ///
+ /// The number of market shifts each good spent in a store without a buyer, and when it was counted
+ /// the last time, by the id of the item. In memory only: a restart of the server gives every good a
+ /// new chance, like a count which is forgotten after a long time (see ).
+ ///
+ private readonly ConcurrentDictionary _unsoldShifts = new();
+
+ private long _nextUnsoldShiftsCleanupTicks;
+
+ ///
+ public BotMerchantConfiguration? Configuration { get; set; }
+
+ ///
+ /// Gets the number of goods whose shifts without a buyer are counted, old ones included until they're forgotten.
+ ///
+ internal int UnsoldGoodCount => this._unsoldShifts.Count;
+
+ ///
+ public object CreateDefaultConfig() => new BotMerchantConfiguration();
+
+ ///
+ public async ValueTask ExecuteTaskAsync(GameContext gameContext)
+ {
+ var now = DateTime.UtcNow;
+ if (this.Configuration is not { } configuration
+ || now < this._nextCheckUtc.GetValueOrDefault(gameContext))
+ {
+ return;
+ }
+
+ this._nextCheckUtc[gameContext] = now + CheckInterval;
+ this.ForgetOldUnsoldShifts(configuration, now);
+ await this.StartShiftsAsync(gameContext, configuration, now).ConfigureAwait(false);
+ }
+
+ ///
+ public void ForceStart() => this._nextCheckUtc.Clear();
+
+ ///
+ public void ItemSold(Player seller, Item item, Player buyer)
+ {
+ if (seller is OfflinePlayer { MarketShift: { } shift } bot)
+ {
+ shift.LastSaleUtc = DateTime.UtcNow;
+ this._unsoldShifts.TryRemove(item.GetId(), out _);
+
+ // The bot buys nothing, so its income is removed - in its own tick, which is the only
+ // place allowed to change its inventory (see OfflinePlayer.PendingBotActions).
+ bot.PendingBotActions.Enqueue(() => BotMerchandise.RemoveIncomeAsync(bot, shift));
+ }
+ }
+
+ ///
+ /// Takes the bot out of its party of bots for the shift (see ).
+ ///
+ ///
+ /// A player may have joined the party since the bot was picked, so the members are checked again
+ /// right before - a party with a player is never left.
+ ///
+ /// The bot.
+ /// true, if the bot is in no party anymore.
+ internal static async ValueTask LeavePartyAsync(OfflinePlayer bot)
+ {
+ if (bot.Party is not { } party)
+ {
+ return true;
+ }
+
+ if (!BotMarketPlanner.HasOnlyBots(party.PartyList))
+ {
+ return false;
+ }
+
+ try
+ {
+ await party.KickMySelfAsync(bot).ConfigureAwait(false);
+ bot.Logger.LogDebug("Bot '{Name}' leaves its party for a market shift.", bot.Name);
+ }
+ catch (Exception ex)
+ {
+ bot.Logger.LogWarning(ex, "Bot '{Name}' couldn't leave its party for a market shift.", bot.Name);
+ }
+
+ return bot.Party is null;
+ }
+
+ ///
+ /// Counts one more market shift without a buyer for the good.
+ ///
+ /// The good.
+ /// The current time.
+ /// The number of shifts the good spent in the store without a buyer.
+ internal int CountUnsoldShift(Item item, DateTime now)
+ => this._unsoldShifts.AddOrUpdate(item.GetId(), new UnsoldGood(1, now), (_, good) => new UnsoldGood(good.Shifts + 1, now)).Shifts;
+
+ ///
+ /// Determines whether the good spent too many shifts in a store without a buyer: it's sold to an NPC
+ /// and doesn't go back into the store.
+ ///
+ /// The good.
+ /// The configuration.
+ /// true, if the good is retired from the market.
+ internal bool IsRetired(Item item, BotMerchantConfiguration configuration)
+ => this._unsoldShifts.TryGetValue(item.GetId(), out var good) && good.Shifts >= configuration.UnsoldShiftsUntilNpc;
+
+ ///
+ /// Forgets the counts of the goods which weren't counted for a long time: they left the stores without
+ /// a sale, or their bot didn't go to the market for so long that the good gets a new chance.
+ ///
+ ///
+ /// A good in a store is counted after each of its shifts, and a retired good is sold at the next visit
+ /// of a merchant NPC, so the memory lasts several times as long as a shift and the break after it.
+ /// Runs at most every .
+ ///
+ /// The configuration.
+ /// The current time.
+ internal void ForgetOldUnsoldShifts(BotMerchantConfiguration configuration, DateTime now)
+ {
+ var next = Interlocked.Read(ref this._nextUnsoldShiftsCleanupTicks);
+ if (now.Ticks < next
+ || Interlocked.CompareExchange(ref this._nextUnsoldShiftsCleanupTicks, (now + UnsoldShiftsCleanupInterval).Ticks, next) != next)
+ {
+ return;
+ }
+
+ var memory = (configuration.MaximumShiftDuration + configuration.TimeBetweenShifts) * Math.Max(1, configuration.UnsoldShiftsUntilNpc + 1);
+ var forgetBefore = now - (memory > MinimumUnsoldShiftsMemory ? memory : MinimumUnsoldShiftsMemory);
+ foreach (var good in this._unsoldShifts)
+ {
+ if (good.Value.LastCountUtc < forgetBefore)
+ {
+ // Removes the entry only if it wasn't counted again meanwhile.
+ this._unsoldShifts.TryRemove(good);
+ }
+ }
+ }
+
+ ///
+ /// Sends bots with goods to the market and the cities until there's the configured number of merchants.
+ ///
+ private async ValueTask StartShiftsAsync(GameContext gameContext, BotMerchantConfiguration configuration, DateTime now)
+ {
+ var players = await gameContext.GetPlayersAsync().ConfigureAwait(false);
+ var bots = players.OfType().Where(p => p.Account?.IsBot == true).ToList();
+ var shifts = bots.Select(b => b.MarketShift).OfType().ToList();
+ var freePlaces = configuration.MaximumMerchants - shifts.Count;
+ if (freePlaces <= 0)
+ {
+ return;
+ }
+
+ var candidates = bots.Where(b => BotMarketPlanner.MayStartShift(b, configuration, now)).ToList();
+ if (candidates.Count == 0)
+ {
+ return;
+ }
+
+ var logger = gameContext.LoggerFactory.CreateLogger();
+
+ // Resolved once per check; a map which isn't usable (unknown, without a spawn gate) or full is null.
+ var places = new Dictionary();
+ async ValueTask GetPlaceAsync(short mapNumber)
+ {
+ if (!places.TryGetValue(mapNumber, out var place))
+ {
+ place = await MarketPlace.ResolveAsync(gameContext, mapNumber, shifts).ConfigureAwait(false);
+ places[mapNumber] = place;
+ }
+
+ return place;
+ }
+
+ // The market is made for stores, so its whole safezone is used; in a city, the stores stay in the middle.
+ async ValueTask<(MarketPlace? Place, Point? Stall)> FindStallAsync(short mapNumber)
+ {
+ if (await GetPlaceAsync(mapNumber).ConfigureAwait(false) is not { } place)
+ {
+ return (null, null);
+ }
+
+ var searchRange = mapNumber == configuration.MarketMapNumber ? (int?)null : BotMarketPlanner.StallSearchRange;
+ var stall = BotMarketPlanner.FindStall(place.Map.Terrain, place.Arrival, place.Stalls, Rand.NextInt, searchRange);
+ if (stall is null)
+ {
+ logger.LogDebug("The map {Map} has no free stall for more bots.", place.Definition.Name);
+ places[mapNumber] = null;
+ }
+
+ return (place, stall);
+ }
+
+ var cities = configuration.GetCityMapNumbers().Where(n => n != configuration.MarketMapNumber).ToList();
+ var storeNames = configuration.GetStoreNames();
+ var takenNames = shifts.Select(s => s.StoreName).ToList();
+
+ // Bots without a party first, so that the parties of the bots lose as few members as possible.
+ var starting = candidates
+ .OrderBy(b => b.Party is null ? 0 : 1)
+ .ThenBy(_ => Rand.NextInt(0, int.MaxValue))
+ .Take(Math.Min(freePlaces, configuration.NewMerchantsPerCheck));
+ foreach (var bot in starting)
+ {
+ var mapNumber = BotMarketPlanner.DrawShiftMap(configuration, cities, Rand.NextInt);
+
+ // A city which can't take the bot (unusable or full) leaves it to the market.
+ var (place, stall) = await FindStallAsync(mapNumber).ConfigureAwait(false);
+ if (stall is null && mapNumber != configuration.MarketMapNumber)
+ {
+ (place, stall) = await FindStallAsync(configuration.MarketMapNumber).ConfigureAwait(false);
+ }
+
+ if (place is null || stall is not { } freeStall)
+ {
+ continue;
+ }
+
+ // The bot was picked before the awaits above; meanwhile, it may have started an errand,
+ // joined an event meeting or a party with a player.
+ if (!BotMarketPlanner.MayStartShift(bot, configuration, now)
+ || !await LeavePartyAsync(bot).ConfigureAwait(false))
+ {
+ continue;
+ }
+
+ place.Stalls.Add(freeStall);
+ var storeName = BotMarketPlanner.DrawStoreName(storeNames, takenNames, bot.Name, Rand.NextInt);
+ takenNames.Add(storeName);
+ var duration = BotMarketPlanner.DrawShiftDuration(configuration, Rand.NextInt);
+ bot.MarketShift = new BotMarketShift(place.Definition, freeStall, storeName, now, now + duration);
+ bot.Logger.LogDebug("Bot '{Name}' goes to {Map} for {Duration} with {Count} goods.", bot.Name, place.Definition.Name, duration, BotMarketPlanner.CountPricedGoods(bot));
+ }
+ }
+
+ ///
+ /// The shifts which a good spent in a store without a buyer.
+ ///
+ /// The number of shifts.
+ /// When the last shift was counted.
+ private readonly record struct UnsoldGood(int Shifts, DateTime LastCountUtc);
+
+ ///
+ /// A map on which the bots open their stores, with the stalls which are taken there.
+ ///
+ /// The definition of the map.
+ /// The map instance of the game server.
+ /// The center of the spawn gate, around which the stalls are.
+ /// The taken stalls of this map.
+ private sealed record MarketPlace(GameMapDefinition Definition, GameMap Map, Point Arrival, List Stalls)
+ {
+ ///
+ /// Resolves the map; null, if it's unknown or has no spawn gate the bots could arrive at.
+ ///
+ public static async ValueTask ResolveAsync(GameContext gameContext, short mapNumber, IEnumerable shifts)
+ {
+ if (gameContext.Configuration.Maps.FirstOrDefault(m => m.Number == mapNumber) is not { } definition
+ || definition.ExitGates.FirstOrDefault(g => g.IsSpawnGate) is not { } gate
+ || await gameContext.GetMapAsync((ushort)definition.Number).ConfigureAwait(false) is not { } map)
+ {
+ return null;
+ }
+
+ var arrival = new Point((byte)((gate.X1 + gate.X2) / 2), (byte)((gate.Y1 + gate.Y2) / 2));
+ var stalls = shifts.Where(s => s.Map.Number == mapNumber).Select(s => s.Stall).ToList();
+ return new MarketPlace(definition, map, arrival, stalls);
+ }
+ }
+}
diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs
index d9a5c33f61..2257c7dc22 100644
--- a/src/GameLogic/Bots/BotNavigator.cs
+++ b/src/GameLogic/Bots/BotNavigator.cs
@@ -14,6 +14,7 @@ namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions;
+using MUnique.OpenMU.GameLogic.PlayerActions.PlayerStore;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
@@ -183,6 +184,9 @@ internal sealed class BotNavigator : AsyncDisposable
/// The game's own warp action, so a bot travels on exactly a player's terms - fare included.
private static readonly WarpAction WarpAction = new();
+ /// The game's own action to open a personal store, with all its checks of the prices.
+ private static readonly OpenStoreAction MarketStoreOpenAction = new();
+
///
/// Cache of which maps' safezone spawn gates have a walkable tile, so a bot may safely be sent
/// there. Keyed by the resolved safezone map's id, not its number: map numbers are not unique -
@@ -325,6 +329,12 @@ internal sealed class BotNavigator : AsyncDisposable
private int _isEvaluating;
private Point _destination;
private bool _hasDestination;
+
+ ///
+ /// Whether the bot just ended a market shift and still stands on the market, which has no hunting
+ /// ground: it warps home first, like after a shopping trip on a map without a merchant.
+ ///
+ private bool _leavesMarket;
private DateTimeOffset _lastWarpUtc = DateTimeOffset.MinValue;
private DateTimeOffset _nextFollowWarpSearchUtc = DateTimeOffset.MinValue;
private Point _lastPosition;
@@ -1158,6 +1168,41 @@ internal async ValueTask EvaluateFollowerHuntingAsync(GameMap map, Player leader
this._emptyGroundSince = null;
}
+ ///
+ /// Opens the personal store at the stall, as it runs inside the MU Helper tick.
+ ///
+ /// The market shift.
+ /// The task.
+ internal async ValueTask OpenMarketStoreAsync(BotMarketShift shift)
+ {
+ // The plugin may have been switched off since the opening was queued; the next tick ends the shift.
+ if (!ReferenceEquals(this._player.MarketShift, shift)
+ || shift.IsEnding
+ || this._player.GameContext.FeaturePlugIns.GetPlugIn() is null)
+ {
+ return;
+ }
+
+ await MarketStoreOpenAction.OpenStoreAsync(this._player, shift.StoreName).ConfigureAwait(false);
+ if (this._player.ShopStorage?.StoreOpen ?? false)
+ {
+ // What the bot has now is its own - every zen and item which comes in later is income (see
+ // BotMerchandise.RemoveIncomeAsync).
+ shift.MoneyAtOpen = this._player.Money;
+ shift.ItemsAtOpen = this._player.Inventory?.Items.ToHashSet() ?? [];
+ shift.LastSaleUtc = DateTime.UtcNow;
+ shift.IsStoreOpened = true;
+ this._player.Logger.LogDebug("Bot '{Name}' opened its store '{Store}' at {Stall}.", this._player.Name, shift.StoreName, shift.Stall);
+ }
+ else
+ {
+ // E.g. a price in a currency which isn't allowed (anymore). It ends like any other shift, so the
+ // bot waits for its next one instead of coming back right away to fail again.
+ var configuration = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration;
+ this.EndMarketShift(shift, configuration, BotMarketPlanner.EndReason.StoreNotOpened);
+ }
+ }
+
///
protected override async ValueTask DisposeAsyncCore()
{
@@ -1527,6 +1572,13 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken)
return;
}
+ // A bot on a market shift sells the goods of its personal store at its stall (see
+ // BotMerchantPlugIn) - its customers have to find it there, like the players of an event meeting.
+ if (await this.TryMarketShiftAsync(map, 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
@@ -1905,6 +1957,129 @@ private async ValueTask WarpToMeetingAsync(BotEventMeeting meeting, ExitGate gat
private int GetGateWarpCount(BotEventMeeting meeting)
=> ReferenceEquals(this._gateWarpMeeting, meeting) ? this._gateWarpCount : 0;
+ ///
+ /// Drives a market shift (see ): the bot warps to the market
+ /// through its spawn gate - not through the warp list, whose level requirement and costs are for
+ /// the players -, walks to its stall, opens its personal store and stays until the shift ends
+ /// (see ). Opening and closing the store run in the
+ /// MU Helper tick, like the trades (see ).
+ ///
+ /// The current map.
+ /// The token which aborts the travel wait on shutdown.
+ /// True, if the shift consumed this tick.
+ private async ValueTask TryMarketShiftAsync(GameMap map, CancellationToken cancellationToken)
+ {
+ if (this._player.MarketShift is not { } shift)
+ {
+ return await this.TryLeaveMarketAsync().ConfigureAwait(false);
+ }
+
+ // Standing behind the own store is not "stuck", and the hunting ground is free for others meanwhile.
+ this._lastMoveUtc = DateTime.UtcNow;
+ this._player.HuntingSpot = null;
+ if (shift.IsEnding)
+ {
+ return true;
+ }
+
+ var configuration = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration;
+ var endReason = configuration is null
+ ? BotMarketPlanner.EndReason.TimeIsUp
+ : BotMarketPlanner.GetEndReason(this._player, shift, configuration, DateTime.UtcNow);
+ if (endReason != BotMarketPlanner.EndReason.None)
+ {
+ this.EndMarketShift(shift, configuration, endReason);
+ return true;
+ }
+
+ if (map.Definition.Number != shift.Map.Number)
+ {
+ if (shift.Map.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } gate)
+ {
+ this.EndMarketShift(shift, configuration, BotMarketPlanner.EndReason.StoreClosed);
+ return true;
+ }
+
+ this._travelPath = null;
+ this._hasDestination = false;
+ this._lastWarpUtc = this._timeProvider.GetUtcNow();
+ this._player.Logger.LogDebug("Bot '{Name}' warps to {Map} for its market shift.", this._player.Name, shift.Map.Name);
+ await this._player.WarpToAsync(gate).ConfigureAwait(false);
+ return true;
+ }
+
+ if (this._player.GetDistanceTo(shift.Stall) > 1)
+ {
+ if (!await this.TravelTowardAsync(map, shift.Stall, cancellationToken).ConfigureAwait(false))
+ {
+ this._player.Logger.LogDebug("Bot '{Name}' gives up its market shift: no route to its stall at {Stall}.", this._player.Name, shift.Stall);
+ this.EndMarketShift(shift, configuration, BotMarketPlanner.EndReason.StoreClosed);
+ }
+
+ return true;
+ }
+
+ if (!shift.IsStoreOpened && !shift.IsOpenRequested)
+ {
+ shift.IsOpenRequested = true;
+ this._player.PendingBotActions.Enqueue(() => this.OpenMarketStoreAsync(shift));
+ }
+
+ return true;
+ }
+
+ ///
+ /// Ends the market shift: the store gets closed in the next MU Helper tick, the shift is removed
+ /// afterward, and the bot may start its next one after the configured time.
+ ///
+ private void EndMarketShift(BotMarketShift shift, BotMerchantConfiguration? configuration, BotMarketPlanner.EndReason reason)
+ {
+ shift.IsEnding = true;
+ this._player.NextMarketShiftUtc = DateTime.UtcNow + (configuration?.TimeBetweenShifts ?? TimeSpan.Zero);
+ this._player.Logger.LogDebug("Bot '{Name}' ends its market shift: {Reason}.", this._player.Name, reason);
+ this._player.PendingBotActions.Enqueue(() => this.CloseMarketStoreAsync(shift));
+ }
+
+ ///
+ /// Closes the personal store, removes the last income, retires the goods which found no buyer for
+ /// too long and removes the shift, as it runs inside the MU Helper tick.
+ ///
+ private async ValueTask CloseMarketStoreAsync(BotMarketShift shift)
+ {
+ await BotMerchandise.CloseShiftStoreAsync(this._player, shift).ConfigureAwait(false);
+ if (ReferenceEquals(this._player.MarketShift, shift))
+ {
+ this._player.MarketShift = null;
+ this._leavesMarket = this._player.CurrentMap?.Definition.Number == shift.Map.Number;
+ }
+ }
+
+ ///
+ /// Takes the bot home after its market shift: the market has no hunting ground, so the routine
+ /// wouldn't find a way back to the monsters from there.
+ ///
+ /// True, if the warp consumed this tick.
+ private async ValueTask TryLeaveMarketAsync()
+ {
+ if (!this._leavesMarket)
+ {
+ return false;
+ }
+
+ this._leavesMarket = false;
+ if (!this.TryGetHomeEscapeGate(out var homeGate, out var homeMap, out _))
+ {
+ return false;
+ }
+
+ this._travelPath = null;
+ this._hasDestination = false;
+ this._lastWarpUtc = this._timeProvider.GetUtcNow();
+ this._player.Logger.LogDebug("Bot '{Name}' warps home to {Map} after its market shift.", this._player.Name, homeMap.Name);
+ await this._player.WarpToAsync(homeGate).ConfigureAwait(false);
+ return true;
+ }
+
///
/// Ends the event meeting, unless a newer one replaced it in the meantime.
///
diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs
index fea60b9454..5e2fc34782 100644
--- a/src/GameLogic/Bots/BotPartyHandler.cs
+++ b/src/GameLogic/Bots/BotPartyHandler.cs
@@ -52,9 +52,9 @@ internal static async ValueTask TryScheduleAcceptAsync(Player receiver, Pl
return false;
}
- if (bot.IsOnShoppingTrip || bot.IsOnBuffTrip || bot.IsOnChaosMachineTrip || bot.HasRevengeIntent || bot.CurrentMiniGame is not null)
+ if (bot.IsOnShoppingTrip || bot.IsOnBuffTrip || bot.IsOnChaosMachineTrip || bot.MarketShift is not null || bot.HasRevengeIntent || bot.CurrentMiniGame is not null)
{
- // Busy - a player in the middle of an errand, a grudge or an event would not group up either.
+ // Busy - a player in the middle of an errand, a grudge, an event or behind the own store would not group up either.
return false;
}
diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs
index 4430727a21..b25945b1a7 100644
--- a/src/GameLogic/Bots/BotPlayer.cs
+++ b/src/GameLogic/Bots/BotPlayer.cs
@@ -60,6 +60,13 @@ public BotPlayer(IGameContext gameContext)
///
public bool AwaitsFaultRestart { get; set; }
+ ///
+ ///
+ /// A bot opens its store only on a market shift (see ): a store which was
+ /// open when it logged out must stay closed after the next login, wherever the bot enters the game.
+ ///
+ protected override bool IsPlayerStoreOpeningAfterEnterSupported => false;
+
///
public override async ValueTask StopAsync()
{
@@ -102,9 +109,23 @@ protected override void StartIntelligence()
}
///
+ ///
+ /// A bot on a market shift settles it before the base class saves it on the way out (see
+ /// ).
+ ///
protected override async ValueTask InternalDisconnectAsync()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
+ try
+ {
+ // The MU Helper tick keeps running until the base class stops it; it holds the same lock.
+ await this.RunPersistenceExclusiveAsync(() => BotMerchandise.EndShiftOnLogoutAsync(this)).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "Bot '{Name}' couldn't end its market shift on logout.", this.Name);
+ }
+
await base.InternalDisconnectAsync().ConfigureAwait(false);
}
diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs
index f2edb9292c..ae95e15914 100644
--- a/src/GameLogic/Bots/BotShoppingHandler.cs
+++ b/src/GameLogic/Bots/BotShoppingHandler.cs
@@ -145,6 +145,11 @@ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap
return false;
}
+ // The goods worth a stall on the market go into the own store instead of to the NPC (see
+ // BotMerchantPlugIn) - before the dialog opens, because the game moves nothing into a store
+ // during an NPC dialog. Nothing happens while the plugin is off.
+ var stocked = await BotMerchandise.StockStoreAsync(player, GetSellableJunk(player, inventory)).ConfigureAwait(false);
+
await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false);
if (player.OpenedNpc is null)
{
@@ -175,9 +180,10 @@ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap
// "went and had nothing to do" apart from a silently failed trip. Every counter reports
// what REALLY happened - a sale which the money limit refused is not a sale.
player.Logger.LogDebug(
- "Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), discarded {Discarded}, repaired {Repaired}, bought {Potions} potion stack(s) and {Jewels} jewel(s), {Money} zen left.",
+ "Bot '{Name}' traded with '{Merchant}': stocked {Stocked} item(s) for the market, sold {Sold} item(s), discarded {Discarded}, repaired {Repaired}, bought {Potions} potion stack(s) and {Jewels} jewel(s), {Money} zen left.",
player.Name,
merchant.Definition.Designation,
+ stocked,
sold,
discarded,
repaired,
diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs
index d95580210e..fec895cb0f 100644
--- a/src/GameLogic/Offline/OfflinePlayer.cs
+++ b/src/GameLogic/Offline/OfflinePlayer.cs
@@ -174,6 +174,18 @@ internal Player? RecentAggressor
///
internal Bots.BotEventMeeting? EventMeeting { get; set; }
+ ///
+ /// Gets or sets the shift on the market during which the bot sells the goods of its personal store,
+ /// set by and carried out by .
+ /// Like an errand, it makes the bot decline party invitations meanwhile.
+ ///
+ internal Bots.BotMarketShift? MarketShift { get; set; }
+
+ ///
+ /// Gets or sets the earliest time at which the bot may start its next market shift.
+ ///
+ internal DateTime NextMarketShiftUtc { get; set; }
+
///
/// Gets a value indicating whether a revenge against a player killer is pending or armed - the
/// bot has unfinished business and is in no mood to group up.
diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs
index 708b3252fd..c78d4f2f67 100644
--- a/src/GameLogic/Properties/PlugInResources.Designer.cs
+++ b/src/GameLogic/Properties/PlugInResources.Designer.cs
@@ -979,7 +979,7 @@ public static string BotChatConfiguration_MaxPostDelay_Name {
}
///
- /// Looks up a localized string similar to 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}..
+ /// Looks up a localized string similar to Said now and then in the normal chat by a bot behind its open store on the market. Placeholders: {level}, {map}, {location}, {store} (the name of its store)..
///
public static string BotChatConfiguration_MerchantLines_Description {
get {
@@ -997,7 +997,7 @@ public static string BotChatConfiguration_MerchantLines_Name {
}
///
- /// Looks up a localized string similar to Chance that a bot advertises when the interval is over. 0 (default) = never: the lines are meant for a future merchant bot..
+ /// Looks up a localized string similar to Chance that a bot advertises when the interval is over. Only bots behind their open store on the market advertise, so nothing happens while the Bot merchants plugin is off..
///
public static string BotChatConfiguration_MerchantTalkChancePercent_Description {
get {
@@ -1015,7 +1015,7 @@ public static string BotChatConfiguration_MerchantTalkChancePercent_Name {
}
///
- /// Looks up a localized string similar to How often a bot standing in a safezone may advertise like a merchant, per game server. Only when a player sees it..
+ /// Looks up a localized string similar to How often a bot selling its goods on the market (Bot merchants plugin) may advertise them, per game server. Only when a player sees it..
///
public static string BotChatConfiguration_MerchantTalkInterval_Description {
get {
@@ -1032,6 +1032,24 @@ public static string BotChatConfiguration_MerchantTalkInterval_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Answers of a bot behind its open store on the market to a whisper, or a mention of its name in the normal chat next to it, which asks what it sells ("what are you selling?"): they point to the store. Only a reply whose keyword matches is used, before the whisper replies; any other message gets a usual answer. Placeholders as in the whisper replies, and {store} (the name of its store)..
+ ///
+ public static string BotChatConfiguration_MerchantWhisperReplies_Description {
+ get {
+ return ResourceManager.GetString("BotChatConfiguration_MerchantWhisperReplies_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Merchant whisper replies.
+ ///
+ public static string BotChatConfiguration_MerchantWhisperReplies_Name {
+ get {
+ return ResourceManager.GetString("BotChatConfiguration_MerchantWhisperReplies_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to 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..
///
@@ -2063,52 +2081,880 @@ public static string BotConversation_Reply_Name {
///
public static string BotConversation_Riposte_Description {
get {
- return ResourceManager.GetString("BotConversation_Riposte_Description", resourceCulture);
+ return ResourceManager.GetString("BotConversation_Riposte_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Riposte.
+ ///
+ public static string BotConversation_Riposte_Name {
+ get {
+ return ResourceManager.GetString("BotConversation_Riposte_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Where the bots may have this conversation: Town (both in a safezone), Hunting (both outside of it) or Anywhere..
+ ///
+ public static string BotConversation_Where_Description {
+ get {
+ return ResourceManager.GetString("BotConversation_Where_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Where.
+ ///
+ public static string BotConversation_Where_Name {
+ get {
+ return ResourceManager.GetString("BotConversation_Where_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature..
+ ///
+ public static string BotFeaturePlugIn_Description {
+ get {
+ return ResourceManager.GetString("BotFeaturePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Bots.
+ ///
+ public static string BotFeaturePlugIn_Name {
+ get {
+ return ResourceManager.GetString("BotFeaturePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of an ancient item with luck..
+ ///
+ public static string BotMerchantConfiguration_AncientLuckSurcharge_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_AncientLuckSurcharge_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: ancient with luck.
+ ///
+ public static string BotMerchantConfiguration_AncientLuckSurcharge_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_AncientLuckSurcharge_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency for which a bot sells its surplus of the Jewel of Bless: zen or another jewel (bless, soul, chaos, life, creation)..
+ ///
+ public static string BotMerchantConfiguration_BlessCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_BlessCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Bless: currency.
+ ///
+ public static string BotMerchantConfiguration_BlessCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_BlessCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The price of one Jewel of Bless, in its currency..
+ ///
+ public static string BotMerchantConfiguration_BlessPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_BlessPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Bless: price.
+ ///
+ public static string BotMerchantConfiguration_BlessPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_BlessPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency for which a bot sells its surplus of the Jewel of Chaos: zen or another jewel (bless, soul, chaos, life, creation)..
+ ///
+ public static string BotMerchantConfiguration_ChaosCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ChaosCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Chaos: currency.
+ ///
+ public static string BotMerchantConfiguration_ChaosCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ChaosCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The price of one Jewel of Chaos, in its currency..
+ ///
+ public static string BotMerchantConfiguration_ChaosPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ChaosPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Chaos: price.
+ ///
+ public static string BotMerchantConfiguration_ChaosPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ChaosPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency of the cheap goods (normal items, excellent items with only a cheap option): zen, bless, soul, chaos, life or creation..
+ ///
+ public static string BotMerchantConfiguration_CheapCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cheap: currency.
+ ///
+ public static string BotMerchantConfiguration_CheapCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The base price of the cheap goods..
+ ///
+ public static string BotMerchantConfiguration_CheapPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cheap: price.
+ ///
+ public static string BotMerchantConfiguration_CheapPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the cheap goods per item level above +7..
+ ///
+ public static string BotMerchantConfiguration_CheapPricePerLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPricePerLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cheap: per level above +7.
+ ///
+ public static string BotMerchantConfiguration_CheapPricePerLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPricePerLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the cheap goods per +4 of the additional option. Fractions are summed up and rounded down..
+ ///
+ public static string BotMerchantConfiguration_CheapPricePerOption_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPricePerOption_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cheap: per +4 option.
+ ///
+ public static string BotMerchantConfiguration_CheapPricePerOption_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CheapPricePerOption_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The numbers of the city maps, separated by commas (0 = Lorencia, 2 = Devias, 3 = Noria, 51 = Elvenland). The bots open their store in the safezone near the spawn gate..
+ ///
+ public static string BotMerchantConfiguration_CityMapNumbers_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CityMapNumbers_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to City map numbers.
+ ///
+ public static string BotMerchantConfiguration_CityMapNumbers_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CityMapNumbers_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The share of the shifts which a bot spends in one of the city maps instead of on the market. 0 keeps all merchants on the market..
+ ///
+ public static string BotMerchantConfiguration_CityMerchantsPercent_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CityMerchantsPercent_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to City merchants (%).
+ ///
+ public static string BotMerchantConfiguration_CityMerchantsPercent_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CityMerchantsPercent_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency for which a bot sells its surplus of the Jewel of Creation: zen or another jewel (bless, soul, chaos, life, creation)..
+ ///
+ public static string BotMerchantConfiguration_CreationCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CreationCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Creation: currency.
+ ///
+ public static string BotMerchantConfiguration_CreationCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CreationCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The price of one Jewel of Creation, in its currency..
+ ///
+ public static string BotMerchantConfiguration_CreationPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CreationPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Creation: price.
+ ///
+ public static string BotMerchantConfiguration_CreationPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_CreationPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency of the expensive goods (ancient items, excellent items with two or more options or one wanted option and luck): zen, bless, soul, chaos, life or creation..
+ ///
+ public static string BotMerchantConfiguration_ExpensiveCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: currency.
+ ///
+ public static string BotMerchantConfiguration_ExpensiveCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The base price of an excellent item with one wanted option and luck..
+ ///
+ public static string BotMerchantConfiguration_ExpensiveOneOptionPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveOneOptionPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: one wanted option and luck.
+ ///
+ public static string BotMerchantConfiguration_ExpensiveOneOptionPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveOneOptionPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The base price of an excellent item with two options and of an ancient item..
+ ///
+ public static string BotMerchantConfiguration_ExpensivePrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: two options or ancient.
+ ///
+ public static string BotMerchantConfiguration_ExpensivePrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the expensive goods per item level above +7..
+ ///
+ public static string BotMerchantConfiguration_ExpensivePricePerLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePricePerLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: per level above +7.
+ ///
+ public static string BotMerchantConfiguration_ExpensivePricePerLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePricePerLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the expensive goods per +4 of the additional option. Fractions are summed up and rounded down, so 0.5 means one more per +8..
+ ///
+ public static string BotMerchantConfiguration_ExpensivePricePerOption_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePricePerOption_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: per +4 option.
+ ///
+ public static string BotMerchantConfiguration_ExpensivePricePerOption_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensivePricePerOption_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The base price of an excellent item with three or more options..
+ ///
+ public static string BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: three or more options.
+ ///
+ public static string BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_ExpensiveThreeOptionsPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The drop level from which an item counts as one of the higher sets. Normal items need it to be stocked; above it, ancient and the best excellent items cost more..
+ ///
+ public static string BotMerchantConfiguration_HighSetMinimumDropLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetMinimumDropLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to High set minimum drop level.
+ ///
+ public static string BotMerchantConfiguration_HighSetMinimumDropLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetMinimumDropLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of an ancient item, and of an excellent item with a wanted option, luck and an additional option, for every step of drop levels above the high set minimum drop level..
+ ///
+ public static string BotMerchantConfiguration_HighSetSurcharge_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetSurcharge_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: higher set surcharge.
+ ///
+ public static string BotMerchantConfiguration_HighSetSurcharge_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetSurcharge_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How many drop levels make one step of the higher set surcharge..
+ ///
+ public static string BotMerchantConfiguration_HighSetSurchargeDropLevels_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetSurchargeDropLevels_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Expensive: drop levels per surcharge.
+ ///
+ public static string BotMerchantConfiguration_HighSetSurchargeDropLevels_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_HighSetSurchargeDropLevels_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to After this time without a sale, the bot leaves the market before its shift is over and makes room for another one. Zero switches it off..
+ ///
+ public static string BotMerchantConfiguration_LeaveWithoutSalesAfter_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LeaveWithoutSalesAfter_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Leave without sales after.
+ ///
+ public static string BotMerchantConfiguration_LeaveWithoutSalesAfter_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LeaveWithoutSalesAfter_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency for which a bot sells its surplus of the Jewel of Life: zen or another jewel (bless, soul, chaos, life, creation)..
+ ///
+ public static string BotMerchantConfiguration_LifeCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LifeCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Life: currency.
+ ///
+ public static string BotMerchantConfiguration_LifeCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LifeCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The price of one Jewel of Life, in its currency..
+ ///
+ public static string BotMerchantConfiguration_LifePrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LifePrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Life: price.
+ ///
+ public static string BotMerchantConfiguration_LifePrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_LifePrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The number of the map on which the bots open their stores (79 = Loren Market). The bots get there through its spawn gate, regardless of the warp list..
+ ///
+ public static string BotMerchantConfiguration_MarketMapNumber_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MarketMapNumber_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Market map number.
+ ///
+ public static string BotMerchantConfiguration_MarketMapNumber_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MarketMapNumber_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How many jewels of one kind a bot puts into its store at most. 0 keeps the jewels out of the stores..
+ ///
+ public static string BotMerchantConfiguration_MaximumJewelsPerKind_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumJewelsPerKind_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewels: maximum per kind.
+ ///
+ public static string BotMerchantConfiguration_MaximumJewelsPerKind_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumJewelsPerKind_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The maximum number of bots with an open store at the same time, on the market and in the cities together, per game server. Only bots with enough goods go, so it's a ceiling - and they go back hunting after their shift..
+ ///
+ public static string BotMerchantConfiguration_MaximumMerchants_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumMerchants_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Maximum merchants.
+ ///
+ public static string BotMerchantConfiguration_MaximumMerchants_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumMerchants_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The longest stay on the market..
+ ///
+ public static string BotMerchantConfiguration_MaximumShiftDuration_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumShiftDuration_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Maximum shift duration.
+ ///
+ public static string BotMerchantConfiguration_MaximumShiftDuration_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MaximumShiftDuration_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency of the medium goods (excellent items with one wanted option, without luck): zen, bless, soul, chaos, life or creation..
+ ///
+ public static string BotMerchantConfiguration_MediumCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Medium: currency.
+ ///
+ public static string BotMerchantConfiguration_MediumCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The base price of the medium goods..
+ ///
+ public static string BotMerchantConfiguration_MediumPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Medium: price.
+ ///
+ public static string BotMerchantConfiguration_MediumPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the medium goods per item level above +7..
+ ///
+ public static string BotMerchantConfiguration_MediumPricePerLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPricePerLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Medium: per level above +7.
+ ///
+ public static string BotMerchantConfiguration_MediumPricePerLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPricePerLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The surcharge of the medium goods per +4 of the additional option. Fractions are summed up and rounded down, so 0.5 means one more per +8..
+ ///
+ public static string BotMerchantConfiguration_MediumPricePerOption_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPricePerOption_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Medium: per +4 option.
+ ///
+ public static string BotMerchantConfiguration_MediumPricePerOption_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MediumPricePerOption_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The minimum number of priced goods in the personal store of a bot to go to the market..
+ ///
+ public static string BotMerchantConfiguration_MinimumGoodsToStart_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumGoodsToStart_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Minimum goods to start.
+ ///
+ public static string BotMerchantConfiguration_MinimumGoodsToStart_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumGoodsToStart_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to When fewer goods are left, the bot closes its store and leaves the market early. 1 (default) = it stays as long as it has anything to sell..
+ ///
+ public static string BotMerchantConfiguration_MinimumGoodsToStay_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumGoodsToStay_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Minimum goods to stay.
+ ///
+ public static string BotMerchantConfiguration_MinimumGoodsToStay_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumGoodsToStay_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The minimum level of a bot to go to the market. The game allows a personal store from level 6..
+ ///
+ public static string BotMerchantConfiguration_MinimumLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Minimum level.
+ ///
+ public static string BotMerchantConfiguration_MinimumLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The minimum level (+) of a normal item of a higher set with luck to be stocked..
+ ///
+ public static string BotMerchantConfiguration_MinimumNormalItemLevel_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumNormalItemLevel_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Minimum normal item level.
+ ///
+ public static string BotMerchantConfiguration_MinimumNormalItemLevel_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumNormalItemLevel_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The shortest stay on the market. The actual length is random between the minimum and the maximum..
+ ///
+ public static string BotMerchantConfiguration_MinimumShiftDuration_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumShiftDuration_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Minimum shift duration.
+ ///
+ public static string BotMerchantConfiguration_MinimumShiftDuration_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_MinimumShiftDuration_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How many bots at most start a shift every 30 seconds. After a server start, the market fills up within minutes instead of all at once, and the parties of the bots don't fall apart together..
+ ///
+ public static string BotMerchantConfiguration_NewMerchantsPerCheck_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_NewMerchantsPerCheck_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to New merchants per 30 seconds.
+ ///
+ public static string BotMerchantConfiguration_NewMerchantsPerCheck_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_NewMerchantsPerCheck_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How far, up or down, a zen price may differ from the calculated one. It's drawn once, when the good goes into the store, and rounded to an amount a player would ask for, e.g. 800,000 or 5.5 million. Prices in jewels stay exact; 0 keeps all prices exact..
+ ///
+ public static string BotMerchantConfiguration_PriceSpreadPercent_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_PriceSpreadPercent_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Zen price spread (%).
+ ///
+ public static string BotMerchantConfiguration_PriceSpreadPercent_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_PriceSpreadPercent_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The currency for which a bot sells its surplus of the Jewel of Soul: zen or another jewel (bless, soul, chaos, life, creation)..
+ ///
+ public static string BotMerchantConfiguration_SoulCurrency_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_SoulCurrency_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Soul: currency.
+ ///
+ public static string BotMerchantConfiguration_SoulCurrency_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_SoulCurrency_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The price of one Jewel of Soul, in its currency..
+ ///
+ public static string BotMerchantConfiguration_SoulPrice_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_SoulPrice_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Jewel of Soul: price.
+ ///
+ public static string BotMerchantConfiguration_SoulPrice_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_SoulPrice_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The names of the stores, separated by commas. Every shift draws one which no other open store has; when all are taken, the store gets the name of the bot..
+ ///
+ public static string BotMerchantConfiguration_StoreNames_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_StoreNames_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Store names.
+ ///
+ public static string BotMerchantConfiguration_StoreNames_Name {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_StoreNames_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The time after a shift before the same bot goes to the market again..
+ ///
+ public static string BotMerchantConfiguration_TimeBetweenShifts_Description {
+ get {
+ return ResourceManager.GetString("BotMerchantConfiguration_TimeBetweenShifts_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Riposte.
+ /// Looks up a localized string similar to Time between shifts.
///
- public static string BotConversation_Riposte_Name {
+ public static string BotMerchantConfiguration_TimeBetweenShifts_Name {
get {
- return ResourceManager.GetString("BotConversation_Riposte_Name", resourceCulture);
+ return ResourceManager.GetString("BotMerchantConfiguration_TimeBetweenShifts_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Where the bots may have this conversation: Town (both in a safezone), Hunting (both outside of it) or Anywhere..
+ /// Looks up a localized string similar to After this many market shifts without a buyer, a good leaves the store and is sold to an NPC. There are no discounts..
///
- public static string BotConversation_Where_Description {
+ public static string BotMerchantConfiguration_UnsoldShiftsUntilNpc_Description {
get {
- return ResourceManager.GetString("BotConversation_Where_Description", resourceCulture);
+ return ResourceManager.GetString("BotMerchantConfiguration_UnsoldShiftsUntilNpc_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Where.
+ /// Looks up a localized string similar to Unsold shifts until NPC.
///
- public static string BotConversation_Where_Name {
+ public static string BotMerchantConfiguration_UnsoldShiftsUntilNpc_Name {
get {
- return ResourceManager.GetString("BotConversation_Where_Name", resourceCulture);
+ return ResourceManager.GetString("BotMerchantConfiguration_UnsoldShiftsUntilNpc_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature..
+ /// Looks up a localized string similar to Lets server-side bots sell the goods of their personal store on the market map (Loren Market) for a while, then go back hunting..
///
- public static string BotFeaturePlugIn_Description {
+ public static string BotMerchantPlugIn_Description {
get {
- return ResourceManager.GetString("BotFeaturePlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("BotMerchantPlugIn_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Bots.
+ /// Looks up a localized string similar to Bot merchants.
///
- public static string BotFeaturePlugIn_Name {
+ public static string BotMerchantPlugIn_Name {
get {
- return ResourceManager.GetString("BotFeaturePlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("BotMerchantPlugIn_Name", resourceCulture);
}
}
@@ -3390,6 +4236,24 @@ public static string GetStatChatCommandPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Applies the effect of the Gladiator's Honor, which can only be used in Vulcanus..
+ ///
+ public static string GladiatorsHonorConsumeHandlerPlugIn_Description {
+ get {
+ return ResourceManager.GetString("GladiatorsHonorConsumeHandlerPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Gladiator's Honor consume handler.
+ ///
+ public static string GladiatorsHonorConsumeHandlerPlugIn_Name {
+ get {
+ return ResourceManager.GetString("GladiatorsHonorConsumeHandlerPlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Gives players Goblin Points of the cash shop for their play time..
///
@@ -4335,6 +5199,24 @@ public static string LowerRefineStoneConsumeHandlerPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Players can register lucky coins at the NPC Delgado, or exchange 10, 20 or 30 of them for an item. The rewards of the exchange are configured here..
+ ///
+ public static string LuckyCoinFeaturePlugIn_Description {
+ get {
+ return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Lucky coin event.
+ ///
+ public static string LuckyCoinFeaturePlugIn_Name {
+ get {
+ return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Limits the maximum number of parallel connections from the same IP address..
///
@@ -7117,6 +7999,42 @@ public static string SymbolOfKundunStackedPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Respawns a dead player at the place of its death, when it carries a Talisman of Resurrection, which is used up. It doesn't apply in mini games, duels and guild wars..
+ ///
+ public static string TalismanOfResurrectionSpawnGatePlugIn_Description {
+ get {
+ return ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Talisman of Resurrection.
+ ///
+ public static string TalismanOfResurrectionSpawnGatePlugIn_Name {
+ get {
+ return ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Item group.
+ ///
+ public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption {
+ get {
+ return ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Item number.
+ ///
+ public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption {
+ get {
+ return ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Default (catch-all) handler for targeted skills.
///
@@ -7604,119 +8522,92 @@ public static string VitalityResetFruitConsumeHandlerPlugIn_Name {
}
///
- /// Looks up a localized string similar to Handles the chat command '/walkmonster <id> <x> <y>'. Walks a previously created monster which can be remote controlled by the GM..
- ///
- public static string WalkMonsterChatCommand_Description {
- get {
- return ResourceManager.GetString("WalkMonsterChatCommand_Description", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Walk remote monster chat command.
- ///
- public static string WalkMonsterChatCommand_Name {
- get {
- return ResourceManager.GetString("WalkMonsterChatCommand_Name", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Maximum spawn duration.
- ///
- public static string WanderingMerchantsConfiguration_MaximumSpawnDuration_Name {
- get {
- return ResourceManager.GetString("WanderingMerchantsConfiguration_MaximumSpawnDuration_Name", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Minimum spawn duration.
+ /// Looks up a localized string similar to Increases the experience of players in Vulcanus by their Vulcanus experience rate, e.g. of the Gladiator's Honor..
///
- public static string WanderingMerchantsConfiguration_MinimumSpawnDuration_Name {
+ public static string VulcanusExperiencePlugIn_Description {
get {
- return ResourceManager.GetString("WanderingMerchantsConfiguration_MinimumSpawnDuration_Name", resourceCulture);
+ return ResourceManager.GetString("VulcanusExperiencePlugIn_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to This plugins spawns and moves the wandering merchants..
+ /// Looks up a localized string similar to Vulcanus experience bonus.
///
- public static string WanderingMerchantsPlugIn_Description {
+ public static string VulcanusExperiencePlugIn_Name {
get {
- return ResourceManager.GetString("WanderingMerchantsPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("VulcanusExperiencePlugIn_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Wandering Merchants.
+ /// Looks up a localized string similar to Map number.
///
- public static string WanderingMerchantsPlugIn_Name {
+ public static string VulcanusMapConfiguration_MapNumber_Name {
get {
- return ResourceManager.GetString("WanderingMerchantsPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("VulcanusMapConfiguration_MapNumber_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Updates the state of the weather of each hosted map in a random way..
+ /// Looks up a localized string similar to Handles the chat command '/waiting'. Lists the items which wait for space in the inventory..
///
- public static string WeatherUpdatePlugIn_Description {
+ public static string WaitingItemsChatCommandPlugIn_Description {
get {
- return ResourceManager.GetString("WeatherUpdatePlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsChatCommandPlugIn_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Weather Update.
+ /// Looks up a localized string similar to Waiting items chat command.
///
- public static string WeatherUpdatePlugIn_Name {
+ public static string WaitingItemsChatCommandPlugIn_Name {
get {
- return ResourceManager.GetString("WeatherUpdatePlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsChatCommandPlugIn_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Enables the White Wizard Invasion feature..
+ /// Looks up a localized string similar to The number of waiting items which are named, one per message..
///
- public static string WhiteWizardInvasionPlugIn_Description {
+ public static string WaitingItemsCommandConfiguration_ItemsNamed_Description {
get {
- return ResourceManager.GetString("WhiteWizardInvasionPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsCommandConfiguration_ItemsNamed_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to White Wizard Invasion.
+ /// Looks up a localized string similar to Named items.
///
- public static string WhiteWizardInvasionPlugIn_Name {
+ public static string WaitingItemsCommandConfiguration_ItemsNamed_Name {
get {
- return ResourceManager.GetString("WhiteWizardInvasionPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsCommandConfiguration_ItemsNamed_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Sends a message to the world chat, which all players of all game servers can read, e.g. '/world Hello!'. It can be bridged to Discord..
+ /// Looks up a localized string similar to Puts the items which wait for space in the inventory of a character into it as soon as they fit there: when the character enters the game or opens the vault, and then periodically. When it's deactivated, the items keep waiting..
///
- public static string WorldChatChatCommandPlugIn_Description {
+ public static string WaitingItemsDeliveryPlugIn_Description {
get {
- return ResourceManager.GetString("WorldChatChatCommandPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsDeliveryPlugIn_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Handles the chat command '/waiting'. Lists the items which wait for space in the inventory..
+ /// Looks up a localized string similar to Waiting items delivery.
///
- public static string WaitingItemsChatCommandPlugIn_Description {
+ public static string WaitingItemsDeliveryPlugIn_Name {
get {
- return ResourceManager.GetString("WaitingItemsChatCommandPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsDeliveryPlugIn_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Waiting items chat command.
+ /// Looks up a localized string similar to How often the waiting items are put into the inventories, as far as they fit there..
///
- public static string WaitingItemsChatCommandPlugIn_Name {
+ public static string WaitingItemsDeliveryPlugInConfiguration_CheckInterval_Description {
get {
- return ResourceManager.GetString("WaitingItemsChatCommandPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsDeliveryPlugInConfiguration_CheckInterval_Description", resourceCulture);
}
}
@@ -7730,11 +8621,11 @@ public static string WaitingItemsDeliveryPlugInConfiguration_CheckInterval_Name
}
///
- /// Looks up a localized string similar to How often the waiting items are put into the inventories, as far as they fit there..
+ /// Looks up a localized string similar to The number of waiting items which are named, one per message, when the player is told about them when entering the game or opening the vault..
///
- public static string WaitingItemsDeliveryPlugInConfiguration_CheckInterval_Description {
+ public static string WaitingItemsDeliveryPlugInConfiguration_ItemsNamed_Description {
get {
- return ResourceManager.GetString("WaitingItemsDeliveryPlugInConfiguration_CheckInterval_Description", resourceCulture);
+ return ResourceManager.GetString("WaitingItemsDeliveryPlugInConfiguration_ItemsNamed_Description", resourceCulture);
}
}
@@ -7748,129 +8639,110 @@ public static string WaitingItemsDeliveryPlugInConfiguration_ItemsNamed_Name {
}
///
- /// Looks up a localized string similar to The number of waiting items which are named, one per message, when the player is told about them when entering the game or opening the vault..
+ /// Looks up a localized string similar to Handles the chat command '/walkmonster <id> <x> <y>'. Walks a previously created monster which can be remote controlled by the GM..
///
- public static string WaitingItemsDeliveryPlugInConfiguration_ItemsNamed_Description {
+ public static string WalkMonsterChatCommand_Description {
get {
- return ResourceManager.GetString("WaitingItemsDeliveryPlugInConfiguration_ItemsNamed_Description", resourceCulture);
+ return ResourceManager.GetString("WalkMonsterChatCommand_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Named items.
+ /// Looks up a localized string similar to Walk remote monster chat command.
///
- public static string WaitingItemsCommandConfiguration_ItemsNamed_Name {
+ public static string WalkMonsterChatCommand_Name {
get {
- return ResourceManager.GetString("WaitingItemsCommandConfiguration_ItemsNamed_Name", resourceCulture);
+ return ResourceManager.GetString("WalkMonsterChatCommand_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to The number of waiting items which are named, one per message..
+ /// Looks up a localized string similar to Maximum spawn duration.
///
- public static string WaitingItemsCommandConfiguration_ItemsNamed_Description {
+ public static string WanderingMerchantsConfiguration_MaximumSpawnDuration_Name {
get {
- return ResourceManager.GetString("WaitingItemsCommandConfiguration_ItemsNamed_Description", resourceCulture);
+ return ResourceManager.GetString("WanderingMerchantsConfiguration_MaximumSpawnDuration_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Puts the items which wait for space in the inventory of a character into it as soon as they fit there: when the character enters the game or opens the vault, and then periodically. When it's deactivated, the items keep waiting..
+ /// Looks up a localized string similar to Minimum spawn duration.
///
- public static string WaitingItemsDeliveryPlugIn_Description {
+ public static string WanderingMerchantsConfiguration_MinimumSpawnDuration_Name {
get {
- return ResourceManager.GetString("WaitingItemsDeliveryPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WanderingMerchantsConfiguration_MinimumSpawnDuration_Name", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Waiting items delivery.
+ /// Looks up a localized string similar to This plugins spawns and moves the wandering merchants..
///
- public static string WaitingItemsDeliveryPlugIn_Name {
+ public static string WanderingMerchantsPlugIn_Description {
get {
- return ResourceManager.GetString("WaitingItemsDeliveryPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WanderingMerchantsPlugIn_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to World chat command.
+ /// Looks up a localized string similar to Wandering Merchants.
///
- public static string WorldChatChatCommandPlugIn_Name {
+ public static string WanderingMerchantsPlugIn_Name {
get {
- return ResourceManager.GetString("WorldChatChatCommandPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WanderingMerchantsPlugIn_Name", resourceCulture);
}
}
-
- /// Gets the localized TalismanOfResurrectionSpawnGatePlugIn_Name text.
- public static string TalismanOfResurrectionSpawnGatePlugIn_Name => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Name", resourceCulture)!;
-
- /// Gets the localized TalismanOfResurrectionSpawnGatePlugIn_Description text.
- public static string TalismanOfResurrectionSpawnGatePlugIn_Description => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Description", resourceCulture)!;
-
- /// Gets the localized TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption text.
- public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption", resourceCulture)!;
-
- /// Gets the localized TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption text.
- public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption", resourceCulture)!;
///
- /// Looks up a localized string similar to Lucky coin event.
- ///
- public static string LuckyCoinFeaturePlugIn_Name {
- get {
- return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Name", resourceCulture);
- }
- }
-
- /// Looks up a localized string similar to Gladiator's Honor consume handler.
+ /// Looks up a localized string similar to Updates the state of the weather of each hosted map in a random way..
///
- public static string GladiatorsHonorConsumeHandlerPlugIn_Name {
+ public static string WeatherUpdatePlugIn_Description {
get {
- return ResourceManager.GetString("GladiatorsHonorConsumeHandlerPlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WeatherUpdatePlugIn_Description", resourceCulture);
}
}
-
+
///
- /// Looks up a localized string similar to Players can register lucky coins at the NPC Delgado, or exchange 10, 20 or 30 of them for an item. The rewards of the exchange are configured here..
+ /// Looks up a localized string similar to Weather Update.
///
- public static string LuckyCoinFeaturePlugIn_Description {
+ public static string WeatherUpdatePlugIn_Name {
get {
- return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WeatherUpdatePlugIn_Name", resourceCulture);
}
}
- /// Looks up a localized string similar to Applies the effect of the Gladiator's Honor, which can only be used in Vulcanus..
+ ///
+ /// Looks up a localized string similar to Enables the White Wizard Invasion feature..
///
- public static string GladiatorsHonorConsumeHandlerPlugIn_Description {
+ public static string WhiteWizardInvasionPlugIn_Description {
get {
- return ResourceManager.GetString("GladiatorsHonorConsumeHandlerPlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WhiteWizardInvasionPlugIn_Description", resourceCulture);
}
}
-
+
///
- /// Looks up a localized string similar to Vulcanus experience bonus.
+ /// Looks up a localized string similar to White Wizard Invasion.
///
- public static string VulcanusExperiencePlugIn_Name {
+ public static string WhiteWizardInvasionPlugIn_Name {
get {
- return ResourceManager.GetString("VulcanusExperiencePlugIn_Name", resourceCulture);
+ return ResourceManager.GetString("WhiteWizardInvasionPlugIn_Name", resourceCulture);
}
}
-
+
///
- /// Looks up a localized string similar to Increases the experience of players in Vulcanus by their Vulcanus experience rate, e.g. of the Gladiator's Honor..
+ /// Looks up a localized string similar to Sends a message to the world chat, which all players of all game servers can read, e.g. '/world Hello!'. It can be bridged to Discord..
///
- public static string VulcanusExperiencePlugIn_Description {
+ public static string WorldChatChatCommandPlugIn_Description {
get {
- return ResourceManager.GetString("VulcanusExperiencePlugIn_Description", resourceCulture);
+ return ResourceManager.GetString("WorldChatChatCommandPlugIn_Description", resourceCulture);
}
}
-
+
///
- /// Looks up a localized string similar to Map number.
+ /// Looks up a localized string similar to World chat command.
///
- public static string VulcanusMapConfiguration_MapNumber_Name {
+ public static string WorldChatChatCommandPlugIn_Name {
get {
- return ResourceManager.GetString("VulcanusMapConfiguration_MapNumber_Name", resourceCulture);
+ return ResourceManager.GetString("WorldChatChatCommandPlugIn_Name", resourceCulture);
}
}
}
diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx
index 59e45394ee..ffa4ab6a00 100644
--- a/src/GameLogic/Properties/PlugInResources.resx
+++ b/src/GameLogic/Properties/PlugInResources.resx
@@ -1990,11 +1990,13 @@
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.
+ How often a bot selling its goods on the market (Bot merchants plugin) may advertise them, 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.
+ Chance that a bot advertises when the interval is over. Only bots behind their open store on the market advertise, so nothing happens while the Bot merchants plugin is off.
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}.
+ Said now and then in the normal chat by a bot behind its open store on the market. Placeholders: {level}, {map}, {location}, {store} (the name of its store).
+ Merchant whisper replies
+ Answers of a bot behind its open store on the market to a whisper, or a mention of its name in the normal chat next to it, which asks what it sells ("what are you selling?"): they point to the store. Only a reply whose keyword matches is used, before the whisper replies; any other message gets a usual answer. Placeholders as in the whisper replies, and {store} (the name of its store).
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
@@ -2120,4 +2122,96 @@
The number of waiting items which are named, one per message.
+ Bot merchants
+ Lets server-side bots sell the goods of their personal store on the market map (Loren Market) for a while, then go back hunting.
+ Market map number
+ The number of the map on which the bots open their stores (79 = Loren Market). The bots get there through its spawn gate, regardless of the warp list.
+ Maximum merchants
+ The maximum number of bots with an open store at the same time, on the market and in the cities together, per game server. Only bots with enough goods go, so it's a ceiling - and they go back hunting after their shift.
+ Minimum level
+ The minimum level of a bot to go to the market. The game allows a personal store from level 6.
+ Minimum goods to start
+ The minimum number of priced goods in the personal store of a bot to go to the market.
+ Minimum goods to stay
+ When fewer goods are left, the bot closes its store and leaves the market early. 1 (default) = it stays as long as it has anything to sell.
+ Minimum shift duration
+ The shortest stay on the market. The actual length is random between the minimum and the maximum.
+ Maximum shift duration
+ The longest stay on the market.
+ Leave without sales after
+ After this time without a sale, the bot leaves the market before its shift is over and makes room for another one. Zero switches it off.
+ Time between shifts
+ The time after a shift before the same bot goes to the market again.
+ Store names
+ The names of the stores, separated by commas. Every shift draws one which no other open store has; when all are taken, the store gets the name of the bot.
+ High set minimum drop level
+ The drop level from which an item counts as one of the higher sets. Normal items need it to be stocked; above it, ancient and the best excellent items cost more.
+ Minimum normal item level
+ The minimum level (+) of a normal item of a higher set with luck to be stocked.
+ Zen price spread (%)
+ How far, up or down, a zen price may differ from the calculated one. It's drawn once, when the good goes into the store, and rounded to an amount a player would ask for, e.g. 800,000 or 5.5 million. Prices in jewels stay exact; 0 keeps all prices exact.
+ Cheap: currency
+ The currency of the cheap goods (normal items, excellent items with only a cheap option): zen, bless, soul, chaos, life or creation.
+ Cheap: price
+ The base price of the cheap goods.
+ Cheap: per level above +7
+ The surcharge of the cheap goods per item level above +7.
+ Cheap: per +4 option
+ The surcharge of the cheap goods per +4 of the additional option. Fractions are summed up and rounded down.
+ Medium: currency
+ The currency of the medium goods (excellent items with one wanted option, without luck): zen, bless, soul, chaos, life or creation.
+ Medium: price
+ The base price of the medium goods.
+ Medium: per level above +7
+ The surcharge of the medium goods per item level above +7.
+ Medium: per +4 option
+ The surcharge of the medium goods per +4 of the additional option. Fractions are summed up and rounded down, so 0.5 means one more per +8.
+ Expensive: currency
+ The currency of the expensive goods (ancient items, excellent items with two or more options or one wanted option and luck): zen, bless, soul, chaos, life or creation.
+ Expensive: one wanted option and luck
+ The base price of an excellent item with one wanted option and luck.
+ Expensive: two options or ancient
+ The base price of an excellent item with two options and of an ancient item.
+ Expensive: three or more options
+ The base price of an excellent item with three or more options.
+ Expensive: per level above +7
+ The surcharge of the expensive goods per item level above +7.
+ Expensive: per +4 option
+ The surcharge of the expensive goods per +4 of the additional option. Fractions are summed up and rounded down, so 0.5 means one more per +8.
+ Expensive: ancient with luck
+ The surcharge of an ancient item with luck.
+ Expensive: higher set surcharge
+ The surcharge of an ancient item, and of an excellent item with a wanted option, luck and an additional option, for every step of drop levels above the high set minimum drop level.
+ Expensive: drop levels per surcharge
+ How many drop levels make one step of the higher set surcharge.
+ Jewels: maximum per kind
+ How many jewels of one kind a bot puts into its store at most. 0 keeps the jewels out of the stores.
+ Jewel of Bless: currency
+ The currency for which a bot sells its surplus of the Jewel of Bless: zen or another jewel (bless, soul, chaos, life, creation).
+ Jewel of Bless: price
+ The price of one Jewel of Bless, in its currency.
+ Jewel of Soul: currency
+ The currency for which a bot sells its surplus of the Jewel of Soul: zen or another jewel (bless, soul, chaos, life, creation).
+ Jewel of Soul: price
+ The price of one Jewel of Soul, in its currency.
+ Jewel of Chaos: currency
+ The currency for which a bot sells its surplus of the Jewel of Chaos: zen or another jewel (bless, soul, chaos, life, creation).
+ Jewel of Chaos: price
+ The price of one Jewel of Chaos, in its currency.
+ Jewel of Life: currency
+ The currency for which a bot sells its surplus of the Jewel of Life: zen or another jewel (bless, soul, chaos, life, creation).
+ Jewel of Life: price
+ The price of one Jewel of Life, in its currency.
+ Jewel of Creation: currency
+ The currency for which a bot sells its surplus of the Jewel of Creation: zen or another jewel (bless, soul, chaos, life, creation).
+ Jewel of Creation: price
+ The price of one Jewel of Creation, in its currency.
+ Unsold shifts until NPC
+ After this many market shifts without a buyer, a good leaves the store and is sold to an NPC. There are no discounts.
+ City merchants (%)
+ The share of the shifts which a bot spends in one of the city maps instead of on the market. 0 keeps all merchants on the market.
+ City map numbers
+ The numbers of the city maps, separated by commas (0 = Lorencia, 2 = Devias, 3 = Noria, 51 = Elvenland). The bots open their store in the safezone near the spawn gate.
+ New merchants per 30 seconds
+ How many bots at most start a shift every 30 seconds. After a server start, the market fills up within minutes instead of all at once, and the parties of the bots don't fall apart together.
diff --git a/tests/MUnique.OpenMU.Tests/BotChatTest.cs b/tests/MUnique.OpenMU.Tests/BotChatTest.cs
index 5d50001e40..576b6516d6 100644
--- a/tests/MUnique.OpenMU.Tests/BotChatTest.cs
+++ b/tests/MUnique.OpenMU.Tests/BotChatTest.cs
@@ -569,6 +569,44 @@ public void DefaultRepliesNameTheWings(string message)
Assert.That(configuration.WithoutWingsLines.Select(l => l.Text.ValueInNeutralLanguage), Has.None.Contain("{wings}"));
}
+ ///
+ /// The default answers of a merchant point a player who asks what it sells to its store - and leave any
+ /// other whisper, like a question about its level, to the usual replies.
+ ///
+ /// The whispered message.
+ /// Whether the message asks what the bot sells.
+ [TestCase("what are you selling?", true)]
+ [TestCase("how much for the ring?", true)]
+ [TestCase("co sprzedajesz?", true)]
+ [TestCase("za ile ten miecz?", true)]
+ [TestCase("masz coś ciekawego?", true)]
+ [TestCase("wtb ring", true)]
+ [TestCase("ile masz lvl?", false)]
+ [TestCase("hi, how are you?", false)]
+ public void DefaultMerchantRepliesPointToTheStore(string message, bool asksForGoods)
+ {
+ var configuration = BotChatConfiguration.CreateDefault();
+
+ var reply = BotChatComposer.PickWhisperReply(configuration.MerchantWhisperReplies, message, _ => 0, requireKeyword: true);
+
+ Assert.That(reply is not null, Is.EqualTo(asksForGoods), message);
+ Assert.That(configuration.MerchantWhisperReplies.Select(r => r.Text.GetTranslation(CultureInfo.GetCultureInfo("pl"))), Has.All.Not.Empty);
+ }
+
+ ///
+ /// A merchant never answers like a bot without goods: every trade keyword of the usual replies is a
+ /// keyword of the merchant replies too.
+ ///
+ [Test]
+ public void DefaultMerchantRepliesCoverTheTradeKeywords()
+ {
+ var configuration = BotChatConfiguration.CreateDefault();
+ static IEnumerable Keywords(string? list) => (list ?? string.Empty).Split(',').Select(k => k.Trim()).Where(k => k.Length > 0);
+ var tradeKeywords = configuration.WhisperReplies.First(r => Keywords(r.Keywords).Contains("trade")).Keywords;
+
+ Assert.That(Keywords(configuration.MerchantWhisperReplies.First().Keywords), Is.SupersetOf(Keywords(tradeKeywords)));
+ }
+
///
/// 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.
diff --git a/tests/MUnique.OpenMU.Tests/BotMarketPlannerTest.cs b/tests/MUnique.OpenMU.Tests/BotMarketPlannerTest.cs
new file mode 100644
index 0000000000..ab8034bdba
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/BotMarketPlannerTest.cs
@@ -0,0 +1,639 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Bots;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Tests the decisions of a market shift of a bot (): where it puts its
+/// stall, how long it stays and when it leaves.
+///
+[TestFixture]
+public class BotMarketPlannerTest
+{
+ private static readonly Point Arrival = new(128, 145);
+
+ private static readonly DateTime Now = new(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// A stall keeps its distance from the stalls of the other merchants, so that every store can be clicked.
+ ///
+ [Test]
+ public void StallKeepsItsDistanceFromTheOtherStalls()
+ {
+ var terrain = CreateSafezoneTerrain();
+ var taken = new List { Arrival };
+
+ for (var i = 0; i < 50; i++)
+ {
+ var stall = BotMarketPlanner.FindStall(terrain, Arrival, taken, Random.Shared.Next);
+ Assert.That(stall, Is.Not.Null);
+ Assert.That(taken.All(t => t.EuclideanDistanceTo(stall!.Value) >= BotMarketPlanner.StallSpacing), Is.True);
+ taken.Add(stall!.Value);
+ }
+ }
+
+ ///
+ /// A stall is always a walkable safezone tile near the arrival point - a store outside the safezone
+ /// would be in the middle of the monsters.
+ ///
+ [Test]
+ public void StallIsInTheSafezoneNearTheArrival()
+ {
+ var terrain = CreateSafezoneTerrain();
+ var outside = new Point(Arrival.X, (byte)(Arrival.Y + 2));
+ terrain.SafezoneMap[outside.X, outside.Y] = false;
+
+ for (var i = 0; i < 200; i++)
+ {
+ var stall = BotMarketPlanner.FindStall(terrain, Arrival, [], Random.Shared.Next)!.Value;
+ Assert.That(stall, Is.Not.EqualTo(outside));
+ Assert.That(terrain.SafezoneMap[stall.X, stall.Y], Is.True);
+ Assert.That(stall.EuclideanDistanceTo(Arrival), Is.LessThanOrEqualTo(BotMarketPlanner.StallSearchRange));
+ }
+ }
+
+ ///
+ /// A stall keeps away from the walls, so that no store stands against one.
+ ///
+ [Test]
+ public void StallKeepsAwayFromTheWalls()
+ {
+ var terrain = CreateSafezoneTerrain();
+ for (var y = 0; y <= byte.MaxValue; y++)
+ {
+ terrain.WalkMap[Arrival.X + 4, y] = false;
+ }
+
+ for (var i = 0; i < 200; i++)
+ {
+ var stall = BotMarketPlanner.FindStall(terrain, Arrival, [], Random.Shared.Next)!.Value;
+ Assert.That(stall.X, Is.LessThanOrEqualTo(Arrival.X + 4 - BotMarketPlanner.StallMargin - 1));
+ }
+ }
+
+ ///
+ /// On the market, the stalls spread over the whole safezone, not only around the arrival point.
+ ///
+ [Test]
+ public void MarketStallsSpreadOverTheWholeSafezone()
+ {
+ var terrain = CreateSafezoneTerrain();
+
+ var stalls = Enumerable.Range(0, 50).Select(_ => BotMarketPlanner.FindStall(terrain, Arrival, [], Random.Shared.Next, null)!.Value).ToList();
+
+ Assert.That(stalls.Any(s => s.EuclideanDistanceTo(Arrival) > BotMarketPlanner.StallSearchRange), Is.True);
+ }
+
+ ///
+ /// A safezone behind a wall, which the bot can't walk to from the arrival point, gets no stall.
+ ///
+ [Test]
+ public void SafezoneBehindAWallGetsNoStall()
+ {
+ var terrain = CreateSafezoneTerrain();
+ const int wallX = 160;
+ for (var y = 0; y <= byte.MaxValue; y++)
+ {
+ terrain.WalkMap[wallX, y] = false;
+ }
+
+ for (var i = 0; i < 50; i++)
+ {
+ var stall = BotMarketPlanner.FindStall(terrain, Arrival, [], Random.Shared.Next, null)!.Value;
+ Assert.That(stall.X, Is.LessThan(wallX));
+ }
+ }
+
+ ///
+ /// A market without a free tile has no stall: the bot doesn't go there at all.
+ ///
+ [Test]
+ public void FullMarketHasNoStall()
+ {
+ var terrain = CreateSafezoneTerrain();
+ Array.Clear(terrain.WalkMap);
+ terrain.WalkMap[Arrival.X, Arrival.Y] = true;
+
+ Assert.That(BotMarketPlanner.FindStall(terrain, Arrival, [Arrival], Random.Shared.Next), Is.Null);
+ }
+
+ ///
+ /// About the configured share of the shifts takes place in the cities, the rest on the market.
+ ///
+ [Test]
+ public void ShareOfTheShiftsIsInTheCities()
+ {
+ var configuration = new BotMerchantConfiguration();
+ var cities = configuration.GetCityMapNumbers();
+ const int draws = 10_000;
+
+ var maps = Enumerable.Range(0, draws).Select(_ => BotMarketPlanner.DrawShiftMap(configuration, cities, Random.Shared.Next)).ToList();
+
+ Assert.That(cities, Is.EqualTo(new short[] { 0, 2, 3, 51 }));
+ Assert.That(maps.Count(m => m == configuration.MarketMapNumber), Is.InRange(7_500, 8_500));
+ Assert.That(maps.Where(m => m != configuration.MarketMapNumber).Distinct(), Is.EquivalentTo(cities));
+ }
+
+ ///
+ /// Without a share for the cities, or without usable cities, every shift is on the market.
+ ///
+ [Test]
+ public void WithoutCitiesEveryShiftIsOnTheMarket()
+ {
+ var noShare = new BotMerchantConfiguration { CityMerchantsPercent = 0 };
+ var noCities = new BotMerchantConfiguration { CityMerchantsPercent = 100, CityMapNumbers = "x, " };
+
+ Assert.That(BotMarketPlanner.DrawShiftMap(noShare, noShare.GetCityMapNumbers(), (_, max) => max - 1), Is.EqualTo(noShare.MarketMapNumber));
+ Assert.That(noCities.GetCityMapNumbers(), Is.Empty);
+ Assert.That(BotMarketPlanner.DrawShiftMap(noCities, noCities.GetCityMapNumbers(), (min, _) => min), Is.EqualTo(noCities.MarketMapNumber));
+ }
+
+ ///
+ /// No two open stores get the same name while there are free names - the default names suffice
+ /// for the maximum number of merchants.
+ ///
+ [Test]
+ public void StoreNamesDontRepeat()
+ {
+ var configuration = new BotMerchantConfiguration();
+ var storeNames = configuration.GetStoreNames();
+ var taken = new List();
+
+ for (var i = 0; i < configuration.MaximumMerchants; i++)
+ {
+ taken.Add(BotMarketPlanner.DrawStoreName(storeNames, taken, "Bot", Random.Shared.Next));
+ }
+
+ Assert.That(taken, Is.Unique);
+ Assert.That(taken, Has.None.EqualTo("Bot"));
+ }
+
+ ///
+ /// When all store names are taken, the store gets the name of the bot.
+ ///
+ [Test]
+ public void StoreGetsTheBotNameWhenAllNamesAreTaken()
+ {
+ Assert.That(BotMarketPlanner.DrawStoreName(["Rare finds"], ["rare finds"], "Raven", Random.Shared.Next), Is.EqualTo("Raven"));
+ }
+
+ ///
+ /// A bot may leave a party of bots for a market shift, but never a party with a player.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask OnlyAPartyOfBotsIsLeftForTheMarketAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var otherBot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ SetIsBot(bot, true);
+ SetIsBot(otherBot, true);
+ SetIsBot(player, false);
+
+ Assert.That(BotMarketPlanner.HasOnlyBots([bot, otherBot]), Is.True);
+ Assert.That(BotMarketPlanner.HasOnlyBots([bot, player]), Is.False);
+ }
+
+ ///
+ /// A player may join the party of a bot after the bot was picked for a shift: the bot then stays in the
+ /// party, while it leaves a party of bots.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask PartyWhichAPlayerJoinedIsNotLeftAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
+ var otherBot = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ SetIsBot(bot, true);
+ SetIsBot(otherBot, true);
+ SetIsBot(player, false);
+ var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger());
+ await party.AddAsync(bot).ConfigureAwait(false);
+ await party.AddAsync(otherBot).ConfigureAwait(false);
+ await party.AddAsync(player).ConfigureAwait(false);
+
+ Assert.That(await BotMerchantPlugIn.LeavePartyAsync(bot).ConfigureAwait(false), Is.False);
+ Assert.That(bot.Party, Is.SameAs(party));
+
+ await party.KickMySelfAsync(player).ConfigureAwait(false);
+ Assert.That(await BotMerchantPlugIn.LeavePartyAsync(bot).ConfigureAwait(false), Is.True);
+ Assert.That(bot.Party, Is.Null);
+ }
+
+ ///
+ /// After a server start, the bots go to the market a few at a time.
+ ///
+ [Test]
+ public void FewBotsStartAShiftAtATime()
+ {
+ var configuration = new BotMerchantConfiguration();
+
+ Assert.That(configuration.NewMerchantsPerCheck, Is.LessThan(configuration.MaximumMerchants));
+ }
+
+ ///
+ /// The length of a shift is random between the minimum and the maximum, both included.
+ ///
+ [Test]
+ public void ShiftDurationIsBetweenMinimumAndMaximum()
+ {
+ var configuration = new BotMerchantConfiguration();
+
+ Assert.That(BotMarketPlanner.DrawShiftDuration(configuration, (min, _) => min), Is.EqualTo(TimeSpan.FromMinutes(30)));
+ Assert.That(BotMarketPlanner.DrawShiftDuration(configuration, (_, max) => max - 1), Is.EqualTo(TimeSpan.FromMinutes(90)));
+ }
+
+ ///
+ /// A maximum below the minimum doesn't break the draw: the shift then lasts the minimum.
+ ///
+ [Test]
+ public void ShiftDurationWithMaximumBelowMinimumIsTheMinimum()
+ {
+ var configuration = new BotMerchantConfiguration { MaximumShiftDuration = TimeSpan.FromMinutes(10) };
+
+ Assert.That(BotMarketPlanner.DrawShiftDuration(configuration, (_, max) => max - 1), Is.EqualTo(TimeSpan.FromMinutes(30)));
+ }
+
+ ///
+ /// A shift ends when its time is up, even before the store was opened.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftEndsWhenTheTimeIsUpAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var shift = CreateShift();
+
+ var reason = BotMarketPlanner.GetEndReason(bot, shift, new BotMerchantConfiguration(), shift.UntilUtc);
+
+ Assert.That(reason, Is.EqualTo(BotMarketPlanner.EndReason.TimeIsUp));
+ }
+
+ ///
+ /// On the way to the stall, the store is still closed and empty shelves don't count yet.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftGoesOnWhileOnTheWayAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var shift = CreateShift();
+
+ var reason = BotMarketPlanner.GetEndReason(bot, shift, new BotMerchantConfiguration(), Now.AddMinutes(1));
+
+ Assert.That(reason, Is.EqualTo(BotMarketPlanner.EndReason.None));
+ }
+
+ ///
+ /// The game closes a store after its last item was sold - the shift ends with it.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftEndsWhenTheGameClosedTheStoreAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var shift = CreateShift();
+ shift.IsStoreOpened = true;
+
+ var reason = BotMarketPlanner.GetEndReason(bot, shift, new BotMerchantConfiguration(), Now.AddMinutes(1));
+
+ Assert.That(reason, Is.EqualTo(BotMarketPlanner.EndReason.StoreClosed));
+ }
+
+ ///
+ /// By default, a bot stays as long as it has anything to sell - a store of two items mustn't close
+ /// after the first sale.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftGoesOnWithTheLastGoodAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 1).ConfigureAwait(false);
+ bot.ShopStorage!.StoreOpen = true;
+ var shift = CreateShift();
+ shift.IsStoreOpened = true;
+
+ var reason = BotMarketPlanner.GetEndReason(bot, shift, new BotMerchantConfiguration(), Now.AddMinutes(1));
+
+ Assert.That(reason, Is.EqualTo(BotMarketPlanner.EndReason.None));
+ }
+
+ ///
+ /// With a higher minimum, a bot which sold nearly everything leaves early.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftEndsBelowTheMinimumOfGoodsAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 1).ConfigureAwait(false);
+ bot.ShopStorage!.StoreOpen = true;
+ var shift = CreateShift();
+ shift.IsStoreOpened = true;
+
+ var reason = BotMarketPlanner.GetEndReason(bot, shift, new BotMerchantConfiguration { MinimumGoodsToStay = 2 }, Now.AddMinutes(1));
+
+ Assert.That(reason, Is.EqualTo(BotMarketPlanner.EndReason.SoldOut));
+ }
+
+ ///
+ /// A bot which doesn't sell anything for the configured time leaves and makes room for another one.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask ShiftEndsAfterALongTimeWithoutSalesAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 3).ConfigureAwait(false);
+ bot.ShopStorage!.StoreOpen = true;
+ var shift = CreateShift();
+ shift.IsStoreOpened = true;
+ var configuration = new BotMerchantConfiguration();
+
+ Assert.That(BotMarketPlanner.GetEndReason(bot, shift, configuration, Now.AddMinutes(29)), Is.EqualTo(BotMarketPlanner.EndReason.None));
+ Assert.That(BotMarketPlanner.GetEndReason(bot, shift, configuration, Now.AddMinutes(30)), Is.EqualTo(BotMarketPlanner.EndReason.NoSales));
+
+ shift.LastSaleUtc = Now.AddMinutes(20);
+ Assert.That(BotMarketPlanner.GetEndReason(bot, shift, configuration, Now.AddMinutes(30)), Is.EqualTo(BotMarketPlanner.EndReason.None));
+
+ configuration.LeaveWithoutSalesAfter = TimeSpan.Zero;
+ Assert.That(BotMarketPlanner.GetEndReason(bot, shift, configuration, Now.AddMinutes(55)), Is.EqualTo(BotMarketPlanner.EndReason.None));
+ }
+
+ ///
+ /// Only goods with a price count: the game doesn't open a store with an item without one.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask OnlyPricedGoodsCountAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 2).ConfigureAwait(false);
+ var unpriced = CreateItem();
+ await bot.ShopStorage!.AddItemAsync((byte)(InventoryConstants.FirstStoreItemSlotIndex + 10), unpriced).ConfigureAwait(false);
+
+ Assert.That(BotMarketPlanner.CountPricedGoods(bot), Is.EqualTo(2));
+ }
+
+ ///
+ /// The bot buys nothing, so its income is removed: the zen above what it had when it opened the store,
+ /// and the items paid for its goods. What it had before stays.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask IncomeIsRemovedAsync()
+ {
+ var bot = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ bot.Money = 1000;
+ var ownJewel = CreateItem();
+ Assert.That(await bot.Inventory!.AddItemAsync(ownJewel).ConfigureAwait(false), Is.True);
+ var shift = CreateShift();
+ shift.MoneyAtOpen = bot.Money;
+ shift.ItemsAtOpen = bot.Inventory.Items.ToHashSet();
+ shift.IsStoreOpened = true;
+
+ bot.Money += 500_000;
+ var paidJewel = CreateItem();
+ Assert.That(await bot.Inventory.AddItemAsync(paidJewel).ConfigureAwait(false), Is.True);
+ await BotMerchandise.RemoveIncomeAsync(bot, shift).ConfigureAwait(false);
+
+ Assert.That(bot.Money, Is.EqualTo(1000));
+ Assert.That(bot.Inventory.Items, Does.Contain(ownJewel));
+ Assert.That(bot.Inventory.Items, Does.Not.Contain(paidJewel));
+ }
+
+ ///
+ /// A bot whose store the game doesn't open ends its shift like any other one, so it waits for its
+ /// next shift instead of coming back right away to fail again.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask FailedStoreOpeningWaitsForTheNextShiftAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext([new PlugInConfiguration { TypeId = typeof(BotMerchantPlugIn).GUID, IsActive = true }]);
+ var configuration = new BotMerchantConfiguration();
+ gameContext.FeaturePlugIns.GetPlugIn()!.Configuration = configuration;
+ var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
+ Assert.That(await bot.ShopStorage!.AddItemAsync(InventoryConstants.FirstStoreItemSlotIndex, CreateItem()).ConfigureAwait(false), Is.True);
+ var shift = CreateShift();
+ bot.MarketShift = shift;
+ var before = DateTime.UtcNow;
+
+ await new BotNavigator(bot).OpenMarketStoreAsync(shift).ConfigureAwait(false);
+ await bot.DrainPendingBotActionsAsync().ConfigureAwait(false);
+
+ Assert.That(bot.ShopStorage.StoreOpen, Is.False);
+ Assert.That(bot.MarketShift, Is.Null);
+ Assert.That(bot.NextMarketShiftUtc, Is.GreaterThanOrEqualTo(before + configuration.TimeBetweenShifts));
+ }
+
+ ///
+ /// A store whose opening was queued before the plugin was switched off stays closed; the next tick
+ /// of the bot ends the shift.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask StoreDoesntOpenAfterThePlugInWasSwitchedOffAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext([new PlugInConfiguration { TypeId = typeof(BotMerchantPlugIn).GUID, IsActive = true }]);
+ gameContext.FeaturePlugIns.GetPlugIn()!.Configuration = new BotMerchantConfiguration();
+ var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 2).ConfigureAwait(false);
+ var shift = CreateShift();
+ bot.MarketShift = shift;
+ var navigator = new BotNavigator(bot);
+
+ gameContext.PlugInManager.DeactivatePlugIn();
+ await navigator.OpenMarketStoreAsync(shift).ConfigureAwait(false);
+ Assert.That(bot.ShopStorage!.StoreOpen, Is.False);
+ Assert.That(shift.IsStoreOpened, Is.False);
+
+ gameContext.PlugInManager.ActivatePlugIn();
+ gameContext.FeaturePlugIns.GetPlugIn()!.Configuration = new BotMerchantConfiguration();
+ await navigator.OpenMarketStoreAsync(shift).ConfigureAwait(false);
+ Assert.That(bot.ShopStorage.StoreOpen, Is.True);
+ Assert.That(shift.IsStoreOpened, Is.True);
+ }
+
+ ///
+ /// The income of a sale is removed in the next tick of the bot. A bot which logs out before that
+ /// tick removes it on the way out - otherwise the logout would save the income with the bot.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask IncomeIsRemovedWhenTheBotLogsOutRightAfterASaleAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ var buyer = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var template = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var bot = new BotPlayer(gameContext) { Account = template.Account };
+ await bot.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await bot.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await bot.PlayerState.TryAdvanceToAsync(PlayerState.CharacterSelection).ConfigureAwait(false);
+ await bot.SetSelectedCharacterAsync(template.SelectedCharacter!).ConfigureAwait(false);
+ var character = bot.SelectedCharacter!;
+ var inventory = bot.Inventory!;
+ var shopStorage = bot.ShopStorage!;
+
+ await AddPricedGoodsAsync(bot, 2).ConfigureAwait(false);
+ bot.Money = 1000;
+ var shift = CreateShift();
+ shift.MoneyAtOpen = bot.Money;
+ shift.ItemsAtOpen = inventory.Items.ToHashSet();
+ shift.IsStoreOpened = true;
+ shopStorage.StoreOpen = true;
+ bot.MarketShift = shift;
+
+ bot.Money += 810_000;
+ var paidJewel = CreateItem();
+ Assert.That(await inventory.AddItemAsync(paidJewel).ConfigureAwait(false), Is.True);
+ new BotMerchantPlugIn().ItemSold(bot, shopStorage.Items.First(), buyer);
+ Assert.That(bot.PendingBotActions, Is.Not.Empty);
+
+ await bot.DisconnectAsync().ConfigureAwait(false);
+
+ Assert.That(character.Inventory!.Money, Is.EqualTo(1000));
+ Assert.That(inventory.Items, Does.Not.Contain(paidJewel));
+ Assert.That(shopStorage.StoreOpen, Is.False);
+ Assert.That(bot.MarketShift, Is.Null);
+ }
+
+ ///
+ /// The shifts of a good without a buyer are forgotten when it wasn't counted for a long time - it left
+ /// the stores without a sale, e.g. a retired good which went to an NPC -, so the counts don't pile up.
+ ///
+ [Test]
+ public void OldUnsoldShiftsAreForgotten()
+ {
+ var plugIn = new BotMerchantPlugIn();
+ var configuration = new BotMerchantConfiguration();
+ var retired = new MUnique.OpenMU.Persistence.BasicModel.Item { Id = Guid.NewGuid() };
+ var inStore = new MUnique.OpenMU.Persistence.BasicModel.Item { Id = Guid.NewGuid() };
+ for (var i = 0; i < configuration.UnsoldShiftsUntilNpc; i++)
+ {
+ plugIn.CountUnsoldShift(retired, Now);
+ }
+
+ plugIn.CountUnsoldShift(inStore, Now.AddHours(20));
+
+ plugIn.ForgetOldUnsoldShifts(configuration, Now.AddHours(23));
+ Assert.That(plugIn.UnsoldGoodCount, Is.EqualTo(2));
+ Assert.That(plugIn.IsRetired(retired, configuration), Is.True);
+
+ plugIn.ForgetOldUnsoldShifts(configuration, Now.AddHours(25));
+ Assert.That(plugIn.UnsoldGoodCount, Is.EqualTo(1));
+ Assert.That(plugIn.IsRetired(retired, configuration), Is.False);
+ Assert.That(plugIn.CountUnsoldShift(inStore, Now.AddHours(25)), Is.EqualTo(2));
+ }
+
+ ///
+ /// A sale right before the end of a shift queues a removal of its income, which runs after the store
+ /// was closed. By then, the goods without a buyer are back in the inventory - they aren't income and
+ /// stay with the bot, to be sold to an NPC.
+ ///
+ /// The task.
+ [Test]
+ public async ValueTask LateIncomeRemovalKeepsTheRetiredGoodsAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext([new PlugInConfiguration { TypeId = typeof(BotMerchantPlugIn).GUID, IsActive = true }]);
+ var plugIn = gameContext.FeaturePlugIns.GetPlugIn()!;
+ plugIn.Configuration = new BotMerchantConfiguration { UnsoldShiftsUntilNpc = 1 };
+ var buyer = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
+ await AddPricedGoodsAsync(bot, 2).ConfigureAwait(false);
+ var goods = bot.ShopStorage!.Items.ToList();
+ bot.Money = 1000;
+ var shift = CreateShift();
+ shift.MoneyAtOpen = bot.Money;
+ shift.ItemsAtOpen = bot.Inventory!.Items.ToHashSet();
+ shift.IsStoreOpened = true;
+ bot.ShopStorage.StoreOpen = true;
+ bot.MarketShift = shift;
+
+ plugIn.ItemSold(bot, goods[0], buyer);
+ await BotMerchandise.CloseShiftStoreAsync(bot, shift).ConfigureAwait(false);
+ Assert.That(bot.Inventory.Items, Is.SupersetOf(goods));
+
+ await bot.DrainPendingBotActionsAsync().ConfigureAwait(false);
+
+ Assert.That(bot.Inventory.Items, Is.SupersetOf(goods));
+ Assert.That(bot.Money, Is.EqualTo(1000));
+ }
+
+ private static BotMarketShift CreateShift()
+ => new(new GameMapDefinition { Number = BotMerchantConfiguration.DefaultMarketMapNumber }, Arrival, "Good stuff", Now, Now.AddMinutes(60));
+
+ private static async ValueTask AddPricedGoodsAsync(Player bot, int count)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ var item = CreateItem();
+ item.StorePrice = 1000;
+ Assert.That(await bot.ShopStorage!.AddItemAsync((byte)(InventoryConstants.FirstStoreItemSlotIndex + (i * 2)), item).ConfigureAwait(false), Is.True);
+ }
+ }
+
+ private static Item CreateItem()
+ {
+ var definitionMock = new Mock();
+ definitionMock.SetupAllProperties();
+ definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List());
+ definitionMock.Setup(d => d.PossibleItemSetGroups).Returns(new List());
+ var definition = definitionMock.Object;
+ definition.Width = 1;
+ definition.Height = 1;
+
+ var itemMock = new Mock- ();
+ itemMock.SetupAllProperties();
+ itemMock.Setup(i => i.ItemOptions).Returns(new List());
+ itemMock.Setup(i => i.ItemSetGroups).Returns(new List());
+ var item = itemMock.Object;
+ item.Definition = definition;
+ return item;
+ }
+
+ ///
+ /// Gives the player an account which is a bot account or not.
+ ///
+ private static void SetIsBot(Player player, bool isBot)
+ {
+ var account = new Mock();
+ account.SetupAllProperties();
+ account.Object.IsBot = isBot;
+ player.Account = account.Object;
+ }
+
+ ///
+ /// Creates a terrain on which every tile is walkable and in the safezone, like the middle of a town.
+ ///
+ private static GameMapTerrain CreateSafezoneTerrain()
+ {
+ var terrain = new GameMapTerrain((byte[]?)null);
+ for (var x = 0; x <= byte.MaxValue; x++)
+ {
+ for (var y = 0; y <= byte.MaxValue; y++)
+ {
+ terrain.WalkMap[x, y] = true;
+ terrain.SafezoneMap[x, y] = true;
+ }
+ }
+
+ return terrain;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/BotMerchandiseTest.cs b/tests/MUnique.OpenMU.Tests/BotMerchandiseTest.cs
new file mode 100644
index 0000000000..59d19017a5
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/BotMerchandiseTest.cs
@@ -0,0 +1,221 @@
+//
+// 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.AttributeSystem;
+using MUnique.OpenMU.DataModel.Attributes;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Bots;
+
+///
+/// Tests which loot a bot puts on its stall and what it costs (), with the
+/// default prices of .
+///
+[TestFixture]
+public class BotMerchandiseTest
+{
+ private readonly BotMerchantConfiguration _configuration = new();
+
+ ///
+ /// The prices of typical goods.
+ ///
+ /// What the item is.
+ /// The drop level of the item.
+ /// The level (+) of the item.
+ /// Whether the item has luck.
+ /// The level of the additional option: 1 for +4, 2 for +8 and so on.
+ /// Whether the item is ancient.
+ /// The excellent options: "DD", "DSR", "Reflect", "HP", "Mana", "EDR", "Speed".
+ /// The name of the expected price class (the enum is internal).
+ /// The expected currency.
+ /// The expected amount.
+ [TestCase("normal Dragon Armor +7, luck, +4", 60, 7, true, 1, false, "", "Cheap", "zen", 2_500_000)]
+ [TestCase("normal Legendary Helm +9, luck, +8", 70, 9, true, 2, false, "", "Cheap", "zen", 5_000_000)]
+ [TestCase("exc Sphinx Mask +6 (HP), luck", 70, 6, true, 0, false, "HP", "Cheap", "zen", 2_000_000)]
+ [TestCase("exc Brass Armor +5 (DD)", 30, 5, false, 0, false, "DD", "Medium", "bless", 2)]
+ [TestCase("exc Dragon Gloves +7 (DSR), +8", 60, 7, false, 2, false, "DSR", "Medium", "bless", 3)]
+ [TestCase("exc Brass Armor +7 (Reflect), luck, +4", 30, 7, true, 1, false, "Reflect", "Expensive", "soul", 3)]
+ [TestCase("exc Lightning Sword +6 (EDR, 2%)", 60, 6, false, 0, false, "EDR,Speed", "Expensive", "soul", 5)]
+ [TestCase("exc Dragon Armor +9 (DD, DSR), luck, +8", 80, 9, true, 2, false, "DD,DSR", "Expensive", "soul", 9)]
+ [TestCase("exc staff +7 (EDR, speed, 2%), luck", 90, 7, true, 0, false, "EDR,Speed,DD", "Expensive", "soul", 10)]
+ [TestCase("ancient Hyon Dragon Gloves +7", 60, 7, false, 0, true, "", "Expensive", "soul", 5)]
+ [TestCase("ancient pants +9, luck, +12", 100, 9, true, 3, true, "", "Expensive", "soul", 11)]
+ public void PricesOfTypicalGoods(string description, int dropLevel, int level, bool luck, int optionLevel, bool ancient, string excellentOptions, string expectedClass, string expectedCurrency, int expectedAmount)
+ {
+ var item = CreateItem(dropLevel, level, luck, optionLevel, ancient, excellentOptions);
+
+ var priceClass = BotMerchandise.Classify(item, this._configuration);
+ var price = BotMerchandise.CalculatePrice(item, priceClass, this._configuration);
+
+ Assert.That(priceClass.ToString(), Is.EqualTo(expectedClass), description);
+ Assert.That(price, Is.EqualTo((expectedCurrency, expectedAmount)), description);
+ }
+
+ ///
+ /// A zen price is drawn within the spread and rounded the way a player would ask for it.
+ ///
+ /// The calculated price.
+ /// The spread in percent.
+ /// The random number: 0 for the lowest price, 1 for the highest.
+ /// The expected price for the store.
+ [TestCase(810_000, 10, 0.0, 750_000)]
+ [TestCase(810_000, 10, 0.5, 800_000)]
+ [TestCase(810_000, 10, 1.0, 900_000)]
+ [TestCase(6_000_000, 10, 0.0, 5_400_000)]
+ [TestCase(6_000_000, 10, 0.3, 5_800_000)]
+ [TestCase(45_000_000, 10, 0.75, 47_000_000)]
+ [TestCase(45_000_000, 10, 0.0, 41_000_000)]
+ [TestCase(30_000, 10, 0.0, 27_000)]
+ [TestCase(500, 10, 1.0, 550)]
+ [TestCase(2_000_000, 0, 0.9, 2_000_000)]
+ [TestCase(810_123, 0, 0.0, 810_123)]
+ [TestCase(int.MaxValue, 50, 1.0, int.MaxValue)]
+ public void ZenPriceSpread(int amount, int spreadPercent, double random, int expected)
+ {
+ Assert.That(BotMerchandise.SpreadZenPrice(amount, spreadPercent, random), Is.EqualTo(expected));
+ }
+
+ ///
+ /// The loot which isn't worth a stall goes to the NPC as before.
+ ///
+ /// What the item is.
+ /// The drop level of the item.
+ /// The level (+) of the item.
+ /// Whether the item has luck.
+ /// The excellent options.
+ [TestCase("normal Dragon Armor +7 without luck", 60, 7, false, "")]
+ [TestCase("normal Dragon Armor +6 with luck", 60, 6, true, "")]
+ [TestCase("normal Leather Armor +9 with luck - a low set", 10, 9, true, "")]
+ [TestCase("exc armor with only mana, without luck", 60, 5, false, "Mana")]
+ public void LootWhichIsNotWorthAStall(string description, int dropLevel, int level, bool luck, string excellentOptions)
+ {
+ var item = CreateItem(dropLevel, level, luck, 0, false, excellentOptions);
+
+ Assert.That(BotMerchandise.Classify(item, this._configuration), Is.EqualTo(BotMerchandise.PriceClass.None), description);
+ Assert.That(BotMerchandise.CalculatePrice(item, BotMerchandise.PriceClass.None, this._configuration), Is.Null, description);
+ }
+
+ ///
+ /// An item which the game doesn't let into a personal store stays out of it, whatever its options.
+ ///
+ [Test]
+ public void ItemWhichCantBeSoldInAStoreIsNoGood()
+ {
+ var item = CreateItem(80, 9, true, 2, true, "DD,DSR");
+ item.Definition!.IsPersonalStoreSellable = false;
+
+ Assert.That(BotMerchandise.Classify(item, this._configuration), Is.EqualTo(BotMerchandise.PriceClass.None));
+ }
+
+ ///
+ /// The game doesn't open a store with a harmony option item, so it's no good either.
+ ///
+ [Test]
+ public void ItemWithHarmonyOptionIsNoGood()
+ {
+ var item = CreateItem(80, 9, true, 2, false, "DD,DSR");
+ item.ItemOptions.Add(new ItemOptionLink { ItemOption = new IncreasableItemOption { OptionType = ItemOptionTypes.HarmonyOption } });
+
+ Assert.That(BotMerchandise.Classify(item, this._configuration), Is.EqualTo(BotMerchandise.PriceClass.None));
+ }
+
+ ///
+ /// A surplus jewel is sold for its value in zen by default.
+ ///
+ [Test]
+ public void SurplusJewelIsSoldForItsValueInZen()
+ {
+ var bless = CreateJewel(13);
+
+ Assert.That(BotMerchandise.Classify(bless, this._configuration), Is.EqualTo(BotMerchandise.PriceClass.Jewel));
+ Assert.That(BotMerchandise.CalculatePrice(bless, BotMerchandise.PriceClass.Jewel, this._configuration), Is.EqualTo(("zen", 9_000_000)));
+ }
+
+ ///
+ /// The admin decides for each jewel for what it's exchanged, e.g. a Life for five Bless.
+ ///
+ [Test]
+ public void JewelCanBeExchangedForAnotherJewel()
+ {
+ var configuration = new BotMerchantConfiguration { LifeCurrency = "bless", LifePrice = 5 };
+ var life = CreateJewel(16);
+
+ Assert.That(BotMerchandise.CalculatePrice(life, BotMerchandise.Classify(life, configuration), configuration), Is.EqualTo(("bless", 5)));
+ }
+
+ private static Item CreateItem(int dropLevel, int level, bool luck, int optionLevel, bool ancient, string excellentOptions)
+ {
+ var definitionMock = new Mock();
+ definitionMock.SetupAllProperties();
+ definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List());
+ definitionMock.Setup(d => d.PossibleItemSetGroups).Returns(new List());
+ var definition = definitionMock.Object;
+ definition.Width = 2;
+ definition.Height = 2;
+ definition.DropLevel = (byte)dropLevel;
+ definition.IsPersonalStoreSellable = true;
+
+ var itemMock = new Mock
- ();
+ itemMock.SetupAllProperties();
+ itemMock.Setup(i => i.ItemOptions).Returns(new List());
+ itemMock.Setup(i => i.ItemSetGroups).Returns(new List());
+ var item = itemMock.Object;
+ item.Definition = definition;
+ item.Level = (byte)level;
+ if (luck)
+ {
+ item.ItemOptions.Add(new ItemOptionLink { ItemOption = new IncreasableItemOption { OptionType = ItemOptionTypes.Luck } });
+ }
+
+ if (optionLevel > 0)
+ {
+ item.ItemOptions.Add(new ItemOptionLink { ItemOption = new IncreasableItemOption { OptionType = ItemOptionTypes.Option }, Level = optionLevel });
+ }
+
+ if (ancient)
+ {
+ item.ItemSetGroups.Add(new ItemOfItemSet { AncientSetDiscriminator = 1 });
+ }
+
+ foreach (var option in excellentOptions.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ {
+ item.ItemOptions.Add(new ItemOptionLink
+ {
+ ItemOption = new IncreasableItemOption
+ {
+ OptionType = ItemOptionTypes.Excellent,
+ PowerUpDefinition = new PowerUpDefinition { TargetAttribute = GetAttribute(option) },
+ },
+ });
+ }
+
+ return item;
+ }
+
+ private static Item CreateJewel(short number)
+ {
+ var jewel = CreateItem(0, 0, false, 0, false, string.Empty);
+ jewel.Definition!.Group = 14;
+ jewel.Definition.Number = number;
+ jewel.Definition.Width = 1;
+ jewel.Definition.Height = 1;
+ return jewel;
+ }
+
+ private static AttributeDefinition GetAttribute(string option) => option switch
+ {
+ "DD" => Stats.ArmorDamageDecrease,
+ "DSR" => Stats.DefenseRatePvm,
+ "Reflect" => Stats.DamageReflection,
+ "HP" => Stats.MaximumHealth,
+ "Mana" => Stats.MaximumMana,
+ "EDR" => Stats.ExcellentDamageChance,
+ "Speed" => Stats.AttackSpeedAny,
+ _ => throw new ArgumentException(option),
+ };
+}