Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aa0dbbf
feat(bots): let bots sell their goods on market shifts
nolt Nov 5, 2026
3fb871d
feat(bots): stock the bots' stores with their best loot
nolt Nov 5, 2026
664d961
feat(bots): let the bots sell their surplus jewels
nolt Nov 5, 2026
8bf1fa3
feat(bots): remove the merchants' income and retire unsold goods
nolt Nov 5, 2026
6acd286
feat(bots): let only the merchants on the market advertise
nolt Nov 5, 2026
02d6cff
docs(bots): describe selling on the market
nolt Nov 5, 2026
78a9dd0
fix(bots): give every open store of the merchants its own name
nolt Nov 6, 2026
c54d4ed
feat(bots): let a share of the merchants sell in the cities
nolt Nov 6, 2026
4fb67a4
fix(bots): use store names which fit any goods
nolt Nov 6, 2026
0bcda9b
fix(bots): let bots leave their party of bots for a market shift
nolt Nov 6, 2026
d173bd8
fix(bots): spread the market stalls and keep them away from the walls
nolt Nov 6, 2026
49a9866
fix(bots): send the merchant to the market when its city is full
nolt Nov 6, 2026
7943af0
fix(bots): put at most two jewels of a kind into a merchant's store
nolt Nov 8, 2026
3523949
fix(bots): wait for the next market shift after a store failed to open
nolt Nov 8, 2026
56b3e22
fix(bots): remove the market income of a bot which logs out
nolt Nov 8, 2026
95ca69e
fix(bots): forget the unsold shifts of goods which left the market
nolt Nov 14, 2026
dd86931
fix(bots): check a merchant again before it leaves its party
nolt Nov 14, 2026
6d7518a
fix(bots): keep the retired goods when a late sale removes the income
nolt Nov 14, 2026
d8c7410
fix(bots): don't open a merchant's store after the plugin was switche…
nolt Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion docs-website/docs/server-features/bots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
60 changes: 44 additions & 16 deletions src/GameLogic/Bots/BotChatConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,20 @@ public class BotChatConfiguration
public int KilledByPlayerChancePercent { get; set; } = 70;

/// <summary>
/// Gets or sets how often a bot standing in a safezone may advertise like a merchant (see
/// <see cref="MerchantLines"/>), per game server.
/// Gets or sets how often a bot selling its goods on the market may advertise them (see
/// <see cref="MerchantLines"/> and <see cref="BotMerchantPlugIn"/>), per game server.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantTalkInterval_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantTalkInterval_Description))]
public TimeSpan MerchantTalkInterval { get; set; } = TimeSpan.FromMinutes(10);

/// <summary>
/// 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 <see cref="Conversations"/>) 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
/// <see cref="BotMerchantPlugIn"/> is off.
/// </summary>
[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;

/// <summary>
/// Gets or sets how often a human player may overhear a conversation of two bots near them (see
Expand Down Expand Up @@ -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<BotChatReply> WhisperReplies { get; set; } = new List<BotChatReply>();

/// <summary>
/// Gets or sets the answers of a bot behind its open store on the market (see <see cref="BotMerchantPlugIn"/>)
/// 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 <see cref="WhisperReplies"/>; 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 <see cref="WhisperReplies"/>, and <c>{store}</c>.
/// </summary>
[MemberOfAggregate]
[ScaffoldColumn(true)]
[CollapsedByDefault]
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.BotChatConfiguration_MerchantWhisperReplies_Name), Description = nameof(PlugInResources.BotChatConfiguration_MerchantWhisperReplies_Description))]
public ICollection<BotChatReply> MerchantWhisperReplies { get; set; } = new List<BotChatReply>();

/// <summary>
/// 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,
Expand Down Expand Up @@ -567,9 +581,9 @@ public class BotChatConfiguration
public ICollection<BotChatLine> KilledAgainLines { get; set; } = new List<BotChatLine>();

/// <summary>
/// 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 <see cref="MerchantTalkInterval"/>) - only when a player sees it.
/// Placeholders: <c>{level}</c>, <c>{map}</c>, <c>{location}</c>.
/// Placeholders: <c>{level}</c>, <c>{map}</c>, <c>{location}</c>, <c>{store}</c> (the name of its store).
/// </summary>
[MemberOfAggregate]
[ScaffoldColumn(true)]
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });

Expand Down
23 changes: 19 additions & 4 deletions src/GameLogic/Bots/BotChatPlugIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -458,7 +458,7 @@ await sender.ForEachObservingAsync<Player>(

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;
Expand Down Expand Up @@ -886,10 +886,12 @@ private static bool IsOfWantedClass(Player bot, BotPartyRequest request, ICollec

/// <summary>
/// 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.
/// </summary>
private static bool IsFreeToMeet(OfflinePlayer bot)
=> bot.EventMeeting is null
&& bot.MarketShift is null
&& !bot.IsOnShoppingTrip
&& !bot.IsOnBuffTrip
&& !bot.IsOnChaosMachineTrip
Expand Down Expand Up @@ -1009,6 +1011,7 @@ private static Dictionary<string, Func<CultureInfo, string>> 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,
};
}

Expand Down Expand Up @@ -1105,6 +1108,15 @@ private bool MayTalkUnasked(Player bot, BotChatConfiguration configuration)
return line;
}

/// <summary>
/// Picks the answer of a bot behind its open store on the market to a message which asks what it sells;
/// <c>null</c> for any other bot or message.
/// </summary>
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;

/// <summary>
/// Picks the reply to the player's message, other than the reply the player got last time.
/// </summary>
Expand Down Expand Up @@ -1298,7 +1310,10 @@ private async ValueTask MakeMerchantTalkAsync(GameContext gameContext, BotChatCo

var players = await gameContext.GetPlayersAsync().ConfigureAwait(false);
var candidates = new List<Player>();
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))
Expand Down
5 changes: 3 additions & 2 deletions src/GameLogic/Bots/BotManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,14 @@ public async ValueTask<bool> RestartBotAsync(IGameContext gameContext, BotPlayer
/// <summary>
/// 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 <see cref="BotMarketShift"/>).
/// </summary>
/// <param name="gameContext">The game context (provides the party manager).</param>
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();

Expand Down
Loading
Loading